/*
RFC-0520: extracted I/O collector for the Bordbuch-vs-git-log external edit guard.
- Does not contain guard logic — that lives in external-edit-guard.ts as a pure function.
- ADR-0091: collect externalEditBaselineSha from system-state.yaml via loose read — malformed values reach the guard as baseline-missing.
- RFC-0520 follow-up: read bordbuch `kind` field (the schema field — `type` never existed, so every commit was untraced) and collect git subjects so the guard can whitelist pipeline bookkeeping commits.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
- ADR-0093: collect mergedShas[] from mission-reconcile entry metadata so the guard can trace every merged workpiece commit.
- RFC-1254: drop unused io-migration leftovers
ADR-0040, RFC-0520
*/
import path from "node:path";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
import { parse as parseYaml } from "yaml";
export interface ExternalEditCollectorOutput {
bordbuchEntries: Array<{
kind?: string;
metadata?: { commitSha?: string; preReconcileSha?: string; mergedShas?: string[] };
}>;
rangeShas: string[];
gitLogShas: string[];
/** Commit subjects aligned with gitLogShas order — used by the guard to
* whitelist machine-authored pipeline bookkeeping commits (Bordbuch:,
* chore:, system-state:, …) that can never carry a forward trace. */
gitLogSubjects: string[];
// ADR-0091: optional baseline SHA from system-state.yaml — undefined when the
// field is absent or the state file cannot be read.
externalEditBaselineSha?: string;
}
export async function collectExternalEditInputs(
cacheDir: string,
bordbuchPath: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const bordbuchRaw = await io.readFile(bordbuchPath);
const lines = bordbuchRaw.split("\n").filter((l) => l.trim().length > 0);
const bordbuchEntries: ExternalEditCollectorOutput["bordbuchEntries"] = [];
const rangeShas: string[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line) as {
kind?: string;
metadata?: { commitSha?: string; preReconcileSha?: string; mergedShas?: string[] };
};
bordbuchEntries.push(entry);
if (entry.kind === "mission-reconcile" && entry.metadata?.commitSha) {
if (entry.metadata.preReconcileSha) {
const rangeResult = await io.exec(
"git",
["rev-list", `${entry.metadata.preReconcileSha}..${entry.metadata.commitSha}`],
{ cwd: cacheDir },
);
if (rangeResult.exitCode === 0) {
for (const sha of rangeResult.stdout.split("\n").filter((l) => l.trim())) {
rangeShas.push(sha.trim());
}
}
// Non-zero exit: range may be invalid if history was rewritten — skip
}
}
} catch {
// Skip unparseable lines
}
}
const gitLogShas: string[] = [];
const gitLogSubjects: string[] = [];
const logResult = await io.exec("git", ["log", "--all", "--format=%H%x00%s"], { cwd: cacheDir });
if (logResult.exitCode === 0) {
for (const line of logResult.stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const sep = trimmed.indexOf("\x00");
if (sep === -1) {
gitLogShas.push(trimmed);
gitLogSubjects.push("");
} else {
gitLogShas.push(trimmed.slice(0, sep));
gitLogSubjects.push(trimmed.slice(sep + 1));
}
}
}
// ADR-0091: loose read of the baseline field — no full schema parse, so a
// malformed value still reaches the guard and surfaces as the precise
// external-edit-baseline-missing violation instead of silently disabling the
// check. systemStateSchema enforces the 40-hex format on canonical readers.
let externalEditBaselineSha: string | undefined;
try {
const stateRaw = await io.readFile(path.join(cacheDir, "system-state.yaml"));
const stateParsed = parseYaml(stateRaw) as { externalEditBaselineSha?: unknown } | null;
if (typeof stateParsed?.externalEditBaselineSha === "string") {
externalEditBaselineSha = stateParsed.externalEditBaselineSha;
}
} catch {
// Missing or unparseable state file — baseline unset, guard runs unfiltered
}
return { bordbuchEntries, rangeShas, gitLogShas, gitLogSubjects, externalEditBaselineSha };
}
export async function bordbuchFileExists(
cacheDir: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const bordbuchPath = path.join(cacheDir, "bordbuch", "events.ndjson");
const gitDir = path.join(cacheDir, ".git");
return (await io.exists(bordbuchPath)) && (await io.exists(gitDir));
}
/**
* Computes the bordbuch events.ndjson path for a cache directory.
*
* @returns The computed bordbuch file path. Always returns a string — the
* file may not exist on disk. Callers MUST check with
* `existsSync` before relying on the path.
*/
export function bordbuchPathFor(cacheDir: string): string {
return path.join(cacheDir, "bordbuch", "events.ndjson");
}