feat: add autoFix runner with lint/test command execution
Implements AutoFixRunner (Task 2) - executes lint and test shell commands sequentially, short-circuits on lint failure, handles timeouts, and produces structured AutoFixResult with AI-friendly error summaries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
95
src/services/autoFix/autoFixRunner.test.ts
Normal file
95
src/services/autoFix/autoFixRunner.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, 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')
|
||||
})
|
||||
})
|
||||
135
src/services/autoFix/autoFixRunner.ts
Normal file
135
src/services/autoFix/autoFixRunner.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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) => {
|
||||
let timedOut = false
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
const proc = spawn('bash', ['-c', command], {
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
})
|
||||
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
proc.kill('SIGTERM')
|
||||
}, timeout)
|
||||
|
||||
proc.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
resolve({
|
||||
stdout: stdout.slice(0, 10000),
|
||||
stderr: stderr.slice(0, 10000),
|
||||
exitCode: code ?? 1,
|
||||
timedOut,
|
||||
})
|
||||
})
|
||||
|
||||
proc.on('error', () => {
|
||||
clearTimeout(timer)
|
||||
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 }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user