Adds scripts/compliance/emit-retention-policy.mjs which walks Payload collection files, validates purgeSchedule is declared on every collection, and emits deterministic YAML to compliance/retention-policy.yml. Supports --print and --check modes. Wires compliance:retention-policy root package script. Ships 19 unit tests covering validation, builder, YAML rendering, and diff modes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
229 lines
6.8 KiB
JavaScript
229 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* emit-retention-policy.mjs — Collection retention policy emitter.
|
|
*
|
|
* Walks packages/*\/src/integrations/cms/collections/*.ts, validates that each
|
|
* collection declares custom.retention.purgeSchedule, and emits a deterministic
|
|
* YAML retention schedule at compliance/retention-policy.yml.
|
|
*
|
|
* Usage:
|
|
* node scripts/compliance/emit-retention-policy.mjs # write compliance/retention-policy.yml
|
|
* node scripts/compliance/emit-retention-policy.mjs --print # write to stdout
|
|
* node scripts/compliance/emit-retention-policy.mjs --check # diff vs committed file; exit 1 on mismatch
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
findCollectionFiles,
|
|
parseCollectionFile,
|
|
unifiedDiff,
|
|
REPO_ROOT,
|
|
} from "./emit-data-map.mjs";
|
|
|
|
export { findCollectionFiles, parseCollectionFile, unifiedDiff, REPO_ROOT };
|
|
|
|
export const OUTPUT_PATH = "compliance/retention-policy.yml";
|
|
|
|
// ---- Validation ----
|
|
|
|
/**
|
|
* Validate that every collection has custom.retention.purgeSchedule.
|
|
* Returns an array of error messages (empty array = valid).
|
|
*/
|
|
export function validateRetentionPolicy(rawCollections) {
|
|
const errors = [];
|
|
for (const coll of rawCollections) {
|
|
if (!coll || typeof coll.slug !== "string") continue;
|
|
const retention = coll.custom && coll.custom.retention;
|
|
if (!retention || typeof retention.purgeSchedule !== "string") {
|
|
errors.push(
|
|
`Collection "${coll.slug}" is missing custom.retention.purgeSchedule.\n` +
|
|
` Hint: add to your collection config:\n` +
|
|
` custom: { retention: { purgeSchedule: "daily" | "weekly" | "monthly" } }`,
|
|
);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
// ---- Retention policy builder ----
|
|
|
|
/**
|
|
* Build the retention policy map from parsed collections.
|
|
* Returns Record<slug, { slug, purgeSchedule, activeRetention?, postDeletion?, coldArchive? }>
|
|
*/
|
|
export function buildRetentionPolicy(rawCollections) {
|
|
const map = {};
|
|
for (const coll of rawCollections) {
|
|
if (!coll || typeof coll.slug !== "string") continue;
|
|
const retention = (coll.custom && coll.custom.retention) || {};
|
|
const entry = { slug: coll.slug, purgeSchedule: retention.purgeSchedule };
|
|
|
|
if (retention.activeRetention) {
|
|
entry.activeRetention = { ...retention.activeRetention };
|
|
}
|
|
if (retention.coldArchive) {
|
|
entry.coldArchive = { ...retention.coldArchive };
|
|
}
|
|
if (retention.postDeletion) {
|
|
entry.postDeletion = { ...retention.postDeletion };
|
|
}
|
|
|
|
map[coll.slug] = entry;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// ---- YAML serialization ----
|
|
|
|
const YAML_HEADER = [
|
|
"# compliance/retention-policy.yml — Collection retention schedules",
|
|
"# Generated by scripts/compliance/emit-retention-policy.mjs — do not edit manually.",
|
|
"# Run `pnpm compliance:retention-policy` to regenerate.",
|
|
].join("\n");
|
|
|
|
/** Quote a YAML string scalar only when necessary. */
|
|
function yamlStr(s) {
|
|
if (typeof s !== "string") return String(s);
|
|
if (
|
|
s === "" ||
|
|
["true", "false", "null", "yes", "no", "on", "off"].includes(s) ||
|
|
/[[{},:#&*!|>'"%@`\]]/u.test(s) ||
|
|
/^\s|\s$/.test(s)
|
|
) {
|
|
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/**
|
|
* Render an optional retention sub-object (activeRetention, postDeletion, coldArchive).
|
|
* Keys are sorted alphabetically for determinism.
|
|
*/
|
|
function renderRetentionBlock(label, obj, indent) {
|
|
let out = `${indent}${label}:\n`;
|
|
for (const key of Object.keys(obj).sort()) {
|
|
out += `${indent} ${key}: ${yamlStr(obj[key])}\n`;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Render the retention policy as deterministic YAML.
|
|
* Collections sorted by slug; optional sub-objects rendered alphabetically by key.
|
|
*/
|
|
export function renderRetentionPolicyYaml(policyMap) {
|
|
let yaml = YAML_HEADER + "\n";
|
|
yaml += "collections:\n";
|
|
|
|
for (const slug of Object.keys(policyMap).sort()) {
|
|
const entry = policyMap[slug];
|
|
yaml += ` ${yamlStr(slug)}:\n`;
|
|
|
|
if (entry.activeRetention) {
|
|
yaml += renderRetentionBlock(
|
|
"activeRetention",
|
|
entry.activeRetention,
|
|
" ",
|
|
);
|
|
}
|
|
if (entry.coldArchive) {
|
|
yaml += renderRetentionBlock("coldArchive", entry.coldArchive, " ");
|
|
}
|
|
if (entry.postDeletion) {
|
|
yaml += renderRetentionBlock("postDeletion", entry.postDeletion, " ");
|
|
}
|
|
|
|
yaml += ` purgeSchedule: ${yamlStr(entry.purgeSchedule)}\n`;
|
|
yaml += ` slug: ${yamlStr(slug)}\n`;
|
|
}
|
|
|
|
return yaml;
|
|
}
|
|
|
|
// ---- CLI ----
|
|
|
|
function parseArgs(argv) {
|
|
const out = { mode: "write" }; // 'write' | 'print' | 'check'
|
|
for (let i = 2; i < argv.length; i++) {
|
|
if (argv[i] === "--print") out.mode = "print";
|
|
else if (argv[i] === "--check") out.mode = "check";
|
|
else if (argv[i] === "--help" || argv[i] === "-h") {
|
|
console.log(
|
|
[
|
|
"Usage: node scripts/compliance/emit-retention-policy.mjs [--print | --check]",
|
|
" (default): write compliance/retention-policy.yml",
|
|
" --print: write YAML to stdout",
|
|
" --check: diff vs committed file; exit 1 on mismatch",
|
|
].join("\n"),
|
|
);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv);
|
|
const repoRoot = process.cwd();
|
|
|
|
const files = findCollectionFiles(repoRoot);
|
|
const rawCollections = files
|
|
.map((f) => parseCollectionFile(f))
|
|
.filter(Boolean);
|
|
|
|
const errors = validateRetentionPolicy(rawCollections);
|
|
if (errors.length > 0) {
|
|
for (const err of errors) {
|
|
process.stderr.write(`[compliance:retention-policy] ${err}\n`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
const policyMap = buildRetentionPolicy(rawCollections);
|
|
const yaml = renderRetentionPolicyYaml(policyMap);
|
|
|
|
const outPath = path.resolve(repoRoot, OUTPUT_PATH);
|
|
|
|
if (args.mode === "print") {
|
|
process.stdout.write(yaml);
|
|
return;
|
|
}
|
|
|
|
if (args.mode === "check") {
|
|
if (!fs.existsSync(outPath)) {
|
|
process.stderr.write(
|
|
`[compliance:retention-policy] --check: no committed file at ${OUTPUT_PATH}\n` +
|
|
`Run \`pnpm compliance:retention-policy\` to generate it first.\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const committed = fs.readFileSync(outPath, "utf8");
|
|
const diff = unifiedDiff(committed, yaml, OUTPUT_PATH);
|
|
if (diff === null) {
|
|
console.log(
|
|
`✓ compliance:retention-policy — ${OUTPUT_PATH} is up to date`,
|
|
);
|
|
process.exit(0);
|
|
}
|
|
process.stderr.write(
|
|
`✗ compliance:retention-policy — ${OUTPUT_PATH} is out of date:\n${diff}\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Default: write to file
|
|
const dir = path.dirname(outPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
fs.writeFileSync(outPath, yaml, "utf8");
|
|
console.log(`✓ compliance:retention-policy — wrote ${OUTPUT_PATH}`);
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main();
|
|
}
|