diff --git a/src/commands.ts b/src/commands.ts index 4f5a7a10..d1d876e7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -136,6 +136,7 @@ import hooks from './commands/hooks/index.js' import files from './commands/files/index.js' import branch from './commands/branch/index.js' import agents from './commands/agents/index.js' +import autoFix from './commands/auto-fix.js' import plugin from './commands/plugin/index.js' import reloadPlugins from './commands/reload-plugins/index.js' import rewind from './commands/rewind/index.js' @@ -264,6 +265,7 @@ const COMMANDS = memoize((): Command[] => [ addDir, advisor, agents, + autoFix, branch, btw, chrome, diff --git a/src/commands/auto-fix.ts b/src/commands/auto-fix.ts new file mode 100644 index 00000000..41c53fd8 --- /dev/null +++ b/src/commands/auto-fix.ts @@ -0,0 +1,25 @@ +import type { Command } from '../types/command.js' + +const command: Command = { + name: 'auto-fix', + description: 'Configure auto-fix: run lint/test after AI edits', + isEnabled: () => true, + type: 'prompt', + progressMessage: 'Configuring auto-fix...', + contentLength: 0, + source: 'builtin', + async getPromptForCommand() { + return [ + { + type: 'text', + text: + 'The user wants to configure auto-fix settings. Auto-fix automatically runs lint and test commands after AI file edits, feeding errors back for self-repair.\n\n' + + 'Current settings location: `.claude/settings.json` or `.claude/settings.local.json`\n\n' + + 'Example configuration:\n```json\n{\n "autoFix": {\n "enabled": true,\n "lint": "eslint . --fix",\n "test": "bun test",\n "maxRetries": 3,\n "timeout": 30000\n }\n}\n```\n\n' + + 'Ask the user what lint and test commands they use, then help them set up the configuration.', + }, + ] + }, +} + +export default command diff --git a/src/services/autoFix/autoFixConfig.test.ts b/src/services/autoFix/autoFixConfig.test.ts new file mode 100644 index 00000000..465240fa --- /dev/null +++ b/src/services/autoFix/autoFixConfig.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test' +import { AutoFixConfigSchema, getAutoFixConfig, type AutoFixConfig } from './autoFixConfig.js' + +describe('AutoFixConfigSchema', () => { + test('parses valid full config', () => { + const input = { + enabled: true, + lint: 'eslint . --fix', + test: 'bun test', + maxRetries: 3, + timeout: 30000, + } + const result = AutoFixConfigSchema.safeParse(input) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.enabled).toBe(true) + expect(result.data.lint).toBe('eslint . --fix') + expect(result.data.test).toBe('bun test') + expect(result.data.maxRetries).toBe(3) + expect(result.data.timeout).toBe(30000) + } + }) + + test('parses minimal config with defaults', () => { + const input = { enabled: true, lint: 'eslint .' } + const result = AutoFixConfigSchema.safeParse(input) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.maxRetries).toBe(3) + expect(result.data.timeout).toBe(30000) + expect(result.data.test).toBeUndefined() + } + }) + + test('rejects config with enabled but no lint or test', () => { + const input = { enabled: true } + const result = AutoFixConfigSchema.safeParse(input) + expect(result.success).toBe(false) + }) + + test('accepts disabled config without commands', () => { + const input = { enabled: false } + const result = AutoFixConfigSchema.safeParse(input) + expect(result.success).toBe(true) + }) + + test('rejects negative maxRetries', () => { + const input = { enabled: true, lint: 'eslint .', maxRetries: -1 } + const result = AutoFixConfigSchema.safeParse(input) + expect(result.success).toBe(false) + }) + + test('rejects maxRetries above 10', () => { + const input = { enabled: true, lint: 'eslint .', maxRetries: 11 } + const result = AutoFixConfigSchema.safeParse(input) + expect(result.success).toBe(false) + }) +}) + +describe('getAutoFixConfig', () => { + test('returns null when settings have no autoFix', () => { + const result = getAutoFixConfig(undefined) + expect(result).toBeNull() + }) + + test('returns null when autoFix is disabled', () => { + const result = getAutoFixConfig({ enabled: false }) + expect(result).toBeNull() + }) + + test('returns parsed config when valid and enabled', () => { + const result = getAutoFixConfig({ enabled: true, lint: 'eslint .' }) + expect(result).not.toBeNull() + expect(result!.enabled).toBe(true) + expect(result!.lint).toBe('eslint .') + }) +}) + +describe('SettingsSchema autoFix integration', () => { + test('SettingsSchema accepts autoFix field', async () => { + const { SettingsSchema } = await import('../../utils/settings/types.js') + const settings = { + autoFix: { + enabled: true, + lint: 'eslint .', + test: 'bun test', + maxRetries: 3, + timeout: 30000, + }, + } + const result = SettingsSchema().safeParse(settings) + expect(result.success).toBe(true) + }) + + test('SettingsSchema rejects invalid autoFix', async () => { + const { SettingsSchema } = await import('../../utils/settings/types.js') + const settings = { + autoFix: { + enabled: true, + // missing lint and test - should fail refine + }, + } + const result = SettingsSchema().safeParse(settings) + expect(result.success).toBe(false) + }) +}) diff --git a/src/services/autoFix/autoFixConfig.ts b/src/services/autoFix/autoFixConfig.ts new file mode 100644 index 00000000..c5b83416 --- /dev/null +++ b/src/services/autoFix/autoFixConfig.ts @@ -0,0 +1,52 @@ +import { z } from 'zod/v4' + +export const AutoFixConfigSchema = z + .object({ + enabled: z.boolean().describe('Whether auto-fix is enabled'), + lint: z + .string() + .optional() + .describe('Lint command to run after file edits (e.g. "eslint . --fix")'), + test: z + .string() + .optional() + .describe('Test command to run after file edits (e.g. "bun test")'), + maxRetries: z + .number() + .int() + .min(0) + .max(10) + .default(3) + .describe('Maximum number of auto-fix retry attempts (default: 3)'), + timeout: z + .number() + .int() + .min(1000) + .max(300000) + .default(30000) + .describe('Timeout in ms for each lint/test command (default: 30000)'), + }) + .refine( + data => !data.enabled || data.lint !== undefined || data.test !== undefined, + { + message: 'At least one of "lint" or "test" must be set when enabled', + }, + ) + +export type AutoFixConfig = z.infer + +export function getAutoFixConfig( + rawConfig: unknown, +): AutoFixConfig | null { + if (!rawConfig || typeof rawConfig !== 'object') { + return null + } + const parsed = AutoFixConfigSchema.safeParse(rawConfig) + if (!parsed.success) { + return null + } + if (!parsed.data.enabled) { + return null + } + return parsed.data +} diff --git a/src/services/autoFix/autoFixHook.test.ts b/src/services/autoFix/autoFixHook.test.ts new file mode 100644 index 00000000..e77bd76b --- /dev/null +++ b/src/services/autoFix/autoFixHook.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test' +import { + shouldRunAutoFix, + buildAutoFixContext, +} from './autoFixHook.js' + +describe('shouldRunAutoFix', () => { + test('returns true for file_edit tool when autoFix enabled', () => { + const config = { enabled: true, lint: 'eslint .', maxRetries: 3, timeout: 30000 } + expect(shouldRunAutoFix('file_edit', config)).toBe(true) + }) + + test('returns true for file_write tool when autoFix enabled', () => { + const config = { enabled: true, lint: 'eslint .', maxRetries: 3, timeout: 30000 } + expect(shouldRunAutoFix('file_write', config)).toBe(true) + }) + + test('returns false for bash tool', () => { + const config = { enabled: true, lint: 'eslint .', maxRetries: 3, timeout: 30000 } + expect(shouldRunAutoFix('bash', config)).toBe(false) + }) + + test('returns false for file_read tool', () => { + const config = { enabled: true, lint: 'eslint .', maxRetries: 3, timeout: 30000 } + expect(shouldRunAutoFix('file_read', config)).toBe(false) + }) + + test('returns false when config is null', () => { + expect(shouldRunAutoFix('file_edit', null)).toBe(false) + }) +}) + +describe('buildAutoFixContext', () => { + test('formats lint errors as AI-readable context', () => { + const context = buildAutoFixContext({ + hasErrors: true, + lintOutput: 'src/foo.ts:10:5 error no-unused-vars', + lintExitCode: 1, + errorSummary: 'Lint errors (exit code 1):\nsrc/foo.ts:10:5 error no-unused-vars', + }) + expect(context).toContain('AUTO-FIX') + expect(context).toContain('no-unused-vars') + expect(context).toContain('Please fix') + }) + + test('returns null when no errors', () => { + const context = buildAutoFixContext({ + hasErrors: false, + }) + expect(context).toBeNull() + }) + + test('formats test failures as AI-readable context', () => { + const context = buildAutoFixContext({ + hasErrors: true, + testOutput: 'FAIL src/foo.test.ts\n expected true, got false', + testExitCode: 1, + errorSummary: 'Test failures (exit code 1):\nFAIL src/foo.test.ts', + }) + expect(context).toContain('AUTO-FIX') + expect(context).toContain('FAIL') + }) +}) diff --git a/src/services/autoFix/autoFixHook.ts b/src/services/autoFix/autoFixHook.ts new file mode 100644 index 00000000..8675c916 --- /dev/null +++ b/src/services/autoFix/autoFixHook.ts @@ -0,0 +1,25 @@ +import type { AutoFixConfig } from './autoFixConfig.js' +import type { AutoFixResult } from './autoFixRunner.js' + +const AUTO_FIX_TOOLS = new Set(['file_edit', 'file_write']) + +export function shouldRunAutoFix( + toolName: string, + config: AutoFixConfig | null, +): boolean { + if (!config) return false + return AUTO_FIX_TOOLS.has(toolName) +} + +export function buildAutoFixContext(result: AutoFixResult): string | null { + if (!result.hasErrors || !result.errorSummary) return null + + return ( + `\n` + + `AUTO-FIX: The file you just edited has errors. Please fix them:\n\n` + + `${result.errorSummary}\n\n` + + `Please fix these errors in the files you just edited. ` + + `Do not ask the user — just apply the fix.\n` + + `` + ) +} diff --git a/src/services/autoFix/autoFixIntegration.test.ts b/src/services/autoFix/autoFixIntegration.test.ts new file mode 100644 index 00000000..1a1347f1 --- /dev/null +++ b/src/services/autoFix/autoFixIntegration.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { getAutoFixConfig } from './autoFixConfig.js' +import { shouldRunAutoFix, buildAutoFixContext } from './autoFixHook.js' +import { runAutoFixCheck } from './autoFixRunner.js' + +describe('autoFix end-to-end flow', () => { + test('full flow: config → shouldRun → check → context', async () => { + const config = getAutoFixConfig({ + enabled: true, + lint: 'echo "error: unused" && exit 1', + maxRetries: 2, + timeout: 5000, + }) + expect(config).not.toBeNull() + expect(shouldRunAutoFix('file_edit', config)).toBe(true) + + const result = await runAutoFixCheck({ + lint: config!.lint, + test: config!.test, + timeout: config!.timeout, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + + const context = buildAutoFixContext(result) + expect(context).not.toBeNull() + expect(context).toContain('AUTO-FIX') + expect(context).toContain('unused') + }) + + test('full flow: no errors = no context', async () => { + const config = getAutoFixConfig({ + enabled: true, + lint: 'echo "all clean"', + timeout: 5000, + }) + const result = await runAutoFixCheck({ + lint: config!.lint, + timeout: config!.timeout, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(false) + const context = buildAutoFixContext(result) + expect(context).toBeNull() + }) +}) diff --git a/src/services/autoFix/autoFixRunner.test.ts b/src/services/autoFix/autoFixRunner.test.ts new file mode 100644 index 00000000..616d6170 --- /dev/null +++ b/src/services/autoFix/autoFixRunner.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test' +import { + runAutoFixCheck, + type AutoFixResult, + type AutoFixCheckOptions, +} from './autoFixRunner.js' + +describe('runAutoFixCheck', () => { + test('returns success when lint command exits 0', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "all clean"', + timeout: 5000, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(false) + expect(result.lintOutput).toContain('all clean') + expect(result.testOutput).toBeUndefined() + }) + + test('returns errors when lint command exits non-zero', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "error: unused var" && exit 1', + timeout: 5000, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + expect(result.lintOutput).toContain('unused var') + expect(result.lintExitCode).toBe(1) + }) + + test('returns errors when test command exits non-zero', async () => { + const result = await runAutoFixCheck({ + test: 'echo "FAIL test_foo" && exit 1', + timeout: 5000, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + expect(result.testOutput).toContain('FAIL test_foo') + expect(result.testExitCode).toBe(1) + }) + + test('runs both lint and test commands', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "lint ok"', + test: 'echo "test ok"', + timeout: 5000, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(false) + expect(result.lintOutput).toContain('lint ok') + expect(result.testOutput).toContain('test ok') + }) + + test('skips test if lint fails', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "lint error" && exit 1', + test: 'echo "should not run"', + timeout: 5000, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + expect(result.lintOutput).toContain('lint error') + expect(result.testOutput).toBeUndefined() + }) + + test('handles timeout gracefully', async () => { + const result = await runAutoFixCheck({ + lint: 'sleep 10', + timeout: 100, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + expect(result.timedOut).toBe(true) + }) + + test('returns success with no commands configured', async () => { + const result = await runAutoFixCheck({ + timeout: 5000, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(false) + }) + + test('formats error summary for AI consumption', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "src/foo.ts:10:5 error no-unused-vars" && exit 1', + timeout: 5000, + + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + const summary = result.errorSummary + expect(summary).toContain('Lint errors') + expect(summary).toContain('no-unused-vars') + }) +}) diff --git a/src/services/autoFix/autoFixRunner.ts b/src/services/autoFix/autoFixRunner.ts new file mode 100644 index 00000000..a24268a7 --- /dev/null +++ b/src/services/autoFix/autoFixRunner.ts @@ -0,0 +1,169 @@ +import { spawn } from 'child_process' + +export interface AutoFixCheckOptions { + lint?: string + test?: string + timeout: number + cwd: string + signal?: AbortSignal +} + +export interface AutoFixResult { + hasErrors: boolean + lintOutput?: string + lintExitCode?: number + testOutput?: string + testExitCode?: number + timedOut?: boolean + errorSummary?: string +} + +async function runCommand( + command: string, + cwd: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve({ stdout: '', stderr: 'Aborted', exitCode: 1, timedOut: false }) + return + } + + let timedOut = false + let stdout = '' + let stderr = '' + + const isWindows = process.platform === 'win32' + const proc = spawn(command, [], { + cwd, + env: { ...process.env }, + shell: true, + windowsHide: true, + // On Unix, create a process group so we can kill child processes on timeout/abort + detached: !isWindows, + }) + + const killTree = () => { + try { + if (!isWindows && proc.pid) { + // Kill the entire process group + process.kill(-proc.pid, 'SIGTERM') + } else { + proc.kill('SIGTERM') + } + } catch { + // Process may have already exited + } + } + + const onAbort = () => { + killTree() + } + signal?.addEventListener('abort', onAbort, { once: true }) + + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString() + }) + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString() + }) + + const timer = setTimeout(() => { + timedOut = true + killTree() + }, timeout) + + proc.on('close', (code) => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + resolve({ + stdout: stdout.slice(0, 10000), + stderr: stderr.slice(0, 10000), + exitCode: code ?? 1, + timedOut, + }) + }) + + proc.on('error', () => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + resolve({ + stdout, + stderr: stderr || 'Command failed to start', + exitCode: 1, + timedOut: false, + }) + }) + }) +} + +function buildErrorSummary(result: AutoFixResult): string | undefined { + if (!result.hasErrors) return undefined + const parts: string[] = [] + + if (result.timedOut) { + parts.push('Command timed out.') + } + if (result.lintExitCode !== undefined && result.lintExitCode !== 0) { + parts.push(`Lint errors (exit code ${result.lintExitCode}):\n${result.lintOutput ?? ''}`) + } + if (result.testExitCode !== undefined && result.testExitCode !== 0) { + parts.push(`Test failures (exit code ${result.testExitCode}):\n${result.testOutput ?? ''}`) + } + + return parts.join('\n\n') +} + +export async function runAutoFixCheck( + options: AutoFixCheckOptions, +): Promise { + const { lint, test, timeout, cwd, signal } = options + + if (!lint && !test) { + return { hasErrors: false } + } + + if (signal?.aborted) { + return { hasErrors: false } + } + + const result: AutoFixResult = { hasErrors: false } + + // Run lint first + if (lint) { + const lintResult = await runCommand(lint, cwd, timeout, signal) + result.lintOutput = (lintResult.stdout + '\n' + lintResult.stderr).trim() + result.lintExitCode = lintResult.exitCode + + if (lintResult.timedOut) { + result.hasErrors = true + result.timedOut = true + result.errorSummary = buildErrorSummary(result) + return result + } + + if (lintResult.exitCode !== 0) { + result.hasErrors = true + result.errorSummary = buildErrorSummary(result) + return result + } + } + + // Run tests only if lint passed (or no lint configured) + if (test) { + const testResult = await runCommand(test, cwd, timeout, signal) + result.testOutput = (testResult.stdout + '\n' + testResult.stderr).trim() + result.testExitCode = testResult.exitCode + + if (testResult.timedOut) { + result.hasErrors = true + result.timedOut = true + } else if (testResult.exitCode !== 0) { + result.hasErrors = true + } + } + + result.errorSummary = buildErrorSummary(result) + return result +} diff --git a/src/services/tools/toolHooks.ts b/src/services/tools/toolHooks.ts index cb2ef8fa..94596f93 100644 --- a/src/services/tools/toolHooks.ts +++ b/src/services/tools/toolHooks.ts @@ -29,6 +29,13 @@ import { } from '../../utils/permissions/PermissionResult.js' import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js' import { formatError } from '../../utils/toolErrors.js' +import { getAutoFixConfig } from '../autoFix/autoFixConfig.js' +import { shouldRunAutoFix, buildAutoFixContext } from '../autoFix/autoFixHook.js' +import { runAutoFixCheck } from '../autoFix/autoFixRunner.js' + +// Track auto-fix retry count per query chain to enforce maxRetries cap. +// Key: queryChainId (or 'default'), Value: number of auto-fix attempts used. +const autoFixRetryCount = new Map() import { isMcpTool } from '../mcp/utils.js' import type { McpServerType, MessageUpdateLazy } from './toolExecution.js' @@ -185,6 +192,65 @@ export async function* runPostToolUseHooks( } } } + + // Auto-fix: run lint/test if configured for this tool + const autoFixSettings = toolUseContext.getAppState().settings + const autoFixConfig = getAutoFixConfig( + autoFixSettings && typeof autoFixSettings === 'object' && 'autoFix' in autoFixSettings + ? (autoFixSettings as Record).autoFix + : undefined, + ) + if (shouldRunAutoFix(tool.name, autoFixConfig) && autoFixConfig) { + // Enforce maxRetries cap to prevent unbounded auto-fix loops. + // Uses queryChainId to scope the counter to the current conversation turn. + const chainKey = (toolUseContext.queryTracking?.chainId as string) ?? 'default' + const currentRetries = autoFixRetryCount.get(chainKey) ?? 0 + + if (currentRetries >= autoFixConfig.maxRetries) { + // Max retries reached — skip auto-fix and let the user know + yield { + message: createAttachmentMessage({ + type: 'hook_additional_context', + content: [ + `\nAUTO-FIX: Maximum retry limit (${autoFixConfig.maxRetries}) reached. ` + + `Skipping further auto-fix attempts. Please review the errors manually.\n`, + ], + hookName: `AutoFix:${tool.name}`, + toolUseID, + hookEvent: 'PostToolUse', + }), + } + } else { + try { + const cwd = toolUseContext.options?.cwd ?? process.cwd() + const autoFixResult = await runAutoFixCheck({ + lint: autoFixConfig.lint, + test: autoFixConfig.test, + timeout: autoFixConfig.timeout, + cwd, + signal: toolUseContext.abortController.signal, + }) + const autoFixContext = buildAutoFixContext(autoFixResult) + if (autoFixContext) { + autoFixRetryCount.set(chainKey, currentRetries + 1) + yield { + message: createAttachmentMessage({ + type: 'hook_additional_context', + content: [autoFixContext], + hookName: `AutoFix:${tool.name}`, + toolUseID, + hookEvent: 'PostToolUse', + }), + } + } else { + // Lint/test passed — reset the retry counter for this chain + autoFixRetryCount.delete(chainKey) + } + } catch (autoFixError) { + logError(autoFixError) + } + } + } } catch (error) { logError(error) } diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 65e0d906..dc2ed93e 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -27,6 +27,7 @@ export { // Also import for use within this file import { type HookCommand, HooksSchema } from '../../schemas/hooks.js' +import { AutoFixConfigSchema } from '../../services/autoFix/autoFixConfig.js' import { count } from '../array.js' /** @@ -435,6 +436,12 @@ export const SettingsSchema = lazySchema(() => hooks: HooksSchema() .optional() .describe('Custom commands to run before/after tool executions'), + autoFix: AutoFixConfigSchema + .optional() + .describe( + 'Auto-fix configuration: automatically run lint/test after AI file edits ' + + 'and feed errors back for self-repair.', + ), worktree: z .object({ symlinkDirectories: z