claude-git-hooks 2.33.1 → 2.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ 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.34.0] - 2026-03-27
9
+
10
+ ### ✨ Added
11
+ - Pre-commit linting with ESLint, Spotless, and sqlfluff - runs before Claude analysis with auto-fix and re-stage
12
+ - `lint` command - run linters on staged files, directories, or specific files (`claude-hooks lint [paths...]`)
13
+ - Linter availability check during installation - verifies linter presence per preset and shows install instructions
14
+ - Generic tool executor infrastructure (`tool-runner.js`) for linters and future formatters
15
+ - Linter orchestration system (`linter-runner.js`) with preset-to-linter mapping
16
+ - Maven availability check during installation with install instructions for backend/fullstack presets
17
+
18
+ ### 🔧 Changed
19
+ - Pre-commit flow now runs linting before Claude analysis - fast, deterministic checks first
20
+ - Unfixable lint issues are forwarded to the judge for semantic resolution
21
+ - Updated documentation to reflect new linting workflow and command usage
22
+
23
+ ### 🐛 Fixed
24
+ - WSL Claude CLI detection in non-default distros (#120, #121)
25
+
26
+
8
27
  ## [2.33.1] - 2026-03-26
9
28
 
10
29
  ### 🔧 Changed
package/CLAUDE.md CHANGED
@@ -6,17 +6,19 @@
6
6
 
7
7
  **Main use cases:**
8
8
 
9
- 1. **Pre-commit analysis**: Detects security issues, bugs, and code smells before each commit (blocks on CRITICAL/BLOCKER only)
10
- 2. **Interactive analysis**: `claude-hooks analyze` - review all issues (INFO to BLOCKER) interactively before committing
11
- 3. **Automatic messages**: Write `git commit -m "auto"` and Claude generates the message in Conventional Commits format with task-id extracted from branch
12
- 4. **PR analysis**: `claude-hooks analyze-diff [branch]` generates title, description, and test plan for PRs
13
- 5. **PR review**: `claude-hooks analyze-pr <url>` analyzes a GitHub PR with preset guidelines, Linear ticket enrichment, and posts review comments
14
- 6. **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)
15
- 7. **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
16
- 8. **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)
17
- 9. **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
18
- 10. **Feature revert**: `claude-hooks revert-feature <task-id>` finds a squash-merged feature commit by task ID in the current release-candidate, checks coupling with other RC features, reverts it, pushes, and optionally re-deploys shadow
19
- 11. **Release closure**: `claude-hooks close-release [description]` finalizes the active release-candidate soft-resets onto main, creates a single clean commit, force-pushes, and creates a PR to main with the merge-commit strategy
9
+ 1. **Pre-commit linting**: Runs linters (ESLint, Spotless, sqlfluff) on staged files before Claude analysis fast, deterministic, auto-fix enabled by default
10
+ 2. **Pre-commit analysis**: Detects security issues, bugs, and code smells before each commit (blocks on CRITICAL/BLOCKER only)
11
+ 3. **Interactive analysis**: `claude-hooks analyze` - review all issues (INFO to BLOCKER) interactively before committing
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
+ 5. **PR analysis**: `claude-hooks analyze-diff [branch]` generates title, description, and test plan for PRs
14
+ 6. **PR review**: `claude-hooks analyze-pr <url>` analyzes a GitHub PR with preset guidelines, Linear ticket enrichment, and posts review comments
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
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
+ 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
+ 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
20
+ 12. **Feature revert**: `claude-hooks revert-feature <task-id>` finds a squash-merged feature commit by task ID in the current release-candidate, checks coupling with other RC features, reverts it, pushes, and optionally re-deploys shadow
21
+ 13. **Release closure**: `claude-hooks close-release [description]` finalizes the active release-candidate — soft-resets onto main, creates a single clean commit, force-pushes, and creates a PR to main with the merge-commit strategy
20
22
 
21
23
  ## Architecture
22
24
 
@@ -57,7 +59,7 @@ claude-git-hooks/
57
59
  │ ├── config.js # Config system - load/merge with priority
58
60
  │ ├── commands/ # Command modules - one file per CLI command
59
61
  │ │ ├── helpers.js # Shared CLI utilities - colors, output, platform
