156 lines
5.1 KiB
JavaScript
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"}.`);
|
|
}
|