chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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<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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user