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, GateStatus, RunGatesResult } from './types.js'; /** Typed reason stamped on every simulated gate result. */ export const SIMULATED_GATE_REASON = 'simulated execution (explicit simulate opt-in): gate was not evaluated by a real implementation'; /** Options for gate execution (RI-N2 fail-closed / explicit simulation). */ export interface RunGateOptions { /** * Explicit caller opt-in to simulation. Simulated gates are NOT executed; * every result is typed `simulated` and never satisfies anything. */ simulate?: boolean; } 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 }; } function simulatedResult(gateEntry: NormalizedGate): GateResult { return { command: gateEntry.command, exit_code: 0, type: gateEntry.type, output: SIMULATED_GATE_REASON, timed_out: false, passed: false, status: 'simulated', reason: SIMULATED_GATE_REASON, }; } function capabilityFailureResult( gateEntry: NormalizedGate, code: GateResult['capability_code'], reason: string, ): GateResult { return { command: gateEntry.command, exit_code: 1, type: gateEntry.type, output: '', timed_out: false, passed: false, status: 'capability_failure', capability_code: code, reason, }; } function waitingResult(gateEntry: NormalizedGate, reason: string): GateResult { return { command: gateEntry.command, exit_code: 0, type: gateEntry.type, output: '', timed_out: false, passed: false, status: 'waiting', capability_code: 'MACP_AUTHORITY_REQUIRED', reason, }; } export function runGate( gate: unknown, cwd: string, logPath: string, timeoutSec: number, options: RunGateOptions = {}, ): GateResult { const gateEntry = normalizeGate(gate); const gateType = gateEntry.type; const command = gateEntry.command; // Explicit simulation only: never executes, typed simulated, never satisfying. if (options.simulate) { return simulatedResult(gateEntry); } // Fail closed: no CI provider implementation exists in @mosaicstack/macp, // so a ci-pipeline gate is an absent capability — never a placeholder pass. if (gateType === 'ci-pipeline') { return capabilityFailureResult( gateEntry, 'MACP_NO_CI_PIPELINE', `ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`, ); } if (!command) { // A manual gate with no automation waits for human sign-off: not pass, not fail. if (gateType === 'manual') { return waitingResult( gateEntry, `manual gate has no automation — waiting for human sign-off (type: ${gateType})`, ); } // Any other commandless gate is an absent capability — never a vacuous pass. return capabilityFailureResult( gateEntry, gateType === 'ai-review' ? 'MACP_NO_REVIEWER' : 'MACP_NO_COMMAND', `gate of type '${gateType}' has no command to execute — refusing empty-command pass`, ); } const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec); const result: GateResult = { command, exit_code: exitCode, type: gateType, output, timed_out: timedOut, passed: false, status: 'failed', }; if (gateType !== 'ai-review') { result.passed = exitCode === 0; result.status = result.passed ? 'passed' : 'failed'; 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.status = result.passed ? 'passed' : 'failed'; 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, options: RunGateOptions = {}, ): RunGatesResult { const gateResults: GateResult[] = []; let hasCapabilityFailure = false; let hasSimulated = false; let hasFailed = false; let hasWaiting = false; for (const gate of gates) { const gateEntry = normalizeGate(gate); const gateCmd = gateEntry.command; const label = gateCmd || gateEntry.type; // NOTE: no silent skip — every gate produces a typed result (RI-N2). emitEvent( eventsPath, 'rail.check.started', taskId, 'gated', 'quality-gate', `Running gate: ${label}`, ); const result = runGate(gate, cwd, logPath, timeoutSec, options); gateResults.push(result); if (result.status === 'passed') { emitEvent( eventsPath, 'rail.check.passed', taskId, 'gated', 'quality-gate', `Gate passed: ${label}`, ); continue; } if (result.status === 'waiting') { hasWaiting = true; emitEvent( eventsPath, 'rail.check.waiting', taskId, 'gated', 'quality-gate', `Gate waiting: ${label} — ${result.reason ?? 'manual gate awaits sign-off'}`, ); continue; } if (result.status === 'simulated') { hasSimulated = true; emitEvent( eventsPath, 'rail.check.simulated', taskId, 'gated', 'quality-gate', `Gate simulated (non-satisfying): ${label}`, ); continue; } if (result.status === 'capability_failure') { hasCapabilityFailure = true; emitEvent( eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', `Gate capability failure (${result.capability_code ?? 'MACP_NO_PROVIDER'}): ${label} — ${result.reason ?? 'required capability is absent'}`, ); continue; } hasFailed = true; 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); } const state: GateStatus = hasCapabilityFailure ? 'capability_failure' : hasSimulated ? 'simulated' : hasFailed ? 'failed' : hasWaiting ? 'waiting' : 'passed'; return { allPassed: state === 'passed', gateResults, state }; }