/*
RFC-1126: pre-overwrite drift guard. Generators that rewrite git-tracked files
call guardTrackedOverwrite before writing; when the on-disk content carries
manual edits that the new content would destroy, the guard warns (default) or
throws (--strict) listing the exact keys/sections that would be lost.
- Does not perform the write itself — callers write via context.io / writeFileIfChanged after the guard returns.
- Does not decide marker-protocol skips — callers run hasGeneratedMarker checks first; the guard only sees files about to be written.
- Does not guard non-tracked files — the caller decides which paths are git-tracked generated outputs.
- Warn-by-default; --strict is the sole fail-hard opt-in — --force keeps DNA-93 cache-clearing semantics.
- The guard is a pre-write side effect, never a cached result — command-result caching (RFC-0390) must not cache it.
- RFC-1126: initial implementation — DriftReport, DriftGuardOptions, guardTrackedOverwrite with json/yaml/text key extraction.
- RFC-1126: step 1 — shared contracts (drift guard + workpiece env)
Add guardTrackedOverwrite (json/yaml/text lost-key diff, warn-by-default, --strict fail-hard) and loadWorkpieceEnv (dotenv.parse wrapper) to werkstatt-shared kernel subpath; extend KernelRuntimeContext with optional workpieceEnv field.
*/
import { parse as yamlParse } from "yaml";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export interface DriftReport {
file: string;
/** Keys/sections present on disk but absent or changed in the new content (dotted paths). */
wouldLose: string[];
/** True when on-disk content already equals new content — caller may skip the write. */
identical: boolean;
}
export interface DriftGuardOptions {
format: "json" | "yaml" | "text";
/** Fail-hard instead of warning. */
strict?: boolean;
/** Remediation hint appended to the warning/error message. */
fixHint?: string;
}
export interface DriftGuardLogger {
warn(msg: string): void;
}
const MAX_REPORTED_KEYS = 20;
/** Collect dotted paths present in `oldValue` but absent or changed in `newValue`. */
function collectLostKeys(
oldValue: unknown,
newValue: unknown,
prefix: string,
out: string[],
): void {
if (out.length >= MAX_REPORTED_KEYS) return;
if (oldValue === null || typeof oldValue !== "object") {
if (oldValue !== newValue) out.push(prefix);
return;
}
if (Array.isArray(oldValue)) {
if (!Array.isArray(newValue)) {
out.push(prefix);
return;
}
for (let i = 0; i < oldValue.length; i++) {
if (i >= newValue.length) {
out.push(`${prefix}[${i}]`);
} else {
collectLostKeys(oldValue[i], newValue[i], `${prefix}[${i}]`, out);
}
if (out.length >= MAX_REPORTED_KEYS) return;
}
return;
}
const oldObj = oldValue as Record;
const newObj =
newValue !== null && typeof newValue === "object" && !Array.isArray(newValue)
? (newValue as Record)
: {};
for (const key of Object.keys(oldObj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (!(key in newObj)) {
out.push(path);
} else {
collectLostKeys(oldObj[key], newObj[key], path, out);
}
if (out.length >= MAX_REPORTED_KEYS) return;
}
}
function diffStructured(format: "json" | "yaml", existing: string, next: string): string[] {
try {
const oldValue: unknown = format === "json" ? JSON.parse(existing) : yamlParse(existing);
const newValue: unknown = format === "json" ? JSON.parse(next) : yamlParse(next);
const out: string[] = [];
collectLostKeys(oldValue, newValue, "", out);
return out;
} catch {
// Unparseable existing content — fall back to line diff so drift is still visible.
return diffText(existing, next);
}
}
function diffText(existing: string, next: string): string[] {
const nextLines = new Set(next.split("\n").map((l) => l.trim()));
const lost: string[] = [];
for (const line of existing.split("\n")) {
const trimmed = line.trim();
if (trimmed && !nextLines.has(trimmed)) {
lost.push(trimmed.length > 80 ? `${trimmed.slice(0, 77)}…` : trimmed);
if (lost.length >= MAX_REPORTED_KEYS) break;
}
}
return lost;
}
/**
* Compare newContent against the on-disk file before overwriting. Returns a
* DriftReport; warns via logger (or throws when options.strict) listing the
* keys that would be destroyed. Never throws for a missing file — nothing to
* lose. Callers should skip the write entirely when report.identical is true.
*/
export async function guardTrackedOverwrite(
filePath: string,
newContent: string,
options: DriftGuardOptions,
logger: DriftGuardLogger,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
let existing: string | null = null;
try {
existing = await io.readFile(filePath);
} catch {
// File absent — nothing to lose.
}
if (existing === null) {
return { file: filePath, wouldLose: [], identical: false };
}
if (existing === newContent) {
return { file: filePath, wouldLose: [], identical: true };
}
const wouldLose =
options.format === "text"
? diffText(existing, newContent)
: diffStructured(options.format, existing, newContent);
if (wouldLose.length === 0) {
return { file: filePath, wouldLose, identical: false };
}
const hint = options.fixHint ? ` ${options.fixHint}` : "";
const message =
`DRIFT-GUARD-01: ${filePath} — manual edits would be destroyed: ` +
`${wouldLose.join(", ")}.${hint}`;
if (options.strict) {
throw new Error(message);
}
logger.warn(message);
return { file: filePath, wouldLose, identical: false };
}