From 844b9ee324e06c2f44919d37cdbe88b38515e389 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 18 May 2026 19:52:01 +0000 Subject: [PATCH] feat(scripts): add emit-retention-policy compliance script + tests 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 --- package.json | 1 + scripts/compliance/emit-retention-policy.mjs | 228 ++++++++++++++ .../compliance/emit-retention-policy.test.mjs | 292 ++++++++++++++++++ 3 files changed, 521 insertions(+) create mode 100644 scripts/compliance/emit-retention-policy.mjs create mode 100644 scripts/compliance/emit-retention-policy.test.mjs diff --git a/package.json b/package.json index 323ca01..9241da8 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "fallow": "fallow", "fallow:audit": "fallow audit --base main", "compliance:data-map": "node scripts/compliance/emit-data-map.mjs", + "compliance:retention-policy": "node scripts/compliance/emit-retention-policy.mjs", "work": "node scripts/work/cli.mjs", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", diff --git a/scripts/compliance/emit-retention-policy.mjs b/scripts/compliance/emit-retention-policy.mjs new file mode 100644 index 0000000..02a63a3 --- /dev/null +++ b/scripts/compliance/emit-retention-policy.mjs @@ -0,0 +1,228 @@ +#!/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 + */ +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(); +} diff --git a/scripts/compliance/emit-retention-policy.test.mjs b/scripts/compliance/emit-retention-policy.test.mjs new file mode 100644 index 0000000..e09aa0a --- /dev/null +++ b/scripts/compliance/emit-retention-policy.test.mjs @@ -0,0 +1,292 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + findCollectionFiles, + parseCollectionFile, + validateRetentionPolicy, + buildRetentionPolicy, + renderRetentionPolicyYaml, + unifiedDiff, + OUTPUT_PATH, +} from "./emit-retention-policy.mjs"; + +// ---- Fixtures ---- + +const USERS_TS = ` +import type { CollectionConfig } from "payload"; +export const users: CollectionConfig = { + slug: "users", + auth: true, + custom: { + retention: { + purgeSchedule: "daily", + postDeletion: { duration: "P30D", trigger: "after-deletion", action: "hard-delete" }, + }, + }, + fields: [{ name: "displayName", type: "text" }], +}; +`; + +const ARTICLES_TS = ` +import type { CollectionConfig } from "payload"; +export const articles: CollectionConfig = { + slug: "articles", + custom: { + retention: { + purgeSchedule: "monthly", + postDeletion: { duration: "P90D", trigger: "after-deletion", action: "hard-delete" }, + }, + }, + fields: [{ name: "title", type: "text" }], +}; +`; + +const ALL_FIELDS_TS = ` +import type { CollectionConfig } from "payload"; +export const full: CollectionConfig = { + slug: "full", + custom: { + retention: { + purgeSchedule: "weekly", + activeRetention: { duration: "P1Y", trigger: "from-last-access" }, + postDeletion: { duration: "P30D", trigger: "after-deletion", action: "pseudonymize" }, + coldArchive: { duration: "P2Y", trigger: "from-creation" }, + }, + }, + fields: [], +}; +`; + +const MISSING_PURGE_TS = ` +import type { CollectionConfig } from "payload"; +export const orphan: CollectionConfig = { + slug: "orphan", + custom: { retention: { postDeletion: { duration: "P30D", trigger: "after-deletion", action: "hard-delete" } } }, + fields: [], +}; +`; + +const NO_RETENTION_TS = ` +import type { CollectionConfig } from "payload"; +export const bare: CollectionConfig = { + slug: "bare", + fields: [], +}; +`; + +// ---- Test helpers ---- + +function makeRepo(collections) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-retention-policy-")); + for (const [pkgSlug, src] of Object.entries(collections)) { + const dir = path.join( + root, + "packages", + pkgSlug, + "src", + "integrations", + "cms", + "collections", + ); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${pkgSlug}.ts`), src, "utf8"); + } + return root; +} + +function parseFixtures(fixtures) { + const root = makeRepo(fixtures); + const files = findCollectionFiles(root); + return files.map((f) => parseCollectionFile(f)).filter(Boolean); +} + +// ---- Tests ---- + +describe("validateRetentionPolicy — required fields", () => { + test("returns no errors when all collections have purgeSchedule", () => { + const raw = parseFixtures({ auth: USERS_TS, blog: ARTICLES_TS }); + assert.deepEqual(validateRetentionPolicy(raw), []); + }); + + test("returns an error for a collection missing purgeSchedule", () => { + const raw = parseFixtures({ orphan: MISSING_PURGE_TS }); + const errors = validateRetentionPolicy(raw); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes(`"orphan"`)); + assert.ok(errors[0].includes("purgeSchedule")); + assert.ok(errors[0].includes("Hint:")); + }); + + test("returns an error for a collection with no custom.retention at all", () => { + const raw = parseFixtures({ bare: NO_RETENTION_TS }); + const errors = validateRetentionPolicy(raw); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes(`"bare"`)); + }); + + test("returns multiple errors when multiple collections are missing purgeSchedule", () => { + const raw = [ + { slug: "alpha", custom: {} }, + { slug: "beta", custom: { retention: {} } }, + ]; + const errors = validateRetentionPolicy(raw); + assert.equal(errors.length, 2); + assert.ok(errors.some((e) => e.includes('"alpha"'))); + assert.ok(errors.some((e) => e.includes('"beta"'))); + }); + + test("returns no errors for empty collections array", () => { + assert.deepEqual(validateRetentionPolicy([]), []); + }); +}); + +describe("buildRetentionPolicy", () => { + test("builds a map with slug and purgeSchedule for each collection", () => { + const raw = parseFixtures({ auth: USERS_TS, blog: ARTICLES_TS }); + const map = buildRetentionPolicy(raw); + assert.ok("users" in map); + assert.ok("articles" in map); + assert.equal(map.users.purgeSchedule, "daily"); + assert.equal(map.articles.purgeSchedule, "monthly"); + assert.equal(map.users.slug, "users"); + }); + + test("includes postDeletion when present", () => { + const raw = parseFixtures({ auth: USERS_TS }); + const map = buildRetentionPolicy(raw); + assert.ok(map.users.postDeletion); + assert.equal(map.users.postDeletion.duration, "P30D"); + assert.equal(map.users.postDeletion.trigger, "after-deletion"); + assert.equal(map.users.postDeletion.action, "hard-delete"); + }); + + test("includes all optional fields (activeRetention, postDeletion, coldArchive)", () => { + const raw = parseFixtures({ full: ALL_FIELDS_TS }); + const map = buildRetentionPolicy(raw); + assert.ok(map.full.activeRetention); + assert.equal(map.full.activeRetention.duration, "P1Y"); + assert.equal(map.full.activeRetention.trigger, "from-last-access"); + assert.ok(map.full.coldArchive); + assert.equal(map.full.coldArchive.duration, "P2Y"); + assert.equal(map.full.coldArchive.trigger, "from-creation"); + assert.ok(map.full.postDeletion); + assert.equal(map.full.postDeletion.action, "pseudonymize"); + }); + + test("omits absent optional fields from entry", () => { + const raw = [ + { slug: "simple", custom: { retention: { purgeSchedule: "weekly" } } }, + ]; + const map = buildRetentionPolicy(raw); + assert.ok(!("activeRetention" in map.simple)); + assert.ok(!("coldArchive" in map.simple)); + assert.ok(!("postDeletion" in map.simple)); + }); + + test("returns empty map for no collections", () => { + assert.deepEqual(buildRetentionPolicy([]), {}); + }); +}); + +describe("renderRetentionPolicyYaml", () => { + test("renders collections in alphabetical order", () => { + const map = { + users: { slug: "users", purgeSchedule: "daily" }, + articles: { slug: "articles", purgeSchedule: "monthly" }, + }; + const yaml = renderRetentionPolicyYaml(map); + const articlesIdx = yaml.indexOf(" articles:"); + const usersIdx = yaml.indexOf(" users:"); + assert.ok(articlesIdx < usersIdx, "articles should appear before users"); + }); + + test("renders purgeSchedule and slug for every collection", () => { + const map = { blog: { slug: "blog", purgeSchedule: "monthly" } }; + const yaml = renderRetentionPolicyYaml(map); + assert.ok(yaml.includes("purgeSchedule: monthly")); + assert.ok(yaml.includes("slug: blog")); + }); + + test("renders postDeletion sub-object with sorted keys", () => { + const map = { + users: { + slug: "users", + purgeSchedule: "daily", + postDeletion: { + action: "hard-delete", + duration: "P30D", + trigger: "after-deletion", + }, + }, + }; + const yaml = renderRetentionPolicyYaml(map); + assert.ok(yaml.includes("postDeletion:")); + assert.ok(yaml.includes("action: hard-delete")); + assert.ok(yaml.includes("duration: P30D")); + assert.ok(yaml.includes("trigger: after-deletion")); + // action (a) comes before duration (d) comes before trigger (t) + const actionIdx = yaml.indexOf("action:"); + const durationIdx = yaml.indexOf("duration:"); + const triggerIdx = yaml.indexOf("trigger:"); + assert.ok(actionIdx < durationIdx && durationIdx < triggerIdx); + }); + + test("renders activeRetention and coldArchive when present", () => { + const raw = parseFixtures({ full: ALL_FIELDS_TS }); + const map = buildRetentionPolicy(raw); + const yaml = renderRetentionPolicyYaml(map); + assert.ok(yaml.includes("activeRetention:")); + assert.ok(yaml.includes("coldArchive:")); + }); + + test("rendered YAML includes header comment", () => { + const yaml = renderRetentionPolicyYaml({}); + assert.ok(yaml.startsWith("# compliance/retention-policy.yml")); + assert.ok(yaml.includes("emit-retention-policy.mjs")); + }); + + test("output is deterministic across multiple calls", () => { + const raw = parseFixtures({ auth: USERS_TS, blog: ARTICLES_TS }); + const map = buildRetentionPolicy(raw); + assert.equal( + renderRetentionPolicyYaml(map), + renderRetentionPolicyYaml(map), + ); + }); + + test("OUTPUT_PATH is compliance/retention-policy.yml", () => { + assert.equal(OUTPUT_PATH, "compliance/retention-policy.yml"); + }); +}); + +describe("--check mode (integration)", () => { + test("passes when committed file matches generated output", () => { + const raw = parseFixtures({ auth: USERS_TS }); + const map = buildRetentionPolicy(raw); + const yaml = renderRetentionPolicyYaml(map); + const diff = unifiedDiff(yaml, yaml, OUTPUT_PATH); + assert.equal(diff, null, "no diff expected when files match"); + }); + + test("fails with readable diff when committed file is stale", () => { + const raw = parseFixtures({ auth: USERS_TS }); + const map = buildRetentionPolicy(raw); + const yaml = renderRetentionPolicyYaml(map); + const stale = "# stale content\ncollections: {}\n"; + const diff = unifiedDiff(stale, yaml, OUTPUT_PATH); + assert.ok(diff !== null, "diff expected when file is stale"); + assert.ok( + diff.includes(`--- ${OUTPUT_PATH}`), + "diff should include the filename header", + ); + assert.ok( + diff.includes("- # stale content"), + "diff should show removed line", + ); + assert.ok(diff.includes("Line"), "diff should include line numbers"); + }); +});