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 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 19:52:01 +00:00
parent ab44b2f16d
commit 844b9ee324
3 changed files with 521 additions and 0 deletions

View File

@@ -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<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();
}

View File

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