Files
projectbrain-Public/scripts/release-evidence-retention.mjs
T

156 lines
5.1 KiB
JavaScript

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"}.`);
}