60
- │ │ ├── install.js # Install command - dependencies, hooks, templates
62
+ │ │ ├── install.js # Install command - dependencies, hooks, templates, linter check
61
63
  │ │ ├── hooks.js # Hook management - enable, disable, status, uninstall
62
64
  │ │ ├── analyze-diff.js # Diff analysis - generate PR metadata from git diff
63
65
  │ │ ├── analyze-pr.js # PR analysis - analyze GitHub PR with team guidelines
@@ -77,11 +79,14 @@ claude-git-hooks/
77
79
  │ │ ├── bump-version.js # Version management - bump with commit, CHANGELOG and tags
78
80
  │ │ ├── generate-changelog.js # CHANGELOG generation - standalone command
79
81
  │ │ ├── diff-batch-info.js # Batch info - orchestration config + speed telemetry (v2.20.0)
82
+ │ │ ├── lint.js # Lint command - run linters on staged files, dirs, or files
80
83
  │ │ └── help.js # Help, AI help, and report-issue commands
81
84
  │ ├── hooks/ # Git hooks - Node.js implementations
82
85
  │ │ ├── pre-commit.js # Pre-commit analysis - code quality gate
83
86
  │ │ └── prepare-commit-msg.js # Message generation - auto commit messages
84
87
  │ └── utils/ # Reusable modules - shared logic
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)
85
90
  │ ├── analysis-engine.js # Shared analysis logic - file data, 3-tier routing, results (v2.13.0+)
86
91
  │ ├── diff-analysis-orchestrator.js # Intelligent batch orchestration via Opus (v2.20.0)
87
92
  │ ├── claude-client.js # Claude CLI wrapper - spawn, retry, model override
@@ -218,6 +223,11 @@ preset config (.claude/presets/{name}/config.json) ← HIGHEST PRIORITY
218
223
  | Judge timeout | 120s | Per-judge call timeout |
219
224
  | PR analysis model | sonnet | Default, configurable via `config.prAnalysis.model` |
220
225
  | PR analysis timeout | 300s | Per-analysis Claude call |
226
+ | Linting enabled | true | Runs linters before Claude analysis |
227
+ | Linting auto-fix | true | Auto-fix and re-stage files |
228
+ | Linting fail on error | true | Block commit on linting errors |
229
+ | Linting fail on warn | false | Do not block on warnings |
230
+ | Linting timeout | 30s | Per-linter timeout |
221
231
 
222
232
  **Judge behavior (v2.20.0):**
223
233
 
@@ -299,6 +309,7 @@ consolidateResults()
299
309
  | `debug.js` | Debug toggle | `runSetDebug()` |
300
310
  | `telemetry-cmd.js` | Telemetry commands | `runShowTelemetry()`, `runClearTelemetry()` |
301
311
  | `diff-batch-info.js` | Batch info display | `runDiffBatchInfo()` |
312
+ | `lint.js` | Lint command | `runLint()`, `resolvePaths()` |
302
313
  | `help.js` | Help, AI help, report-issue | `runShowHelp()`, `showStaticHelp()`, `runShowVersion()` |
303
314
 
304
315
  **Utility Modules (`lib/utils/`):**
@@ -307,6 +318,8 @@ consolidateResults()
307
318
  | ------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
308
319
  | `lib/cli-metadata.js` | Command registry | `commands`, `buildCommandMap()`, `generateCompletionData()`, `PRESET_NAMES`, `HOOK_NAMES`, `BUMP_TYPES` |
309
320
  | `lib/config.js` | Config system | `getConfig()` |
321
+ | `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) |
310
323
  | `analysis-engine.js` | Shared analysis logic | `buildFileData()`, `buildFilesData()`, `runAnalysis()`, `consolidateResults()`, `hasBlockingIssues()`, `hasAnyIssues()`, `displayResults()`, `displayIssueSummary()` (v2.13.0+) |
311
324
  | `diff-analysis-orchestrator.js` | Intelligent batch orchestration | `orchestrateBatches()`, `buildFileOverview()`, `detectDependencies()` (v2.20.0) |
312
325
  | `claude-client.js` | Claude CLI wrapper | `analyzeCode()`, `executeClaudeWithRetry()`, `extractJSON()` — spawn, retry, model override |
@@ -347,15 +360,36 @@ consolidateResults()
347
360
  8. **Singleton Pattern**: `config.js` loads configuration once per execution
348
361
  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`
349
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.
350
364
 
