Files
flynn/scripts/prune-phase0-baseline-artifacts.ts
T
William Valentin 5b9bcbafee fix(audit): validate phase0 artifact tag inputs
Add shared artifact-tag normalization/validation and apply it to capture, drift, and prune scripts for --tag/--report-tag/--baseline-tag paths. Architecture diagrams reviewed; no flow changes required.
2026-02-27 13:25:35 -08:00

169 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { parseArgs } from 'node:util';
import { normalizeArtifactTag } from '../src/audit/artifactTag.js';
import {
planRollingPhase0ArtifactRetention,
type Phase0RollingArtifactRetentionPlan,
} from '../src/audit/phase0BaselineArtifactRetention.js';
function usage(): string {
return [
'Usage: node --import tsx/esm scripts/prune-phase0-baseline-artifacts.ts [options]',
'',
'Options:',
' --artifacts-dir <path> Artifacts directory (default: docs/plans/artifacts)',
' --keep-per-family <num> Keep newest rolling tags per family (default: 8)',
' --apply Apply deletions (default: dry-run)',
' --report-tag <tag> Report tag suffix (default: current UTC date)',
' --write-default-artifacts Write report files to artifacts dir',
' --summary-json-out <path> Write JSON report to path',
' --summary-md-out <path> Write markdown report to path',
' --format <text|json> Output format (default: text)',
' --help Show usage',
].join('\n');
}
function isoDateTagNow(): string {
return new Date().toISOString().slice(0, 10);
}
function parseOptionalInteger(raw: string | undefined, flag: string): number | undefined {
if (!raw) {
return undefined;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid ${flag} value "${raw}". Expected an integer.`);
}
if (!Number.isInteger(parsed)) {
throw new Error(`Invalid ${flag} value "${raw}". Expected an integer.`);
}
if (parsed < 0) {
throw new Error(`${flag} must be greater than or equal to 0.`);
}
return parsed;
}
function renderText(plan: Phase0RollingArtifactRetentionPlan, artifactsDir: string, keepPerFamily: number, apply: boolean): string {
const lines: string[] = [];
lines.push('# Phase-0 Rolling Artifact Prune');
lines.push('');
lines.push(`Artifacts dir: ${artifactsDir}`);
lines.push(`Keep per family: ${keepPerFamily}`);
lines.push(`Mode: ${apply ? 'apply' : 'dry-run'}`);
lines.push(`Keep files: ${plan.keep.length}`);
lines.push(`Remove files: ${plan.remove.length}`);
lines.push('');
lines.push('## Families');
for (const row of plan.families) {
lines.push(`- ${row.family}: tags total=${row.total_tags} keep=${row.keep_tags} remove=${row.remove_tags}`);
}
lines.push('');
lines.push('## Remove List');
if (plan.remove.length === 0) {
lines.push('- none');
} else {
for (const row of plan.remove) {
lines.push(`- ${row.file_name}`);
}
}
return lines.join('\n');
}
async function writeTextFile(pathValue: string, contents: string): Promise<void> {
await mkdir(dirname(pathValue), { recursive: true });
await writeFile(pathValue, `${contents}\n`, 'utf8');
}
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
'artifacts-dir': { type: 'string' },
'keep-per-family': { type: 'string' },
apply: { type: 'boolean' },
'report-tag': { type: 'string' },
'write-default-artifacts': { type: 'boolean' },
'summary-json-out': { type: 'string' },
'summary-md-out': { type: 'string' },
format: { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
process.stdout.write(`${usage()}\n`);
return;
}
const artifactsDir = resolve(values['artifacts-dir'] ?? 'docs/plans/artifacts');
const keepPerFamily = parseOptionalInteger(values['keep-per-family'], '--keep-per-family') ?? 8;
const apply = Boolean(values.apply);
const format = values.format ?? 'text';
const reportTag = normalizeArtifactTag(values['report-tag'] ?? isoDateTagNow(), '--report-tag');
const writeDefaultArtifacts = Boolean(values['write-default-artifacts']);
if (format !== 'text' && format !== 'json') {
throw new Error(`Invalid --format value "${format}".`);
}
const defaultBaseName = resolve(artifactsDir, `phase0_baseline_live_prune_${reportTag}`);
const summaryJsonOut = values['summary-json-out']
? resolve(values['summary-json-out'])
: writeDefaultArtifacts
? `${defaultBaseName}.json`
: undefined;
const summaryMdOut = values['summary-md-out']
? resolve(values['summary-md-out'])
: writeDefaultArtifacts
? `${defaultBaseName}.md`
: undefined;
const files = await readdir(artifactsDir);
const plan = planRollingPhase0ArtifactRetention(files, keepPerFamily);
if (apply) {
for (const row of plan.remove) {
await rm(resolve(artifactsDir, row.file_name));
}
}
const payload = {
generated_at: new Date().toISOString(),
artifacts_dir: artifactsDir,
keep_per_family: keepPerFamily,
apply,
report_tag: reportTag,
reports: {
summary_json_out: summaryJsonOut,
summary_md_out: summaryMdOut,
},
plan,
};
const jsonOutput = JSON.stringify(payload, null, 2);
const textOutput = renderText(plan, artifactsDir, keepPerFamily, apply);
if (summaryJsonOut) {
await writeTextFile(summaryJsonOut, jsonOutput);
}
if (summaryMdOut) {
await writeTextFile(summaryMdOut, textOutput);
}
if (format === 'json') {
process.stdout.write(`${jsonOutput}\n`);
} else {
process.stdout.write(`${textOutput}\n`);
}
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n\n${usage()}\n`);
process.exitCode = 1;
});