claude-git-hooks 2.34.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,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.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
+
45
+ ## [2.35.0] - 2026-03-27
46
+
47
+ ### ✨ Added
48
+ - Prettier formatting support — auto-formats JS/TS/CSS/HTML/JSON/YAML/MD files before linting (format first, then lint)
49
+ - Remote formatter configuration — preset-to-tools mapping fetched from centralized git-hooks-config repo, allowing team-wide control without releasing new versions
50
+ - New `parsePrettierOutput()` function for parsing Prettier --check output into structured issues
51
+
52
+ ### 🔧 Changed
53
+ - Linting pipeline now runs formatters (Prettier) before linters (ESLint) for consistent code style
54
+ - Updated preset-to-tools mapping: frontend, fullstack, ai, and default presets now include Prettier
55
+ - `getLinterToolsForPreset()` now fetches remote config with local fallback instead of using hardcoded mapping only
56
+ - `runLinters()` and `checkLinterAvailability()` converted to async functions to support remote config fetching
57
+ - Unfixable linting issues are now forwarded to the Claude judge for semantic resolution instead of blocking directly
58
+
59
+
8
60
  ## [2.34.0] - 2026-03-27
9
61
 
10
62
  ### ✨ Added
package/CLAUDE.md CHANGED
@@ -6,14 +6,14 @@
6
6
 
7
7
  **Main use cases:**
8
8
 
9
- 1. **Pre-commit linting**: Runs linters (ESLint, Spotless, sqlfluff) on staged files before Claude analysis — fast, deterministic, auto-fix enabled by default
9
+ 1. **Pre-commit linting**: Runs formatters and linters (Prettier, ESLint, Spotless, sqlfluff) on staged files before Claude analysis — fast, deterministic, auto-fix enabled by default
10
10
  2. **Pre-commit analysis**: Detects security issues, bugs, and code smells before each commit (blocks on CRITICAL/BLOCKER only)
11
11
  3. **Interactive analysis**: `claude-hooks analyze` - review all issues (INFO to BLOCKER) interactively before committing
12
12
  4. **Automatic messages**: Write `git commit -m "auto"` and Claude generates the message in Conventional Commits format with task-id extracted from branch
13
13
  5. **PR analysis**: `claude-hooks analyze-diff [branch]` generates title, description, and test plan for PRs
14
14
  6. **PR review**: `claude-hooks analyze-pr <url>` analyzes a GitHub PR with preset guidelines, Linear ticket enrichment, and posts review comments
15
15
  7. **PR creation**: `claude-hooks create-pr [branch]` creates the PR on GitHub with automatic metadata (reviewers from CODEOWNERS, labels by preset, merge strategy auto-detected from branch naming)
16
- 8. **Linting**: `claude-hooks lint [paths...]` runs linters on staged files, directories, or specific files — supports ESLint, Spotless, sqlfluff per preset
16
+ 8. **Linting**: `claude-hooks lint [paths...]` runs formatters and linters on staged files, directories, or specific files — supports Prettier, ESLint, Spotless, sqlfluff per preset (remote config priority with local fallback)
17
17
  9. **Coupling detection**: `claude-hooks check-coupling` scans open PRs targeting a base branch, computes file overlap, and reports which features are coupled (share modified files) — helps TL make informed decisions before cutting a release
18
18
  10. **Shadow management**: `claude-hooks shadow <analyze|reset|sync>` manages the shadow branch lifecycle — analyze divergence vs main and active RC, reset shadow to a clean copy of main, or sync shadow with a source branch (RC, develop, feature)
19
19
  11. **Release creation**: `claude-hooks create-release <major|minor|patch>` creates a release-candidate branch from develop, bumps version files, commits, pushes, and deploys to shadow — replaces the 8 manual steps executed every Tuesday by the Tech Lead
@@ -86,7 +86,7 @@ claude-git-hooks/
86
86
  │ │ └── prepare-commit-msg.js # Message generation - auto commit messages
87
87
  │ └── utils/ # Reusable modules - shared logic
