Compare commits
8 Commits
v0.2.3
...
feat/auto-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff7eccc36c | ||
|
|
fbc838ce55 | ||
|
|
8c2d56844b | ||
|
|
6041b7f016 | ||
|
|
122f7b83f3 | ||
|
|
68230f3ffb | ||
|
|
832e80e535 | ||
|
|
93dc5a1554 |
@@ -136,6 +136,7 @@ import hooks from './commands/hooks/index.js'
|
|||||||
import files from './commands/files/index.js'
|
import files from './commands/files/index.js'
|
||||||
import branch from './commands/branch/index.js'
|
import branch from './commands/branch/index.js'
|
||||||
import agents from './commands/agents/index.js'
|
import agents from './commands/agents/index.js'
|
||||||
|
import autoFix from './commands/auto-fix.js'
|
||||||
import plugin from './commands/plugin/index.js'
|
import plugin from './commands/plugin/index.js'
|
||||||
import reloadPlugins from './commands/reload-plugins/index.js'
|
import reloadPlugins from './commands/reload-plugins/index.js'
|
||||||
import rewind from './commands/rewind/index.js'
|
import rewind from './commands/rewind/index.js'
|
||||||
@@ -263,6 +264,7 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
addDir,
|
addDir,
|
||||||
advisor,
|
advisor,
|
||||||
agents,
|
agents,
|
||||||
|
autoFix,
|
||||||
branch,
|
branch,
|
||||||
btw,
|
btw,
|
||||||
chrome,
|
chrome,
|
||||||
|
|||||||
25
src/commands/auto-fix.ts
Normal file
25
src/commands/auto-fix.ts
Normal file
@@ -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
|
||||||
106
src/services/autoFix/autoFixConfig.test.ts
Normal file
106
src/services/autoFix/autoFixConfig.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
52
src/services/autoFix/autoFixConfig.ts
Normal file
52
src/services/autoFix/autoFixConfig.ts
Normal file
@@ -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<typeof AutoFixConfigSchema>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
63
src/services/autoFix/autoFixHook.test.ts
Normal file
63
src/services/autoFix/autoFixHook.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
25
src/services/autoFix/autoFixHook.ts
Normal file
25
src/services/autoFix/autoFixHook.ts
Normal file
@@ -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 (
|
||||||
|
`<auto_fix_feedback>\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` +
|
||||||
|
`</auto_fix_feedback>`
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/services/autoFix/autoFixIntegration.test.ts
Normal file
48
src/services/autoFix/autoFixIntegration.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
103
src/services/autoFix/autoFixRunner.test.ts
Normal file
103
src/services/autoFix/autoFixRunner.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
169
src/services/autoFix/autoFixRunner.ts
Normal file
169
src/services/autoFix/autoFixRunner.ts
Normal file
@@ -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<AutoFixResult> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@ import {
|
|||||||
} from '../../utils/permissions/PermissionResult.js'
|
} from '../../utils/permissions/PermissionResult.js'
|
||||||
import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js'
|
import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js'
|
||||||
import { formatError } from '../../utils/toolErrors.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<string, number>()
|
||||||
import { isMcpTool } from '../mcp/utils.js'
|
import { isMcpTool } from '../mcp/utils.js'
|
||||||
import type { McpServerType, MessageUpdateLazy } from './toolExecution.js'
|
import type { McpServerType, MessageUpdateLazy } from './toolExecution.js'
|
||||||
|
|
||||||
@@ -185,6 +192,65 @@ export async function* runPostToolUseHooks<Input extends AnyObject, Output>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<string, unknown>).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: [
|
||||||
|
`<auto_fix_feedback>\nAUTO-FIX: Maximum retry limit (${autoFixConfig.maxRetries}) reached. ` +
|
||||||
|
`Skipping further auto-fix attempts. Please review the errors manually.\n</auto_fix_feedback>`,
|
||||||
|
],
|
||||||
|
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) {
|
} catch (error) {
|
||||||
logError(error)
|
logError(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export {
|
|||||||
|
|
||||||
// Also import for use within this file
|
// Also import for use within this file
|
||||||
import { type HookCommand, HooksSchema } from '../../schemas/hooks.js'
|
import { type HookCommand, HooksSchema } from '../../schemas/hooks.js'
|
||||||
|
import { AutoFixConfigSchema } from '../../services/autoFix/autoFixConfig.js'
|
||||||
import { count } from '../array.js'
|
import { count } from '../array.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -435,6 +436,12 @@ export const SettingsSchema = lazySchema(() =>
|
|||||||
hooks: HooksSchema()
|
hooks: HooksSchema()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Custom commands to run before/after tool executions'),
|
.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
|
worktree: z
|
||||||
.object({
|
.object({
|
||||||
symlinkDirectories: z
|
symlinkDirectories: z
|
||||||
|
|||||||
Reference in New Issue
Block a user