import { spawnSync } from 'node:child_process'; import { appendFileSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { emitEvent } from './event-emitter.js'; import { nowISO } from './event-emitter.js'; import type { GateResult } from './types.js'; export interface NormalizedGate { command: string; type: string; fail_on: string; } export function normalizeGate(gate: unknown): NormalizedGate { if (typeof gate === 'string') { return { command: gate, type: 'mechanical', fail_on: 'blocker' }; } if (typeof gate === 'object' && gate !== null && !Array.isArray(gate)) { const g = gate as Record; return { command: String(g['command'] ?? ''), type: String(g['type'] ?? 'mechanical'), fail_on: String(g['fail_on'] ?? 'blocker'), }; } return { command: '', type: 'mechanical', fail_on: 'blocker' }; } export function runShell( command: string, cwd: string, logPath: string, timeoutSec: number, ): { exitCode: number; output: string; timedOut: boolean } { mkdirSync(dirname(logPath), { recursive: true }); const header = `\n[${nowISO()}] COMMAND: ${command}\n`; appendFileSync(logPath, header, 'utf-8'); let exitCode: number; let output = ''; let timedOut = false; try { const result = spawnSync('sh', ['-c', command], { cwd, timeout: Math.max(1, timeoutSec) * 1000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); output = (result.stdout ?? '') + (result.stderr ?? ''); if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { timedOut = true; exitCode = 124; appendFileSync(logPath, `[${nowISO()}] TIMEOUT: exceeded ${timeoutSec}s\n`, 'utf-8'); } else { exitCode = result.status ?? 1; } } catch { exitCode = 1; } if (output) appendFileSync(logPath, output, 'utf-8'); appendFileSync(logPath, `[${nowISO()}] EXIT: ${exitCode}\n`, 'utf-8'); return { exitCode, output, timedOut }; } export function countAIFindings(parsedOutput: unknown): { blockers: number; total: number } { if (typeof parsedOutput !== 'object' || parsedOutput === null || Array.isArray(parsedOutput)) { return { blockers: 0, total: 0 }; } const obj = parsedOutput as Record; const stats = obj['stats']; let blockers = 0; let total = 0; if (typeof stats === 'object' && stats !== null && !Array.isArray(stats)) { const s = stats as Record; blockers = Number(s['blockers']) || 0; total = blockers + (Number(s['should_fix']) || 0) + (Number(s['suggestions']) || 0); } const findings = obj['findings']; if (Array.isArray(findings)) { if (blockers === 0) { blockers = findings.filter( (f) => typeof f === 'object' && f !== null && (f as Record)['severity'] === 'blocker', ).length; } if (total === 0) { total = findings.length; } } return { blockers, total }; } export function runGate( gate: unknown, cwd: string, logPath: string, timeoutSec: number, ): GateResult { const gateEntry = normalizeGate(gate); const gateType = gateEntry.type; const command = gateEntry.command; if (gateType === 'ci-pipeline') { return { command, exit_code: 0, type: gateType, output: 'CI pipeline gate placeholder', timed_out: false, passed: true, }; } if (!command) { return { command: '', exit_code: 0, type: gateType, output: '', timed_out: false, passed: true, }; } const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec); const result: GateResult = { command, exit_code: exitCode, type: gateType, output, timed_out: timedOut, passed: false, }; if (gateType !== 'ai-review') { result.passed = exitCode === 0; return result; } const failOn = gateEntry.fail_on || 'blocker'; let parsedOutput: unknown = undefined; let blockers = 0; let findingsCount = 0; let parseError: string | undefined; try { parsedOutput = output.trim() ? JSON.parse(output) : {}; const counts = countAIFindings(parsedOutput); blockers = counts.blockers; findingsCount = counts.total; } catch (exc) { parseError = String(exc instanceof Error ? exc.message : exc); } if (failOn === 'any') { result.passed = exitCode === 0 && findingsCount === 0 && !timedOut && parseError === undefined; } else { result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined; } result.fail_on = failOn; result.blockers = blockers; result.findings = findingsCount; if (parsedOutput !== undefined) { result.parsed_output = parsedOutput; } if (parseError !== undefined) { result.parse_error = parseError; } return result; } export function runGates( gates: unknown[], cwd: string, logPath: string, timeoutSec: number, eventsPath: string, taskId: string, ): { allPassed: boolean; gateResults: GateResult[] } { let allPassed = true; const gateResults: GateResult[] = []; for (const gate of gates) { const gateEntry = normalizeGate(gate); const gateCmd = gateEntry.command; if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue; const label = gateCmd || gateEntry.type; emitEvent( eventsPath, 'rail.check.started', taskId, 'gated', 'quality-gate', `Running gate: ${label}`, ); const result = runGate(gate, cwd, logPath, timeoutSec); gateResults.push(result); if (result.passed) { emitEvent( eventsPath, 'rail.check.passed', taskId, 'gated', 'quality-gate', `Gate passed: ${label}`, ); continue; } allPassed = false; let message: string; if (result.timed_out) { message = `Gate timed out after ${timeoutSec}s: ${label}`; } else if (result.type === 'ai-review' && result.parse_error) { message = `AI review gate output was not valid JSON: ${label}`; } else { message = `Gate failed (${result.exit_code}): ${label}`; } emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message); } return { allPassed, gateResults }; }