/*
RFC-1256: mission.deploy-preflight — deploy-readiness probe. Runs the
historically fragile non-build validators (DEPLOY_PREFLIGHT_CHECKS) against
a mission workpiece in one aggregated pass before mission.reconcile, so
latent deploy blockers surface in a single report instead of across serial
full-validation cycles. Read-only per K-0004; dispatches to registered check
commands via executeRegisteredCommand with a KernelRuntimeContext whose
site.directory points at the workpiece.
- Do not replace mission.validate — it remains the reconcile gate for full-lane missions; this probe stays advisory. RFC-1263 wires it into mission.content-edit as the fast lane's gate 4 (advisory there too) — never wired into leitstand.ship.
- Do not run build pipelines — every step is a non-build validator; the probe stays inside the RFC-0963 time budget.
- Do not mutate the workpiece — no commits, no artifact regeneration; workpieceDirty is informational only.
- Do not evaluate the cache clone — mission.preflight owns the cache-clone subject; this command only reads missions/{id}/workpiece.
- warpgogol-m000196: layout.rhythm.validate appended — post-hero density rhythm gate (mirrors DEPLOY_PREFLIGHT_STEPS in werkstatt-site).
- RFC-1256: initial implementation — mirrors mission-preflight.ts dispatch (RFC-1157 site-module resolution), aggregates all steps, warning-only steps report pass, workpieceDirty informational.
- RFC-1256: a11y.contrast.lint + mission.deploy-preflight — early deploy-blocker detection
- RFC-1256: review fixes — manifest-derived systemId, at-statement boundary, alpha/NaN guards, resolveSiteWorkspace, execOnReadOnly drift
- RFC-1263: deploy-preflight becomes the fast lane's advisory gate 4 (mission.content-edit).
*/
import { join } from "node:path";
import type {
CheckResult,
KernelCommandInput,
KernelCommandResult,
KernelExecutionReport,
KernelNextStep,
KernelRuntimeContext,
DiscoveredSiteWorkspace,
} from "@warpgogol/werkstatt-engine/kernel";
import { executeRegisteredCommand, siteModuleLoaders } from "@warpgogol/werkstatt-engine/kernel";
import { computeOwnershipMap } from "../kernel/runtime/execute-command.ts";
import { getOrBuildSiteModuleStates } from "../kernel/runtime/registry-cache.ts";
import { resolveMissionDir } from "../kernel/mission-resolver.ts";
import { isWorkpieceDirty } from "./mission-git-commit.ts";
import { readMissionManifest } from "./mission-io.ts";
/**
* Deploy-blocker families observed in warpgogol-m000196 plus their fragile
* neighbors. Mirrors DEPLOY_PREFLIGHT_STEPS in
* packages/werkstatt-site/src/checks/pipelines/deploy-preflight.ts — the two
* lists exist on opposite sides of the engine↛site package boundary; the
* resolution test asserts every name here resolves to a registered command.
*/
export const DEPLOY_PREFLIGHT_CHECKS = [
"semantic.parity",
"feature.links.validate",
"nachweis.validate",
"generated.drift.validate",
"a11y.contrast.lint",
"ratgeber.claim.validate",
"ratgeber.policy.validate",
"typography.validate",
"list.punctuation.validate",
"shared.context.validate",
"page.block.validate",
"cosmic.catalog.validate",
"layout.rhythm.validate",
] as const;
export interface DeployPreflightStepResult {
command: string;
status: "pass" | "fail" | "unavailable" | "skipped";
violations: number;
durationMs: number;
/** Command's own summary — surfaced for skipped/vacuous steps so a skip reason is never dropped. */
note?: string;
}
export interface MissionDeployPreflightData {
command: "mission.deploy-preflight";
mission: string;
status: "pass" | "fail";
steps: DeployPreflightStepResult[];
totalViolations: number;
totalDurationMs: number;
workpieceDirty: boolean;
}
function countViolations(report: KernelExecutionReport): number {
const data = report.data as CheckResult | undefined;
if (!data || !data.diagnostics) return report.exitCode !== 0 ? 1 : 0;
const errors = data.diagnostics.filter((d) => d.severity === "error").length;
return errors > 0 ? errors : report.exitCode !== 0 ? 1 : 0;
}
function buildNextSteps(steps: DeployPreflightStepResult[]): KernelNextStep[] {
const failed = steps.filter((s) => s.status === "fail" || s.status === "unavailable");
if (failed.length === 0) return [];
return [
{
action: `Fix deploy blockers reported by: ${failed.map((s) => s.command).join(", ")} — re-run an individual validator for detail, then mission.deploy-preflight`,
kind: "required",
},
];
}
export async function runMissionDeployPreflight(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const missionId = input.flags.mission as string | undefined;
if (!missionId) {
return {
data: {
command: "mission.deploy-preflight",
mission: "",
status: "fail",
steps: [],
totalViolations: 0,
totalDurationMs: 0,
workpieceDirty: false,
},
exitCode: 1,
summary: "[mission.deploy-preflight] missing required --mission flag",
nextSteps: [{ action: "Provide --mission flag", kind: "required" }],
};
}
const missionDir = await resolveMissionDir(context.workspaceRoot, missionId, context.io);
const workpieceDir = missionDir ? join(missionDir, "workpiece") : undefined;
if (!workpieceDir || !(await context.io.exists(workpieceDir))) {
return {
data: {
command: "mission.deploy-preflight",
mission: missionId,
status: "fail",
steps: [],
totalViolations: 0,
totalDurationMs: 0,
workpieceDirty: false,
},
exitCode: 1,
summary: `[mission.deploy-preflight] no workpiece at missions/${missionId}/workpiece — the probe has no subject. Open or materialize the mission first.`,
nextSteps: [
{
action: `Run: pnpm exec werkstatt run mission.materialize --mission ${missionId}`,
kind: "required",
},
],
};
}
// Identity-derived consumers (nachweis.validate resolves systemId from
// site.name, generated.drift.validate uses it for generator dispatch) need
// the Sternsystem id, not the mission id — read it from mission.yaml, same
// as mission.validate's closed-workpiece site synthesis.
let systemId = missionId;
try {
const manifest = await readMissionManifest(context.workspaceRoot, missionId, context.io);
systemId = manifest.systemId;
} catch (err) {
context.logger.warn(
`[mission.deploy-preflight] mission.yaml unreadable — site.name falls back to the mission id and identity-derived steps may skip: ${err instanceof Error ? err.message : String(err)}`,
);
}
const workpieceSite: DiscoveredSiteWorkspace = {
name: systemId,
directory: workpieceDir,
toolsDirectory: join(workpieceDir, "tools"),
configPath: join(workpieceDir, "tools", "kernel.config.ts"),
};
// RFC-1157: check commands live in site-module ActualStates — materialize
// the canonical site module set once and merge its generator ownership into
// the dispatch context (same pattern as mission.preflight).
let siteStates: Awaited> = new Map();
try {
siteStates = await getOrBuildSiteModuleStates(context.workspaceRoot, siteModuleLoaders());
} catch (err) {
context.logger.warn(
`[mission.deploy-preflight] site-module states unavailable: ${err instanceof Error ? err.message : String(err)}`,
);
}
const ownershipMap = [...(context.ownershipMap ?? [])];
const seenOwnership = new Set(ownershipMap.map((e) => `${e.command}:${e.artifact.path}`));
for (const state of siteStates.values()) {
const siteMap = await computeOwnershipMap(state);
if (!siteMap) continue;
for (const entry of siteMap) {
const key = `${entry.command}:${entry.artifact.path}`;
if (seenOwnership.has(key)) continue;
seenOwnership.add(key);
ownershipMap.push(entry);
}
}
const workpieceContext: KernelRuntimeContext = {
...context,
site: workpieceSite,
ownershipMap,
};
const steps: DeployPreflightStepResult[] = [];
let totalViolations = 0;
let totalDurationMs = 0;
for (const stepName of DEPLOY_PREFLIGHT_CHECKS) {
const started = Date.now();
let command = context.actualState.commands.get(stepName);
if (!command) {
for (const state of siteStates.values()) {
command = state.commands.get(stepName);
if (command) break;
}
}
if (!command) {
steps.push({ command: stepName, status: "unavailable", violations: 1, durationMs: 0 });
totalViolations += 1;
context.logger.warn(`[mission.deploy-preflight] Command ${stepName} is not registered`);
continue;
}
const report = await executeRegisteredCommand(command, workpieceContext, []);
const durationMs = Date.now() - started;
totalDurationMs += durationMs;
// Vacuity tripwire: a step that self-reports `skipped` (e.g. nachweis
// entitlement unresolved) is surfaced distinctly — never masquerades as
// "pass" and never drops its own reason (REVIEW-RFC-1256 finding).
const skipped = (report.data as { skipped?: boolean } | undefined)?.skipped === true;
const violations = countViolations(report);
// Q-2 (AUDIT-RFC-1256-01): warning-only results count as pass — identical
// to mission.validate semantics where advisory diagnostics do not block.
const status = skipped ? "skipped" : violations > 0 ? "fail" : "pass";
steps.push({
command: stepName,
status,
violations,
durationMs,
...(skipped && report.summary ? { note: report.summary } : {}),
});
totalViolations += violations;
}
const dirty = await isWorkpieceDirty(workpieceDir, context.io);
const overallStatus = totalViolations > 0 ? "fail" : "pass";
const data: MissionDeployPreflightData = {
command: "mission.deploy-preflight",
mission: missionId,
status: overallStatus,
steps,
totalViolations,
totalDurationMs,
workpieceDirty: dirty.dirty,
};
const skippedCount = steps.filter((s) => s.status === "skipped").length;
const skippedNote = skippedCount > 0 ? `, ${skippedCount} skipped` : "";
const summary =
overallStatus === "pass"
? `[mission.deploy-preflight] ${missionId}: ${steps.length - skippedCount}/${steps.length} deploy checks passed in ${(totalDurationMs / 1000).toFixed(1)}s${skippedNote}${dirty.dirty ? " (workpiece dirty — informational)" : ""}`
: `[mission.deploy-preflight] ${missionId}: ${totalViolations} violation(s) across ${steps.filter((s) => s.status === "fail" || s.status === "unavailable").length} step(s)${skippedNote}`;
return {
data,
exitCode: totalViolations > 0 ? 1 : 0,
summary,
nextSteps: buildNextSteps(steps),
};
}