import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof"; const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff"; const keepArg = process.argv.find((arg) => arg.startsWith("--keep=")); const keep = Number.parseInt(keepArg?.slice("--keep=".length) ?? process.env.PROJECTBRAIN_EVIDENCE_KEEP ?? "20", 10); const shouldPrune = process.argv.includes("--prune"); const confirmed = process.argv.includes("--yes") || process.env.PROJECTBRAIN_EVIDENCE_PRUNE_CONFIRM === "1"; const run = (command, args) => spawnSync(command, args, { encoding: "utf8", shell: process.platform === "win32", }); const getGitCommit = () => { const result = run("git", ["rev-parse", "--short", "HEAD"]); return result.status === 0 ? result.stdout.trim() : "unknown"; }; const parseField = (text, label) => { const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m")); return match?.[1]?.trim() ?? "unknown"; }; const displayPath = (filePath) => filePath.split(path.sep).join("/"); const safeKeep = Number.isFinite(keep) && keep > 0 ? keep : 20; const listMarkdownFiles = (archiveDir, type) => { if (!fs.existsSync(archiveDir)) return []; return fs.readdirSync(archiveDir) .filter((fileName) => fileName.endsWith(".md")) .map((fileName) => path.join(archiveDir, fileName)) .filter((filePath) => fs.statSync(filePath).isFile()) .map((filePath) => { const text = fs.readFileSync(filePath, "utf8"); const stat = fs.statSync(filePath); const generated = parseField(text, "Generated"); return { type, filePath, displayPath: displayPath(filePath), fileName: path.basename(filePath), generated, modifiedAt: stat.mtime.toISOString(), commit: parseField(text, "Commit"), verdict: parseField(text, "Verdict"), freshness: parseField(text, "Freshness"), }; }); }; const sortNewestFirst = (items) => items.sort((a, b) => { const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated); if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta; const modifiedDelta = Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt); if (Number.isFinite(modifiedDelta) && modifiedDelta !== 0) return modifiedDelta; return b.fileName.localeCompare(a.fileName); }); const createArchive = (archiveDir, type) => { const items = sortNewestFirst(listMarkdownFiles(archiveDir, type)); const keepers = items.slice(0, safeKeep); const candidates = items.slice(safeKeep); return { type, archiveDir, items, keepers, candidates, }; }; const deleteCandidate = (candidate, archiveDir) => { const resolvedArchiveDir = path.resolve(archiveDir); const resolvedFile = path.resolve(candidate.filePath); if (!resolvedFile.startsWith(`${resolvedArchiveDir}${path.sep}`)) { throw new Error(`Refusing to delete outside archive: ${candidate.displayPath}`); } if (!candidate.fileName.endsWith(".md")) { throw new Error(`Refusing to delete non-markdown artifact: ${candidate.displayPath}`); } fs.unlinkSync(candidate.filePath); }; const currentCommit = getGitCommit(); const archives = [ createArchive(proofDir, "proof"), createArchive(signoffDir, "signoff"), ]; const candidates = archives.flatMap((archive) => archive.candidates.map((candidate) => ({ ...candidate, archiveDir: archive.archiveDir, }))); const lines = [ "# ProjectBrain Release Evidence Retention", "", `Current commit: ${currentCommit}`, `Mode: ${shouldPrune ? confirmed ? "prune" : "dry-run prune" : "list"}`, `Keep newest per archive: ${safeKeep}`, "", "| Archive | Type | Snapshots | Kept | Prune candidates |", "| --- | --- | --- | --- | --- |", ]; for (const archive of archives) { lines.push(`| ${archive.archiveDir} | ${archive.type} | ${archive.items.length} | ${archive.keepers.length} | ${archive.candidates.length} |`); } lines.push(""); if (!candidates.length) { lines.push("No release evidence cleanup candidates found."); } else { lines.push("## Cleanup Candidates"); lines.push(""); lines.push("| Generated | Type | Commit | Verdict/Freshness | File |"); lines.push("| --- | --- | --- | --- | --- |"); for (const candidate of candidates) { const state = candidate.type === "proof" ? candidate.verdict : candidate.freshness; lines.push(`| ${candidate.generated} | ${candidate.type} | ${candidate.commit} | ${state} | ${candidate.displayPath} |`); } } if (shouldPrune && !confirmed) { lines.push(""); lines.push("Dry-run only. To delete cleanup candidates, rerun with:"); lines.push(""); lines.push("```bash"); lines.push("pnpm run release:evidence-prune -- --yes"); lines.push("```"); } console.log(lines.join("\n")); if (shouldPrune && confirmed) { for (const candidate of candidates) { deleteCandidate(candidate, candidate.archiveDir); } console.log(""); console.log(`Deleted ${candidates.length} release evidence artifact${candidates.length === 1 ? "" : "s"}.`); }