241 lines
6.2 KiB
TypeScript
241 lines
6.2 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown>;
|
|
const stats = obj['stats'];
|
|
let blockers = 0;
|
|
let total = 0;
|
|
|
|
if (typeof stats === 'object' && stats !== null && !Array.isArray(stats)) {
|
|
const s = stats as Record<string, unknown>;
|
|
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<string, unknown>)['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 };
|
|
}
|