88
88
  │ ├── tool-runner.js # Generic tool executor - resolve, spawn, parse, auto-fix (v2.34.0)
89
- │ ├── linter-runner.js # Linter orchestration - preset mapping, ESLint/Spotless/sqlfluff (v2.34.0)
89
+ │ ├── linter-runner.js # Linter orchestration - preset mapping, Prettier/ESLint/Spotless/sqlfluff, remote config (v2.34.0)
90
90
  │ ├── analysis-engine.js # Shared analysis logic - file data, 3-tier routing, results (v2.13.0+)
91
91
  │ ├── diff-analysis-orchestrator.js # Intelligent batch orchestration via Opus (v2.20.0)
92
92
  │ ├── claude-client.js # Claude CLI wrapper - spawn, retry, model override
@@ -183,6 +183,7 @@ preset config (.claude/presets/{name}/config.json) ← HIGHEST PRIORITY
183
183
  **Team-wide remote config** ([`mscope-S-L/git-hooks-config`](https://github.com/mscope-S-L/git-hooks-config)):
184
184
 
185
185
  - `labels.json` — PR label rules (fetched by `remote-config.js`, consumed by `label-resolver.js`)
186
+ - `formatters.json` — preset-to-tools mapping for linting/formatting (fetched by `remote-config.js`, consumed by `linter-runner.js`)
186
187
  - `permissions.json` — role-based authorization (fetched directly by `authorization.js`, fail-closed)
187
188
  - Changes take effect immediately across all governed repos — no tool update needed
188
189
 
@@ -319,7 +320,7 @@ consolidateResults()
319
320
  | `lib/cli-metadata.js` | Command registry | `commands`, `buildCommandMap()`, `generateCompletionData()`, `PRESET_NAMES`, `HOOK_NAMES`, `BUMP_TYPES` |
320
321
  | `lib/config.js` | Config system | `getConfig()` |
321
322
  | `tool-runner.js` | Generic tool executor | `isToolAvailable()`, `filterFilesByTool()`, `runTool()`, `runToolFix()`, `runToolWithAutoFix()`, `displayToolResult()` (v2.34.0) |
322
- | `linter-runner.js` | Linter orchestration | `runLinters()`, `displayLintResults()`, `checkLinterAvailability()`, `getLinterToolsForPreset()`, `LINTER_TOOLS`, `PRESET_LINTERS`, `parseEslintOutput()`, `parseSpotlessOutput()`, `parseSqlfluffOutput()`, `filesToSpotlessRegex()` (v2.34.0) |
323
+ | `linter-runner.js` | Linter orchestration | `runLinters()`, `displayLintResults()`, `checkLinterAvailability()`, `getLinterToolsForPreset()`, `LINTER_TOOLS`, `PRESET_LINTERS`, `parsePrettierOutput()`, `parseEslintOutput()`, `parseSpotlessOutput()`, `parseSqlfluffOutput()`, `filesToSpotlessRegex()` (v2.34.0) |
323
324
  | `analysis-engine.js` | Shared analysis logic | `buildFileData()`, `buildFilesData()`, `runAnalysis()`, `consolidateResults()`, `hasBlockingIssues()`, `hasAnyIssues()`, `displayResults()`, `displayIssueSummary()` (v2.13.0+) |
324
325
  | `diff-analysis-orchestrator.js` | Intelligent batch orchestration | `orchestrateBatches()`, `buildFileOverview()`, `detectDependencies()` (v2.20.0) |
325
326
  | `claude-client.js` | Claude CLI wrapper | `analyzeCode()`, `executeClaudeWithRetry()`, `extractJSON()` — spawn, retry, model override |
@@ -359,8 +360,8 @@ consolidateResults()
359
360
  7. **Adapter Pattern**: `git-operations.js` abstracts git commands into JS functions
360
361
  8. **Singleton Pattern**: `config.js` loads configuration once per execution
361
362
  9. **Guard Pattern**: `authorization.js` — fail-closed gate in `bin/claude-hooks` before command dispatch; static `PROTECTED_COMMANDS` set avoids API calls for unprotected commands; permissions sourced from `mscope-S-L/git-hooks-config/permissions.json`
362
- 10. **Remote Config Pattern**: `remote-config.js` — fetches JSON from `mscope-S-L/git-hooks-config`, caches per-process (including nulls), graceful degradation (warn + return null); `label-resolver.js` — callers receive config via dependency injection and decide fallback
363
- 11. **Pipeline Pattern**: `tool-runner.js` + `linter-runner.js` — generic tool execution infrastructure; linters (and future formatters) share the same resolve → spawn → parse → fix → re-stage pipeline. Tool definitions are data objects, not classes.
363
+ 10. **Remote Config Pattern**: `remote-config.js` — fetches JSON from `mscope-S-L/git-hooks-config`, caches per-process (including nulls), graceful degradation (warn + return null); `label-resolver.js` and `linter-runner.js` — callers fetch remote config and decide fallback (`labels.json` for PR labels, `formatters.json` for preset-to-tools mapping)
364
+ 11. **Pipeline Pattern**: `tool-runner.js` + `linter-runner.js` — generic tool execution infrastructure; formatters (Prettier) and linters (ESLint, Spotless, sqlfluff) share the same resolve → spawn → parse → fix → re-stage pipeline. Tool definitions are data objects, not classes. Preset-to-tools mapping fetched from `mscope-S-L/git-hooks-config/formatters.json` (remote config priority, local fallback).
364
365
 
365
366
  ### Key Data Flows
366
367
 
@@ -372,13 +373,14 @@ git commit
372
373
  → filters by preset extensions + size
373
374
 
374
375
  → LINTING STEP (fast, deterministic)
375
- → getLinterToolsForPreset(presetName) → applicable linters
376
- for each linter:
376
+ → getLinterToolsForPreset(presetName):
377
+ 1. fetchRemoteConfig('formatters.json') remote presetTools mapping
378
+ 2. fallback to local PRESET_LINTERS if remote unavailable
379
+ → for each tool (formatters first, then linters):
377
380
  isToolAvailable() → not found? warn + install hint → skip
378
381
  filterFilesByTool() → matching files
379
382
  runToolWithAutoFix() → check → auto-fix → re-stage → re-check
380
- if failOnError && errors remain → exit 1 (COMMIT BLOCKED)
381
- → if failOnWarning && warnings → exit 1 (COMMIT BLOCKED)
383
+ unfixable issues forwarded to judge
382
384
 
383
385
  → continues to Claude analysis
384
386
  ```
package/README.md CHANGED
@@ -82,6 +82,43 @@ export GITHUB_TOKEN="ghp_..."
82
82
 
83
83
  Create token at https://github.com/settings/tokens with scopes: `repo`, `read:org`
84
84
 
85
+ ### Linting & Formatting
86
+
87
+ Runs formatters and linters on staged files automatically during pre-commit, or on demand:
88
+
89
+ ```bash
90
+ # Lint staged files (default)
91
+ claude-hooks lint
92
+
93
+ # Lint all files in a directory
94
+ claude-hooks lint src/
95
+
96
+ # Lint specific files
97
+ claude-hooks lint file1.js file2.java
98
+
99
+ # Mix of directories and files
100
+ claude-hooks lint src/ lib/utils/ file.js
101
+ ```
102
+
103
+ **Tools per preset** (configured via [remote config](https://github.com/mscope-S-L/git-hooks-config)):
104
+
105
+ | Preset | Tools |
106
+ | ----------- | ----------------------------- |
107
+ | `frontend` | Prettier, ESLint |
108
+ | `backend` | Spotless |
109
+ | `fullstack` | Prettier, ESLint, Spotless |
110
+ | `database` | sqlfluff |
111
+ | `ai` | Prettier, ESLint |
112
+ | `default` | Prettier, ESLint |
113
+
114
+ **Behavior:**
115
+
116
+ - Formatters run first (Prettier), then linters (ESLint) — format before lint
117
+ - Auto-fix enabled by default — fixes and re-stages files automatically
118
+ - Missing tools are skipped with install instructions (never blocks)
119
+ - Unfixable issues are forwarded to the Claude judge for semantic resolution
120
+ - Tool-to-preset mapping is fetched from remote config (team-controlled, no release needed)
121
+
85
122
  ### Analyze Code (Interactive Review)
86
123
 
87
124
  Run interactive code analysis before committing:
@@ -673,7 +673,7 @@ export async function runInstall(args) {
673
673
  const presetName = config.preset || 'default';
674
674
  if (config.linting?.enabled !== false) {
675
675
  const { checkLinterAvailability } = await import('../utils/linter-runner.js');
676
- checkLinterAvailability(presetName);
676
+ await checkLinterAvailability(presetName);
677
677
  }
678
678
  } catch {
679
679
  // Non-fatal — linter check failure should not block installation
@@ -170,7 +170,7 @@ export async function runLint(args = []) {
170
170
 
171
171
  info(`🎯 Linting ${filesToLint.length} file(s) with '${metadata.displayName}' preset`);
172
172
 
173
- const lintResult = runLinters(filesToLint, config, presetName);
173
+ const lintResult = await runLinters(filesToLint, config, presetName);
174
174
  displayLintResults(lintResult);
175
175
 
176
176
  // Exit with error code if linting failed
@@ -152,7 +152,7 @@ const main = async () => {
152
152
  logger.info('🔍 Running linters...');
153
153
  const lintStartTime = Date.now();
154
154
  const filePaths = validFiles.map((f) => (typeof f === 'string' ? f : f.path));
155
- const lintResult = runLinters(filePaths, config, presetName);
155
+ const lintResult = await runLinters(filePaths, config, presetName);
156
156
  displayLintResults(lintResult);
157
157
 
158
158
  // Record lint metric
@@ -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,
@@ -23,6 +25,7 @@ import {
23
25
  displayToolResult
24
26
  } from './tool-runner.js';
25
27
  import { getRepoRoot } from './git-operations.js';
28
+ import { fetchRemoteConfig } from './remote-config.js';
26
29
  import logger from './logger.js';
27
30
 
28
31
  /**
@@ -157,6 +160,46 @@ export function parseSqlfluffOutput(stdout) {
157
160
  return { errors, warnings };
158
161
  }
159
162
 
163
+ /**
164
+ * Parse Prettier --check output into structured issues
165
+ * Prettier outputs [warn] lines for files that need formatting
166
+ *
167
+ * @param {string} stdout - Prettier --check stdout
168
+ * @returns {{ errors: Array, warnings: Array }}
169
+ */
170
+ export function parsePrettierOutput(stdout) {
171
+ const errors = [];
172
+ const warnings = [];
173
+
174
+ const lines = stdout.split('\n');
175
+
176
+ for (const line of lines) {
177
+ const trimmed = line.trim();
178
+
179
+ // Match [warn] <filepath> lines — skip summary lines
180
+ const match = trimmed.match(/^\[warn\]\s+(.+)$/);
181
+ if (!match) continue;
182
+
183
+ const content = match[1];
184
+
185
+ // Skip Prettier's summary lines (not file paths)
186
+ if (
187
+ content.includes('Code style issues') ||
188
+ content.includes('Forgot to run Prettier')
189
+ ) {
190
+ continue;
191
+ }
192
+
193
+ errors.push({
194
+ file: content,
195
+ severity: 'error',
196
+ message: 'File is not properly formatted'
197
+ });
198
+ }
199
+
200
+ return { errors, warnings };
201
+ }
202
+
160
203
  /**
161
204
  * Convert file paths to Spotless-compatible regex pattern
162
205
  * Spotless -DspotlessFiles accepts a regex matching absolute file paths
@@ -171,25 +214,94 @@ export function filesToSpotlessRegex(files) {
171
214
  return escaped.join('|');
172
215
  }
173
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
+
174
247
  /**
175
248
  * Available linter tool definitions
176
249
  * @type {Object<string, import('./tool-runner.js').ToolDefinition>}
177
250
  */
178
251
  export const LINTER_TOOLS = {
252
+ prettier: {
253
+ name: 'prettier',
254
+ command: 'npx',
255
+ args: (files) => ['prettier', '--check', ...files],
256
+ fixArgs: (files) => ['prettier', '--write', ...files],
257
+ detectCommand: 'prettier',
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
+ ],
276
+ installHint: 'npm install --save-dev prettier',
277
+ extensions: ['.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.html', '.json', '.md', '.yaml', '.yml'],
278
+ parseOutput: parsePrettierOutput,
279
+ timeout: 30000
280
+ },
179
281
  eslint: {
180
282
  name: 'eslint',
181
283
  command: 'npx',
182
284
  args: (files) => ['eslint', '--format', 'json', '--no-error-on-unmatched-pattern', ...files],
183
285
  fixArgs: (files) => ['eslint', '--fix', '--no-error-on-unmatched-pattern', ...files],
184
286
  detectCommand: 'eslint',
185
- detectInProjectFile: {
186
- filename: 'package.json',
187
- check: (content) => {
188
- const pkg = JSON.parse(content);
189
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
190
- return 'eslint' in allDeps;
191
- }
192
- },
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
+ ],
193
305
  installHint: 'npm install --save-dev eslint',
194
306
  extensions: ['.js', '.jsx', '.ts', '.tsx'],
195
307
  parseOutput: parseEslintOutput,
@@ -212,7 +324,14 @@ export const LINTER_TOOLS = {
212
324
  installHint: 'Add spotless-maven-plugin to pom.xml (see https://github.com/diffplug/spotless)',
213
325
  extensions: ['.java'],
214
326
  parseOutput: parseSpotlessOutput,
215
- 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
216
335
  },
217
336
  sqlfluff: {
218
337
  name: 'sqlfluff',
@@ -228,27 +347,63 @@ export const LINTER_TOOLS = {
228
347
  };
229
348
 
230
349
  /**
231
- * Preset-to-linter mapping
232
- * Each preset lists the linter tool names it uses
350
+ * Local fallback preset-to-tool mapping
351
+ * Used when remote formatters.json is unavailable
233
352
  * @type {Object<string, string[]>}
234
353
  */
235
354
  export const PRESET_LINTERS = {
236
- frontend: ['eslint'],
355
+ frontend: ['prettier', 'eslint'],
237
356
  backend: ['spotless'],
238
- fullstack: ['eslint', 'spotless'],
357
+ fullstack: ['prettier', 'eslint', 'spotless'],
239
358
  database: ['sqlfluff'],
240
- ai: ['eslint'],
241
- default: ['eslint']
359
+ ai: ['prettier', 'eslint'],
360
+ default: ['prettier', 'eslint']
242
361
  };
243
362
 
244
363
  /**
245
- * Get linter tool definitions for a preset
364
+ * Get linter/formatter tool definitions for a preset.
365
+ * Fetches preset-to-tools mapping from remote config (formatters.json),
366
+ * falls back to local PRESET_LINTERS when remote is unavailable.
246
367
  *
247
368
  * @param {string} presetName - Preset name
248
- * @returns {import('./tool-runner.js').ToolDefinition[]} Tool definitions
369
+ * @returns {Promise<import('./tool-runner.js').ToolDefinition[]>} Tool definitions
249
370
  */
250
- export function getLinterToolsForPreset(presetName) {
251
- const toolNames = PRESET_LINTERS[presetName] || PRESET_LINTERS.default;
371
+ export async function getLinterToolsForPreset(presetName) {
372
+ // Try remote config first
373
+ const remoteConfig = await fetchRemoteConfig('formatters.json');
374
+
375
+ let toolNames;
376
+
377
+ if (remoteConfig?.presetTools?.[presetName]) {
378
+ toolNames = remoteConfig.presetTools[presetName];
379
+ logger.debug('linter-runner - getLinterToolsForPreset', 'Using remote preset tools', {
380
+ preset: presetName,
381
+ tools: toolNames
382
+ });
383
+ } else if (remoteConfig?.presetTools?.default) {
384
+ toolNames = remoteConfig.presetTools.default;
385
+ logger.debug('linter-runner - getLinterToolsForPreset',
386
+ 'Preset not in remote config, using remote default', {
387
+ preset: presetName,
388
+ tools: toolNames
389
+ });
390
+ } else {
391
+ // Fall back to local
392
+ toolNames = PRESET_LINTERS[presetName] || PRESET_LINTERS.default;
393
+ logger.debug('linter-runner - getLinterToolsForPreset', 'Using local fallback', {
394
+ preset: presetName,
395
+ tools: toolNames,
396
+ remoteAvailable: remoteConfig !== null
397
+ });
398
+ }
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
+
252
407
  return toolNames.map((name) => LINTER_TOOLS[name]).filter(Boolean);
253
408
  }
254
409
 
@@ -260,12 +415,12 @@ export function getLinterToolsForPreset(presetName) {
260
415
  * @param {string} [presetName] - Preset name (default: 'default')
261
416
  * @returns {{ results: ToolRunResult[], totalErrors: number, totalWarnings: number, totalFixed: number }}
262
417
  */
263
- export function runLinters(files, config, presetName = 'default') {
418
+ export async function runLinters(files, config, presetName = 'default') {
264
419
  const lintConfig = config.linting || {};
265
420
  const autoFix = lintConfig.autoFix !== false;
266
421
  const timeout = lintConfig.timeout || 30000;
267
422
 
268
- const tools = getLinterToolsForPreset(presetName);
423
+ const tools = await getLinterToolsForPreset(presetName);
269
424
  const results = [];
270
425
 
271
426
  logger.debug('linter-runner - runLinters', 'Starting linters', {
@@ -306,14 +461,53 @@ export function runLinters(files, config, presetName = 'default') {
306
461
  continue;
307
462
  }
308
463
 
309
- // Run tool with optional auto-fix
310
- 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 = {
311
478
  autoFix,
312
479
  timeout,
313
- restage: true
314
- });
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
+ );
315
497
 
316
- 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
+ }
317
511
  }
318
512
 
319
513
  // Aggregate totals
@@ -415,8 +609,8 @@ export function lintIssuesToAnalysisDetails(lintResult) {
415
609
  *
416
610
  * @param {string} presetName - Preset name
417
611
  */
418
- export function checkLinterAvailability(presetName) {
419
- const tools = getLinterToolsForPreset(presetName);
612
+ export async function checkLinterAvailability(presetName) {
613
+ const tools = await getLinterToolsForPreset(presetName);
420
614
 
421
615
  if (tools.length === 0) {
422
616
  return;
@@ -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,69 +1,73 @@
1
- {
2
- "name": "claude-git-hooks",
3
- "version": "2.34.0",
4
- "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
- "type": "module",
6
- "bin": {
7
- "claude-hooks": "./bin/claude-hooks"
8
- },
9
- "scripts": {
10
- "test": "npm run test:all",
11
- "test:all": "npm run lint && npm run test:smoke && npm run test:unit && npm run test:integration",
12
- "test:smoke": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/smoke --maxWorkers=1",
13
- "test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/unit --forceExit",
14
- "test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration --maxWorkers=1 --testTimeout=30000 --forceExit",
15
- "test:integration:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration/ci-safe.test.js --maxWorkers=1 --testTimeout=30000 --forceExit",
16
- "test:changed": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/unit --changedSince=main --forceExit",
17
- "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
18
- "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
19
- "lint": "eslint lib/ bin/claude-hooks",
20
- "lint:fix": "eslint lib/ bin/claude-hooks --fix",
21
- "format": "prettier --write \"lib/**/*.js\" \"bin/**\" \"test/**/*.js\"",
22
- "precommit": "npm run lint && npm run test:smoke",
23
- "prepublishOnly": "npm run test:all"
24
- },
25
- "keywords": [
26
- "git",
27
- "hooks",
28
- "claude",
29
- "ai",
30
- "code-review",
31
- "commit-messages",
32
- "pre-commit",
33
- "automation"
34
- ],
35
- "author": "Pablo Rovito",
36
- "license": "MIT",
37
- "repository": {
38
- "type": "git",
39
- "url": "https://github.com/mscope-S-L/git-hooks.git"
40
- },
41
- "engines": {
42
- "node": ">=16.9.0"
43
- },
44
- "engineStrict": false,
45
- "os": [
46
- "darwin",
47
- "linux",
48
- "win32"
49
- ],
50
- "preferGlobal": true,
51
- "files": [
52
- "bin/",
53
- "lib/",
54
- "templates/",
55
- "README.md",
56
- "CHANGELOG.md",
57
- "CLAUDE.md",
58
- "LICENSE"
59
- ],
60
- "dependencies": {
61
- "@octokit/rest": "^21.0.0"
62
- },
63
- "devDependencies": {
64
- "@types/jest": "^29.5.0",
65
- "eslint": "^8.57.1",
66
- "jest": "^29.7.0",
67
- "prettier": "^3.2.0"
68
- }
69
- }
1
+ {
2
+ "name": "claude-git-hooks",
3
+ "version": "2.35.2",
4
+ "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-hooks": "./bin/claude-hooks"
8
+ },
9
+ "scripts": {
10
+ "test": "npm run test:all",
11
+ "test:all": "npm run lint && npm run test:smoke && npm run test:unit && npm run test:integration",
12
+ "test:smoke": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/smoke --maxWorkers=1",
13
+ "test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/unit --forceExit",
14
+ "test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration --maxWorkers=1 --testTimeout=30000 --forceExit",
15
+ "test:integration:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration/ci-safe.test.js --maxWorkers=1 --testTimeout=30000 --forceExit",
16
+ "test:changed": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/unit --changedSince=main --forceExit",
17
+ "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
18
+ "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
19
+ "lint": "eslint lib/ bin/claude-hooks",
20
+ "lint:fix": "eslint lib/ bin/claude-hooks --fix",
21
+ "format": "prettier --write \"lib/**/*.js\" \"bin/**\" \"test/**/*.js\"",
22
+ "precommit": "npm run lint && npm run test:smoke",
23
+ "prepublishOnly": "npm run test:all",
24
+ "library:tokens": "node .library/tools/measure-tokens.js",
25
+ "library:graph": "node .library/tools/generate-graph.js"
26
+ },
27
+ "keywords": [
28
+ "git",
29
+ "hooks",
30
+ "claude",
31
+ "ai",
32
+ "code-review",
33
+ "commit-messages",
34
+ "pre-commit",
35
+ "automation"
36
+ ],
37
+ "author": "Pablo Rovito",
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/mscope-S-L/git-hooks.git"
42
+ },
43
+ "engines": {
44
+ "node": ">=16.9.0"
45
+ },
46
+ "engineStrict": false,
47
+ "os": [
48
+ "darwin",
49
+ "linux",
50
+ "win32"
51
+ ],
52
+ "preferGlobal": true,
53
+ "files": [
54
+ "bin/",
55
+ "lib/",
56
+ "templates/",
57
+ "README.md",
58
+ "CHANGELOG.md",
59
+ "CLAUDE.md",
60
+ "LICENSE"
61
+ ],
62
+ "dependencies": {
63
+ "@octokit/rest": "^21.0.0"
64
+ },
65
+ "devDependencies": {
66
+ "@types/jest": "^29.5.0",
67
+ "eslint": "^8.57.1",
68
+ "jest": "^29.7.0",
69
+ "js-tiktoken": "^1.0.18",
70
+ "madge": "^8.0.0",
71
+ "prettier": "^3.2.0"
72
+ }
73
+ }