351
365
  ### Key Data Flows
352
366
 
367
+ **Flow 0: Pre-commit linting (v2.34.0)**
368
+
369
+ ```
370
+ git commit
371
+ → hook reads staged files
372
+ → filters by preset extensions + size
373
+
374
+ → LINTING STEP (fast, deterministic)
375
+ → getLinterToolsForPreset(presetName) → applicable linters
376
+ → for each linter:
377
+ isToolAvailable() → not found? warn + install hint → skip
378
+ filterFilesByTool() → matching files
379
+ 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)
382
+
383
+ → continues to Claude analysis
384
+ ```
385
+
353
386
  **Flow 1: Pre-commit with blocking**
354
387
 
355
388
  ```
356
389
  git commit
357
390
  → hook reads staged files
358
391
  → filters by preset extensions
392
+ → [linting step — see Flow 0]
359
393
  → builds prompt with diff
360
394
  → Claude analyzes → detects issues
361
395
  → judge evaluates ALL issues (any severity):
@@ -1107,6 +1141,13 @@ claude-hooks presets # List available presets
1107
1141
  claude-hooks --set-preset backend # Change preset
1108
1142
  claude-hooks preset current # View current preset
1109
1143
 
1144
+ # Linting
1145
+ claude-hooks lint # Lint staged files
1146
+ claude-hooks lint src/ # Lint all files in directory
1147
+ claude-hooks lint src/ lib/utils/ # Multiple directories
1148
+ claude-hooks lint file1.js file2.js # Specific files
1149
+ claude-hooks lint src/ file.js lib/ # Mix of dirs and files
1150
+
1110
1151
  # Analysis and PRs
1111
1152
  claude-hooks analyze-diff [branch] # Analyze diff for PR
1112
1153
  claude-hooks analyze-pr <pr-url> # Analyze GitHub PR with team guidelines
package/bin/claude-hooks CHANGED
@@ -187,6 +187,7 @@ async function main() {
187
187
  if (
188
188
  [
189
189
  'install',
190
+ 'lint',
190
191
  'analyze-diff',
191
192
  'analyze-pr',
192
193
  'back-merge',
@@ -97,6 +97,15 @@ export const commands = [
97
97
  description: 'Show hook status',
98
98
  handler: async () => (await import('./commands/hooks.js')).runStatus
99
99
  },
100
+ {
101
+ name: 'lint',
102
+ description: 'Run linters on staged files, directories, or specific files',
103
+ handler: async () => (await import('./commands/lint.js')).runLint,
104
+ args: {
105
+ name: 'paths',
106
+ completion: "find . -maxdepth 3 -type d -not -path '*/\\.*' -not -path '*/node_modules/*'"
107
+ }
108
+ },
100
109
  {
101
110
  name: 'analyze',
102
111
  description: 'Analyze code interactively before committing',
@@ -32,6 +32,7 @@ import {
32
32
  } from './helpers.js';
33
33
  import { runSetupGitHub } from './setup-github.js';
34
34
  import { generateCompletionData } from '../cli-metadata.js';
35
+ import { getConfig } from '../config.js';
35
36
 
36
37
  /**
37
38
  * Function to check version (used by hooks)
@@ -186,6 +187,17 @@ async function checkAndInstallDependencies(skipAuth = false) {
186
187
  error('npm is not installed.');
187
188
  }
188
189
 
190
+ // Check Maven (optional — needed for backend/fullstack presets with Spotless)
191
+ try {
192
+ const mvnVersion = execSync('mvn --version', { encoding: 'utf8', timeout: 5000 })
193
+ .split('\n')[0].trim();
194
+ success(`${mvnVersion}`);
195
+ } catch {
196
+ warning('Maven (mvn) not found — required for Spotless linting in backend/fullstack presets');
197
+ info(' Install: https://maven.apache.org/install.html');
198
+ info(' Or via package manager: brew install maven / choco install maven / apt install maven');
199
+ }
200
+
189
201
  // v2.0.0+: jq and curl are no longer needed (pure Node.js implementation)
190
202
 
191
203
  // Check Git
@@ -655,6 +667,18 @@ export async function runInstall(args) {
655
667
  // Install shell completions
656
668
  installCompletions();
657
669
 
670
+ // Check linter toolchain availability for the selected preset
671
+ try {
672
+ const config = await getConfig();
673
+ const presetName = config.preset || 'default';
674
+ if (config.linting?.enabled !== false) {
675
+ const { checkLinterAvailability } = await import('../utils/linter-runner.js');
676
+ checkLinterAvailability(presetName);
677
+ }
678
+ } catch {
679
+ // Non-fatal — linter check failure should not block installation
680
+ }
681
+
658
682
  success('Claude Git Hooks installed successfully! 🎉');
659
683
  console.log('\nRun claude-hooks --help to see all available commands.');
660
684
 
@@ -0,0 +1,187 @@
1
+ /**
2
+ * File: lint.js
3
+ * Purpose: CLI command to run linters on files or directories
4
+ *
5
+ * Usage:
6
+ * claude-hooks lint # lint staged files
7
+ * claude-hooks lint src/ # lint all files in src/
8
+ * claude-hooks lint src/ lib/utils/ # multiple directories
9
+ * claude-hooks lint file1.js file2.java # specific files
10
+ * claude-hooks lint src/ file3.js lib/ # mix of dirs and files
11
+ *
12
+ * Path resolution (like git add):
13
+ * - Directories → walk and collect files matching preset extensions
14
+ * - Files → use directly
15
+ * - No args → fall back to staged files
16
+ *
17
+ * Dependencies:
18
+ * - linter-runner: Linter orchestration
19
+ * - git-operations: Staged files, repo root
20
+ * - file-utils: Directory walking
21
+ * - preset-loader: Extension filtering
22
+ * - config: Linting configuration
23
+ * - helpers: checkGitRepo, colors
24
+ */
25
+
26
+ import fs from 'fs';
27
+ import path from 'path';
28
+ import { checkGitRepo, error, info } from './helpers.js';
29
+ import { getConfig } from '../config.js';
30
+ import { loadPreset } from '../utils/preset-loader.js';
31
+ import { getStagedFiles, getRepoRoot } from '../utils/git-operations.js';
32
+ import { runLinters, displayLintResults } from '../utils/linter-runner.js';
33
+ import logger from '../utils/logger.js';
34
+
35
+ /**
36
+ * Resolve user-provided paths into a flat list of files
37
+ * Handles directories (walked recursively), individual files, and mixtures.
38
+ *
39
+ * @param {string[]} paths - User-provided paths (files and/or directories)
40
+ * @param {string[]} extensions - Allowed file extensions from preset
41
+ * @param {string} repoRoot - Repository root for relative path resolution
42
+ * @returns {string[]} Resolved file paths (relative to cwd)
43
+ */
44
+ export function resolvePaths(paths, extensions, _repoRoot) {
45
+ const files = new Set();
46
+ const extSet = new Set(extensions.map((e) => e.toLowerCase()));
47
+
48
+ for (const userPath of paths) {
49
+ const resolved = path.resolve(userPath);
50
+
51
+ if (!fs.existsSync(resolved)) {
52
+ logger.warning(`Path not found: ${userPath}`);
53
+ continue;
54
+ }
55
+
56
+ const stat = fs.statSync(resolved);
57
+
58
+ if (stat.isFile()) {
59
+ // Accept file if it matches preset extensions, or if no extensions filter
60
+ const ext = path.extname(resolved).toLowerCase();
61
+ if (extSet.size === 0 || extSet.has(ext)) {
62
+ files.add(path.relative(process.cwd(), resolved));
63
+ } else {
64
+ logger.debug('lint - resolvePaths', `Skipping ${userPath}: extension ${ext} not in preset`);
65
+ }
66
+ } else if (stat.isDirectory()) {
67
+ // Walk directory and collect matching files
68
+ _walkForFiles(resolved, extSet, files);
69
+ }
70
+ }
71
+
72
+ return Array.from(files);
73
+ }
74
+
75
+ /**
76
+ * Recursively walk a directory collecting files that match extensions
77
+ *
78
+ * @param {string} dir - Directory to walk
79
+ * @param {Set<string>} extSet - Allowed extensions
80
+ * @param {Set<string>} files - Accumulator set of relative file paths
81
+ * @param {number} [depth] - Current depth (max 10)
82
+ */
83
+ function _walkForFiles(dir, extSet, files, depth = 0) {
84
+ if (depth > 10) return;
85
+
86
+ try {
87
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
88
+
89
+ for (const entry of entries) {
90
+ // Skip hidden dirs, node_modules, target, build
91
+ if (
92
+ entry.name.startsWith('.') ||
93
+ entry.name === 'node_modules' ||
94
+ entry.name === 'target' ||
95
+ entry.name === 'build' ||
96
+ entry.name === 'dist'
97
+ ) {
98
+ continue;
99
+ }
100
+
101
+ const fullPath = path.join(dir, entry.name);
102
+
103
+ if (entry.isDirectory()) {
104
+ _walkForFiles(fullPath, extSet, files, depth + 1);
105
+ } else if (entry.isFile()) {
106
+ const ext = path.extname(entry.name).toLowerCase();
107
+ if (extSet.size === 0 || extSet.has(ext)) {
108
+ files.add(path.relative(process.cwd(), fullPath));
109
+ }
110
+ }
111
+ }
112
+ } catch (err) {
113
+ logger.debug('lint - _walkForFiles', `Cannot read directory: ${dir}`, {
114
+ error: err.message
115
+ });
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Main lint command handler
121
+ *
122
+ * @param {string[]} args - CLI arguments (paths and flags)
123
+ */
124
+ export async function runLint(args = []) {
125
+ if (!checkGitRepo()) {
126
+ error('Not a git repository');
127
+ process.exit(1);
128
+ }
129
+
130
+ const config = await getConfig();
131
+
132
+ if (config.system?.debug) {
133
+ logger.setDebugMode(true);
134
+ }
135
+
136
+ if (config.linting?.enabled === false) {
137
+ info('Linting is disabled in configuration');
138
+ return;
139
+ }
140
+
141
+ const repoRoot = getRepoRoot();
142
+ const presetName = config.preset || 'default';
143
+ const { metadata } = await loadPreset(presetName);
144
+
145
+ // Separate flags from paths
146
+ const paths = args.filter((a) => !a.startsWith('--'));
147
+
148
+ let filesToLint;
149
+
150
+ if (paths.length === 0) {
151
+ // No paths → lint staged files
152
+ info('No paths specified — linting staged files');
153
+ const stagedFiles = getStagedFiles({ extensions: metadata.fileExtensions });
154
+
155
+ if (stagedFiles.length === 0) {
156
+ info('No staged files to lint');
157
+ return;
158
+ }
159
+
160
+ filesToLint = stagedFiles;
161
+ } else {
162
+ // Resolve user-provided paths
163
+ filesToLint = resolvePaths(paths, metadata.fileExtensions, repoRoot);
164
+
165
+ if (filesToLint.length === 0) {
166
+ info('No matching files found in specified paths');
167
+ return;
168
+ }
169
+ }
170
+
171
+ info(`🎯 Linting ${filesToLint.length} file(s) with '${metadata.displayName}' preset`);
172
+
173
+ const lintResult = runLinters(filesToLint, config, presetName);
174
+ displayLintResults(lintResult);
175
+
176
+ // Exit with error code if linting failed
177
+ const failOnError = config.linting?.failOnError !== false;
178
+ const failOnWarning = config.linting?.failOnWarning === true;
179
+
180
+ if (failOnError && lintResult.totalErrors > 0) {
181
+ process.exit(1);
182
+ }
183
+
184
+ if (failOnWarning && lintResult.totalWarnings > 0) {
185
+ process.exit(1);
186
+ }
187
+ }
package/lib/config.js CHANGED
@@ -84,6 +84,13 @@ const HARDCODED = {
84
84
  'documentation',
85
85
  'testing'
86
86
  ]
87
+ },
88
+ linting: {
89
+ enabled: true, // Run linters before Claude analysis
90
+ autoFix: true, // Auto-fix and re-stage
91
+ failOnError: true, // Block commit on linting errors
92
+ failOnWarning: false, // Do not block on warnings
93
+ timeout: 30000 // 30s per linter
87
94
  }
88
95
  };
89
96
 
@@ -33,6 +33,7 @@ import { getVersion } from '../utils/package-info.js';
33
33
  import logger from '../utils/logger.js';
34
34
  import { getConfig } from '../config.js';
35
35
  import { recordMetric } from '../utils/metrics.js';
36
+ import { runLinters, displayLintResults, lintIssuesToAnalysisDetails } from '../utils/linter-runner.js';
36
37
 
37
38
  /**
38
39
  * Configuration loaded from lib/config.js
@@ -144,11 +145,45 @@ const main = async () => {
144
145
  process.exit(0);
145
146
  }
146
147
 
147
- // Step 3: Build file data using shared engine
148
+ // Step 3: Run linters (fast, deterministic before Claude analysis)
149
+ // Unfixable lint issues are forwarded to the judge for semantic resolution
150
+ let unfixableLintDetails = [];
151
+ if (config.linting?.enabled !== false) {
152
+ logger.info('🔍 Running linters...');
153
+ const lintStartTime = Date.now();
154
+ const filePaths = validFiles.map((f) => (typeof f === 'string' ? f : f.path));
155
+ const lintResult = runLinters(filePaths, config, presetName);
156
+ displayLintResults(lintResult);
157
+
158
+ // Record lint metric
159
+ recordMetric('linting.completed', {
160
+ preset: presetName,
161
+ fileCount: filePaths.length,
162
+ totalErrors: lintResult.totalErrors,
163
+ totalWarnings: lintResult.totalWarnings,
164
+ totalFixed: lintResult.totalFixed,
165
+ toolsRun: lintResult.results.filter((r) => !r.skipped).length,
166
+ toolsSkipped: lintResult.results.filter((r) => r.skipped).length,
167
+ duration: Date.now() - lintStartTime
168
+ }).catch((err) => {
169
+ logger.debug('pre-commit - main', 'Lint metrics recording failed', err);
170
+ });
171
+
172
+ // Forward all remaining lint issues (errors + warnings) to the judge
173
+ // The judge decides whether to fix, dismiss, or block — no hard exit here
174
+ if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
175
+ unfixableLintDetails = lintIssuesToAnalysisDetails(lintResult);
176
+ logger.debug('pre-commit - main', 'Forwarding unfixable lint issues to judge', {
177
+ count: unfixableLintDetails.length
178
+ });
179
+ }
180
+ }
181
+
182
+ // Step 4: Build file data using shared engine
148
183
  logger.debug('pre-commit - main', 'Building file data for analysis');
149
184
  const filesData = buildFilesData(validFiles, { staged: true });
150
185
 
151
- // Step 4: Log analysis configuration
186
+ // Step 5: Log analysis configuration
152
187
  logger.info(`Sending ${filesData.length} files for review...`);
153
188
 
154
189
  // Display analysis routing hint
@@ -156,51 +191,82 @@ const main = async () => {
156
191
  logger.info('⚡ Intelligent orchestration: grouping files and assigning models');
157
192
  }
158
193
 
159
- // Step 5: Run analysis using shared engine
194
+ // Step 6: Run analysis using shared engine
160
195
  const result = await runAnalysis(filesData, config, {
161
196
  hook: 'pre-commit',
162
197
  saveDebug: config.system.debug
163
198
  });
164
199
 
165
- // Step 6: Display results using shared function
200
+ // Step 7: Display results using shared function
166
201
  displayResults(result);
167
202
 
168
- // Step 6.5: Judge auto-fix all issues
203
+ // Step 8: Merge unfixable lint issues into analysis result for the judge
204
+ if (unfixableLintDetails.length > 0) {
205
+ if (!Array.isArray(result.details)) {
206
+ result.details = [];
207
+ }
208
+ result.details.push(...unfixableLintDetails);
209
+
210
+ // Update issue counts
211
+ for (const detail of unfixableLintDetails) {
212
+ const sev = (detail.severity || 'minor').toLowerCase();
213
+ if (result.issues[sev] !== undefined) {
214
+ result.issues[sev]++;
215
+ }
216
+ }
217
+
218
+ logger.info(`📋 ${unfixableLintDetails.length} unfixable lint issue(s) forwarded to judge`);
219
+ }
220
+
221
+ // Step 9: Judge — auto-fix all issues (Claude + lint)
169
222
  if (config.judge?.enabled !== false && hasAnyIssues(result)) {
223
+ let judgeModule;
170
224
  try {
171
- const { judgeAndFix } = await import('../utils/judge.js');
172
- const judgeResult = await judgeAndFix(result, filesData, config);
225
+ judgeModule = await import('../utils/judge.js');
226
+ } catch (importErr) {
227
+ logger.warning(`Judge module import failed: ${importErr.message}`);
228
+ logger.warning('Commit blocked — judge module unavailable');
229
+ result.QUALITY_GATE = 'FAILED';
230
+ result.approved = false;
231
+ judgeModule = null;
232
+ }
173
233
 
174
- // Update result with remaining issues (fixed + false positives removed)
175
- result.blockingIssues = judgeResult.remainingIssues.filter((i) =>
176
- ['blocker', 'critical'].includes((i.severity || '').toLowerCase())
177
- );
178
- result.details = judgeResult.remainingIssues;
179
-
180
- // Recalculate quality gate — pass only if ALL issues resolved
181
- if (judgeResult.remainingIssues.length === 0) {
182
- result.issues = {
183
- blocker: 0,
184
- critical: 0,
185
- major: 0,
186
- minor: 0,
187
- info: 0
188
- };
189
- result.QUALITY_GATE = 'PASSED';
190
- result.approved = true;
191
- } else {
234
+ if (judgeModule) {
235
+ try {
236
+ const { judgeAndFix } = judgeModule;
237
+ const judgeResult = await judgeAndFix(result, filesData, config);
238
+
239
+ // Update result with remaining issues (fixed + false positives removed)
240
+ result.blockingIssues = judgeResult.remainingIssues.filter((i) =>
241
+ ['blocker', 'critical'].includes((i.severity || '').toLowerCase())
242
+ );
243
+ result.details = judgeResult.remainingIssues;
244
+
245
+ // Recalculate quality gate — pass only if ALL issues resolved
246
+ if (judgeResult.remainingIssues.length === 0) {
247
+ result.issues = {
248
+ blocker: 0,
249
+ critical: 0,
250
+ major: 0,
251
+ minor: 0,
252
+ info: 0
253
+ };
254
+ result.QUALITY_GATE = 'PASSED';
255
+ result.approved = true;
256
+ } else {
257
+ result.QUALITY_GATE = 'FAILED';
258
+ result.approved = false;
259
+ }
260
+ } catch (err) {
261
+ logger.warning(`Judge execution failed: ${err.message}`);
262
+ logger.warning('Commit blocked — judge could not verify issues');
192
263
  result.QUALITY_GATE = 'FAILED';
193
264
  result.approved = false;
194
265
  }
195
- } catch (err) {
196
- logger.warning(`Judge failed: ${err.message}`);
197
- logger.warning('Commit blocked — judge could not verify issues');
198
- result.QUALITY_GATE = 'FAILED';
199
- result.approved = false;
200
266
  }
201
267
  }
202
268
 
203
- // Step 7: Check quality gate
269
+ // Step 10: Check quality gate
204
270
  const qualityGatePassed = result.QUALITY_GATE === 'PASSED';
205
271
  const approved = result.approved !== false;
206
272
 
@@ -182,17 +182,19 @@ const judgeAndFix = async (analysisResult, filesData, config) => {
182
182
  const resolvedIndices = new Set();
183
183
  const verdicts = [];
184
184
 
185
- for (let i = 0; i < fixes.length; i++) {
185
+ // Cap at allIssues.length LLM may hallucinate extra fixes beyond the input count
186
+ const fixCount = Math.min(fixes.length, allIssues.length);
187
+
188
+ if (fixes.length > allIssues.length) {
189
+ logger.debug('judge', `LLM returned ${fixes.length} fixes for ${allIssues.length} issues — ignoring ${fixes.length - allIssues.length} extra entries`);
190
+ }
191
+
192
+ for (let i = 0; i < fixCount; i++) {
186
193
  const verdict = fixes[i];
187
194
  const num = i + 1;
188
- // Find matching issue by index or use verdict data if index exceeds allIssues length
189
- const matchedIssue = i < allIssues.length ? allIssues[i] : null;
190
- const severity = matchedIssue
191
- ? (matchedIssue.severity || 'unknown').toUpperCase()
192
- : 'UNKNOWN';
193
- const desc = matchedIssue
194
- ? matchedIssue.description || matchedIssue.message || 'No description'
195
- : verdict.explanation || 'Unknown issue';
195
+ const matchedIssue = allIssues[i];
196
+ const severity = (matchedIssue.severity || 'unknown').toUpperCase();
197
+ const desc = matchedIssue.description || matchedIssue.message || 'No description';
196
198
 
197
199
  if (verdict.assessment === 'FALSE_POSITIVE') {
198
200
  falsePositiveCount++;