chore(template): clean-slate template snapshot from bb4a0c7

Curated, product-agnostic snapshot of the post-story-04 tree: demo
content deleted, auth-only reference feature, web-next shell, all gates
green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor
library traces, and product naming are curated out; generic template
repairs (coverage provider devDeps, root test:coverage script, live
lint fixes, root-only release-please) are kept. See TEMPLATE.md for
provenance, curation list, and usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 20:40:54 +02:00
commit f77e6ea881
1062 changed files with 105156 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* emit-all.mjs — Compliance artifact orchestrator.
*
* Default: regenerates all three compliance artifacts (write mode).
* --check: diffs each artifact against the committed file; exits non-zero
* if any generator reports a mismatch or validation failure.
*
* Usage:
* node scripts/compliance/emit-all.mjs # regenerate all artifacts
* node scripts/compliance/emit-all.mjs --check # drift check (CI gate)
* pnpm compliance:emit-all
* pnpm compliance:emit-all --check
*/
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCRIPTS = [
"emit-data-map.mjs",
"emit-retention-policy.mjs",
"emit-sub-processors.mjs",
];
const checkMode = process.argv.includes("--check");
const subArgs = checkMode ? ["--check"] : [];
let anyFailed = false;
for (const script of SCRIPTS) {
const scriptPath = path.join(__dirname, script);
const result = spawnSync(process.execPath, [scriptPath, ...subArgs], {
stdio: "inherit",
});
if (result.status !== 0) {
anyFailed = true;
}
}
if (anyFailed) {
if (checkMode) {
process.stderr.write(
"\n✗ compliance:emit-all — one or more artifacts are out of date.\n" +
" Run `pnpm compliance:emit-all` to regenerate.\n",
);
} else {
process.stderr.write(
"\n✗ compliance:emit-all — one or more artifacts failed to generate.\n",
);
}
process.exit(1);
}
if (checkMode) {
console.log("✓ compliance:emit-all — all artifacts are up to date");
} else {
console.log("✓ compliance:emit-all — all artifacts regenerated");
}

View File

@@ -0,0 +1,412 @@
#!/usr/bin/env node
/**
* emit-data-map.mjs — PII field inventory emitter.
*
* Walks packages/*\/src/integrations/cms/collections/*.ts, applies
* PAYLOAD_AUTH_PII_DEFAULTS + custom.authPii overrides, and emits a
* deterministic YAML PII inventory at compliance/data-map.yml.
*
* Usage:
* node scripts/compliance/emit-data-map.mjs # write compliance/data-map.yml
* node scripts/compliance/emit-data-map.mjs --print # write to stdout
* node scripts/compliance/emit-data-map.mjs --check # diff vs committed file; exit 1 on mismatch
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "@typescript-eslint/parser";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const REPO_ROOT = path.resolve(__dirname, "../..");
export const OUTPUT_PATH = "compliance/data-map.yml";
// ---- Constants ----
/**
* Default PII classification for Payload auth fields.
* Null means the field is NOT PII and must be excluded from the data map.
* Mirrors packages/core-shared/src/payload/pii-types.ts PAYLOAD_AUTH_PII_DEFAULTS.
*/
export const PAYLOAD_AUTH_PII_DEFAULTS = {
email: {
category: "contact-email",
purpose: ["account-authentication", "transactional-notifications"],
exportable: true,
restrictable: true,
},
password: null,
salt: null,
hash: null,
resetPasswordToken: null,
resetPasswordExpiration: null,
loginAttempts: null,
lockUntil: null,
apiKey: null,
apiKeyIndex: null,
};
// ---- AST parsing helpers ----
/**
* Recursively extract a plain JS value from an AST node.
* Returns undefined for unresolvable nodes (identifier references, etc.).
*/
function extractValue(node) {
if (!node) return null;
if (node.type === "Literal") return node.value;
if (node.type === "TSAsExpression") return extractValue(node.expression);
if (node.type === "Identifier") {
if (node.name === "true") return true;
if (node.name === "false") return false;
if (node.name === "null") return null;
return undefined;
}
if (node.type === "ObjectExpression") {
const obj = {};
for (const prop of node.properties) {
if (prop.type !== "Property") continue;
const key =
prop.key.type === "Identifier"
? prop.key.name
: prop.key.type === "Literal"
? String(prop.key.value)
: null;
if (!key) continue;
const v = extractValue(prop.value);
if (v !== undefined) obj[key] = v;
}
return obj;
}
if (node.type === "ArrayExpression") {
return node.elements
.map((e) => (e ? extractValue(e) : null))
.filter((e) => e !== undefined);
}
return undefined;
}
/**
* Parse a Payload collection TypeScript file.
* Returns the first exported variable whose value is an object with a string
* `slug` field, or null on parse failure / missing file.
*/
export function parseCollectionFile(filePath) {
let src;
try {
src = fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
let ast;
try {
ast = parse(src, {
sourceType: "module",
ecmaVersion: "latest",
loc: false,
range: false,
});
} catch {
return null;
}
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue;
if (node.declaration.type !== "VariableDeclaration") continue;
for (const decl of node.declaration.declarations) {
if (!decl.init) continue;
const value = extractValue(decl.init);
if (
value &&
typeof value === "object" &&
typeof value.slug === "string"
) {
return value;
}
}
}
return null;
}
// ---- Collection file discovery ----
/**
* Find all Payload collection TypeScript files across packages.
*/
export function findCollectionFiles(repoRoot = REPO_ROOT) {
const packagesDir = path.join(repoRoot, "packages");
if (!fs.existsSync(packagesDir)) return [];
const files = [];
for (const pkg of fs.readdirSync(packagesDir).sort()) {
const collectionsDir = path.join(
packagesDir,
pkg,
"src",
"integrations",
"cms",
"collections",
);
if (!fs.existsSync(collectionsDir)) continue;
for (const file of fs.readdirSync(collectionsDir).sort()) {
if (file.endsWith(".ts") && !file.endsWith(".test.ts")) {
files.push(path.join(collectionsDir, file));
}
}
}
return files;
}
// ---- Data map builder ----
/** Build a single PII entry. */
function makePiiEntry(fieldName, pii, source) {
return {
field: fieldName,
source,
category: pii.category,
purpose: Array.isArray(pii.purpose) ? [...pii.purpose] : [],
exportable: pii.exportable ?? false,
restrictable: pii.restrictable ?? false,
...(pii.retention ? { retention: { ...pii.retention } } : {}),
};
}
/** Collect field-level PII entries from a collection's fields array. */
function collectFieldPiiEntries(fields) {
const entries = [];
if (!Array.isArray(fields)) return entries;
for (const field of fields) {
if (!field || typeof field.name !== "string") continue;
const pii = field.custom && field.custom.pii;
if (!pii || typeof pii !== "object") continue;
entries.push(makePiiEntry(field.name, pii, "field-tag"));
}
return entries;
}
/** Collect auth PII entries by merging defaults with per-collection overrides. */
function collectAuthPiiEntries(authPiiDefaults, authPiiOverrides) {
const merged = { ...authPiiDefaults, ...authPiiOverrides };
const entries = [];
for (const [fieldName, pii] of Object.entries(merged)) {
if (pii === null) continue;
const source =
fieldName in authPiiOverrides ? "auth-override" : "auth-default";
entries.push(makePiiEntry(fieldName, pii, source));
}
return entries;
}
/**
* Walk parsed collection configs and build the PII data map.
* Applies authPiiDefaults (PAYLOAD_AUTH_PII_DEFAULTS) and custom.authPii
* overrides for auth-enabled collections.
*
* Returns: Record<slug, { auth, piiFields, slug }>
*/
export function buildDataMap(
rawCollections,
authPiiDefaults = PAYLOAD_AUTH_PII_DEFAULTS,
) {
const map = {};
for (const coll of rawCollections) {
if (!coll || typeof coll.slug !== "string") continue;
const isAuth = coll.auth === true;
const piiFields = [
...collectFieldPiiEntries(coll.fields),
...(isAuth
? collectAuthPiiEntries(
authPiiDefaults,
(coll.custom && coll.custom.authPii) || {},
)
: []),
];
piiFields.sort((a, b) => a.field.localeCompare(b.field));
map[coll.slug] = { auth: isAuth, piiFields, slug: coll.slug };
}
return map;
}
// ---- YAML serialization ----
const YAML_HEADER = [
"# compliance/data-map.yml — PII field inventory",
"# Generated by scripts/compliance/emit-data-map.mjs — do not edit manually.",
"# Run `pnpm compliance:data-map` 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;
}
function renderPiiField(f) {
let out = "";
out += ` - category: ${yamlStr(f.category)}\n`;
out += ` exportable: ${f.exportable}\n`;
out += ` field: ${yamlStr(f.field)}\n`;
if (!f.purpose || f.purpose.length === 0) {
out += ` purpose: []\n`;
} else {
out += ` purpose:\n`;
for (const p of f.purpose) {
out += ` - ${yamlStr(p)}\n`;
}
}
out += ` restrictable: ${f.restrictable}\n`;
if (f.retention) {
out += ` retention:\n`;
out += ` action: ${yamlStr(f.retention.action)}\n`;
out += ` duration: ${yamlStr(f.retention.duration)}\n`;
out += ` trigger: ${yamlStr(f.retention.trigger)}\n`;
}
out += ` source: ${yamlStr(f.source)}\n`;
return out;
}
/**
* Render the data map as deterministic YAML.
* Collections sorted by slug; piiFields sorted by field name (done in buildDataMap).
*/
export function renderDataMapYaml(dataMap) {
let yaml = YAML_HEADER + "\n";
yaml += "collections:\n";
for (const slug of Object.keys(dataMap).sort()) {
const coll = dataMap[slug];
yaml += ` ${yamlStr(slug)}:\n`;
yaml += ` auth: ${coll.auth}\n`;
if (!coll.piiFields || coll.piiFields.length === 0) {
yaml += ` piiFields: []\n`;
} else {
yaml += ` piiFields:\n`;
for (const f of coll.piiFields) {
yaml += renderPiiField(f);
}
}
yaml += ` slug: ${yamlStr(slug)}\n`;
}
return yaml;
}
// ---- Diff helper for --check mode ----
/**
* Produce a readable line-diff between expected (committed) and actual (generated).
* Returns null if equal.
*/
export function unifiedDiff(expected, actual, filename) {
const expLines = expected.split("\n");
const actLines = actual.split("\n");
const maxLen = Math.max(expLines.length, actLines.length);
const hunks = [];
for (let i = 0; i < maxLen; i++) {
const expLine = expLines[i] ?? "";
const actLine = actLines[i] ?? "";
if (expLine !== actLine) {
hunks.push(`Line ${i + 1}:`);
hunks.push(` - ${expLine}`);
hunks.push(` + ${actLine}`);
}
}
if (hunks.length === 0) return null;
return (
`--- ${filename} (committed)\n` +
`+++ ${filename} (generated)\n` +
hunks.join("\n")
);
}
// ---- 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-data-map.mjs [--print | --check]",
" (default): write compliance/data-map.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 dataMap = buildDataMap(rawCollections);
const yaml = renderDataMapYaml(dataMap);
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:data-map] --check: no committed file at ${OUTPUT_PATH}\n` +
`Run \`pnpm compliance:data-map\` 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:data-map — ${OUTPUT_PATH} is up to date`);
process.exit(0);
}
process.stderr.write(
`✗ compliance:data-map — ${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:data-map — wrote ${OUTPUT_PATH}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@@ -0,0 +1,354 @@
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,
buildDataMap,
renderDataMapYaml,
unifiedDiff,
} from "./emit-data-map.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",
custom: {
pii: {
category: "identification-username",
purpose: ["service-delivery"],
exportable: true,
restrictable: true,
},
},
},
{ name: "role", type: "select" },
],
};
`;
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" },
{ name: "content", type: "richText" },
],
};
`;
const AUTH_PII_OVERRIDE_TS = `
import type { CollectionConfig } from "payload";
export const members: CollectionConfig = {
slug: "members",
auth: true,
custom: {
retention: { purgeSchedule: "daily" },
authPii: {
email: {
category: "contact-email",
purpose: ["account-authentication", "marketing-communications"],
exportable: false,
restrictable: true,
},
password: null,
},
},
fields: [],
};
`;
// ---- Test helpers ----
function makeRepo(collections) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-data-map-"));
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 buildMapFromFixtures(fixtures) {
const root = makeRepo(fixtures);
const files = findCollectionFiles(root);
const raw = files.map((f) => parseCollectionFile(f)).filter(Boolean);
return buildDataMap(raw);
}
// ---- Tests ----
describe("findCollectionFiles", () => {
test("returns all .ts files in packages/*/integrations/cms/collections/", () => {
const root = makeRepo({ blog: ARTICLES_TS, auth: USERS_TS });
const files = findCollectionFiles(root);
assert.equal(files.length, 2);
assert.ok(files.every((f) => f.endsWith(".ts")));
assert.ok(files.some((f) => f.includes("auth")));
assert.ok(files.some((f) => f.includes("blog")));
});
test("returns empty array when packages/ does not exist", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-data-map-empty-"));
assert.deepEqual(findCollectionFiles(root), []);
});
test("excludes .test.ts files", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-data-map-test-"));
const dir = path.join(
root,
"packages",
"blog",
"src",
"integrations",
"cms",
"collections",
);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "articles.ts"), ARTICLES_TS, "utf8");
fs.writeFileSync(path.join(dir, "articles.test.ts"), "// test", "utf8");
const files = findCollectionFiles(root);
assert.equal(files.length, 1);
assert.ok(!files[0].endsWith(".test.ts"));
});
});
describe("parseCollectionFile", () => {
test("extracts slug, auth, fields, and custom from a collection file", () => {
const root = makeRepo({ auth: USERS_TS });
const [file] = findCollectionFiles(root);
const coll = parseCollectionFile(file);
assert.equal(coll.slug, "users");
assert.equal(coll.auth, true);
assert.ok(Array.isArray(coll.fields));
assert.equal(coll.fields.length, 2);
assert.equal(coll.fields[0].name, "displayName");
assert.equal(coll.fields[0].custom.pii.category, "identification-username");
});
test("returns null for an unreadable path", () => {
assert.equal(parseCollectionFile("/nonexistent/path.ts"), null);
});
test("extracts a collection without auth or PII", () => {
const root = makeRepo({ blog: ARTICLES_TS });
const [file] = findCollectionFiles(root);
const coll = parseCollectionFile(file);
assert.equal(coll.slug, "articles");
assert.equal(coll.auth, undefined);
assert.equal(coll.fields.length, 2);
assert.ok(!coll.fields[0].custom);
});
});
describe("buildDataMap — empty collections", () => {
test("returns empty map for no collections", () => {
assert.deepEqual(buildDataMap([]), {});
});
test("includes collection with no PII fields as piiFields: []", () => {
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
assert.ok("articles" in map);
assert.deepEqual(map.articles.piiFields, []);
assert.equal(map.articles.auth, false);
});
});
describe("buildDataMap — auth defaults applied", () => {
test("applies PAYLOAD_AUTH_PII_DEFAULTS for auth-enabled collections", () => {
const map = buildMapFromFixtures({ auth: USERS_TS });
const emailField = map.users.piiFields.find((f) => f.field === "email");
assert.ok(emailField, "email field should be present from auth defaults");
assert.equal(emailField.category, "contact-email");
assert.equal(emailField.source, "auth-default");
assert.deepEqual(emailField.purpose, [
"account-authentication",
"transactional-notifications",
]);
});
test("excludes null-valued auth defaults (e.g. password, salt)", () => {
const map = buildMapFromFixtures({ auth: USERS_TS });
const nullFields = ["password", "salt", "hash", "resetPasswordToken"];
for (const name of nullFields) {
const found = map.users.piiFields.find((f) => f.field === name);
assert.equal(
found,
undefined,
`${name} should be excluded (null default)`,
);
}
});
test("does NOT apply auth defaults for non-auth collections", () => {
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
assert.deepEqual(map.articles.piiFields, []);
});
test("includes field-level PII tags alongside auth defaults", () => {
const map = buildMapFromFixtures({ auth: USERS_TS });
const displayName = map.users.piiFields.find(
(f) => f.field === "displayName",
);
const email = map.users.piiFields.find((f) => f.field === "email");
assert.ok(displayName, "displayName should come from field-tag");
assert.equal(displayName.source, "field-tag");
assert.ok(email, "email should come from auth-default");
assert.equal(email.source, "auth-default");
});
test("piiFields are sorted by field name", () => {
const map = buildMapFromFixtures({ auth: USERS_TS });
const names = map.users.piiFields.map((f) => f.field);
assert.deepEqual(names, [...names].sort());
});
});
describe("buildDataMap — authPii override applied", () => {
test("custom.authPii overrides PAYLOAD_AUTH_PII_DEFAULTS", () => {
const map = buildMapFromFixtures({ members: AUTH_PII_OVERRIDE_TS });
const emailField = map.members.piiFields.find((f) => f.field === "email");
assert.ok(emailField, "email should be present");
assert.equal(emailField.source, "auth-override");
assert.equal(emailField.exportable, false);
assert.ok(
emailField.purpose.includes("marketing-communications"),
"overridden purpose should be used",
);
});
test("authPii null overrides exclude the field", () => {
const map = buildMapFromFixtures({ members: AUTH_PII_OVERRIDE_TS });
const pwField = map.members.piiFields.find((f) => f.field === "password");
assert.equal(
pwField,
undefined,
"password null override should be excluded",
);
});
});
describe("renderDataMapYaml", () => {
test("renders collections in alphabetical order", () => {
const map = {
users: { auth: true, piiFields: [], slug: "users" },
articles: { auth: false, piiFields: [], slug: "articles" },
};
const yaml = renderDataMapYaml(map);
const articlesIdx = yaml.indexOf(" articles:");
const usersIdx = yaml.indexOf(" users:");
assert.ok(articlesIdx < usersIdx, "articles should come before users");
});
test("renders piiFields: [] for empty fields", () => {
const map = { articles: { auth: false, piiFields: [], slug: "articles" } };
const yaml = renderDataMapYaml(map);
assert.ok(yaml.includes("piiFields: []"));
});
test("renders PII field with all required keys", () => {
const map = {
users: {
auth: true,
slug: "users",
piiFields: [
{
field: "email",
source: "auth-default",
category: "contact-email",
purpose: ["account-authentication"],
exportable: true,
restrictable: true,
},
],
},
};
const yaml = renderDataMapYaml(map);
assert.ok(yaml.includes("field: email"));
assert.ok(yaml.includes("category: contact-email"));
assert.ok(yaml.includes("source: auth-default"));
assert.ok(yaml.includes("exportable: true"));
assert.ok(yaml.includes("restrictable: true"));
assert.ok(yaml.includes("- account-authentication"));
});
test("rendered YAML includes header comment", () => {
const yaml = renderDataMapYaml({});
assert.ok(yaml.startsWith("# compliance/data-map.yml"));
});
test("output is deterministic across multiple calls", () => {
const map = buildMapFromFixtures({ auth: USERS_TS, blog: ARTICLES_TS });
assert.equal(renderDataMapYaml(map), renderDataMapYaml(map));
});
});
describe("unifiedDiff", () => {
test("returns null when strings are equal", () => {
assert.equal(unifiedDiff("a\nb\n", "a\nb\n", "file.yml"), null);
});
test("returns a readable diff when strings differ", () => {
const diff = unifiedDiff("line1\nold\n", "line1\nnew\n", "file.yml");
assert.ok(diff !== null);
assert.ok(diff.includes("--- file.yml"));
assert.ok(diff.includes("+++ file.yml"));
assert.ok(diff.includes("- old"));
assert.ok(diff.includes("+ new"));
});
test("diff includes line numbers", () => {
const diff = unifiedDiff("a\n", "b\n", "x.yml");
assert.ok(diff.includes("Line 1:"));
});
test("handles different lengths (extra lines)", () => {
const diff = unifiedDiff("a\nb\n", "a\nb\nc\n", "x.yml");
assert.ok(diff !== null);
assert.ok(diff.includes("+ c"));
});
});
describe("--check mode (integration)", () => {
test("passes when committed file matches generated output", () => {
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
const yaml = renderDataMapYaml(map);
const diff = unifiedDiff(yaml, yaml, "compliance/data-map.yml");
assert.equal(diff, null, "no diff expected when file matches");
});
test("fails when committed file does not match generated output", () => {
const map = buildMapFromFixtures({ blog: ARTICLES_TS });
const yaml = renderDataMapYaml(map);
const stale = "# stale content\ncollections: {}\n";
const diff = unifiedDiff(stale, yaml, "compliance/data-map.yml");
assert.ok(diff !== null, "diff expected when file is stale");
assert.ok(diff.includes("- # stale content"));
});
});

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

View File

@@ -0,0 +1,339 @@
#!/usr/bin/env node
/**
* emit-sub-processors.mjs — Third-party sub-processor inventory emitter.
*
* Walks docs/library-decisions/*.md, filters entries where is-sub-processor: true,
* merges compliance/sub-processors.manual.yml (if present) with source: manual flag,
* and emits a sorted deterministic YAML inventory at compliance/sub-processors.yml.
*
* Usage:
* node scripts/compliance/emit-sub-processors.mjs # write compliance/sub-processors.yml
* node scripts/compliance/emit-sub-processors.mjs --print # write to stdout
* node scripts/compliance/emit-sub-processors.mjs --check # diff vs committed file; exit 1 on mismatch
*/
import fs from "node:fs";
import path from "node:path";
import { unifiedDiff, REPO_ROOT } from "./emit-data-map.mjs";
export { unifiedDiff, REPO_ROOT };
export const OUTPUT_PATH = "compliance/sub-processors.yml";
export const MANUAL_PATH = "compliance/sub-processors.manual.yml";
// ---- Frontmatter parser ----
/** Parse a YAML scalar value string into its JS equivalent. */
function parseScalarValue(raw) {
if (raw === "true") return true;
if (raw === "false") return false;
if (raw === "null") return null;
if (
(raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'"))
) {
return raw.slice(1, -1);
}
return raw;
}
/**
* Parse top-level scalar fields from YAML frontmatter in a markdown file.
* Returns an object of key → value pairs, or null if no frontmatter is found.
* Skips comment lines, empty lines, and indented lines (nested block fields).
*/
export function parseFrontmatter(src) {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(src);
if (!match) return null;
const result = {};
for (const line of match[1].split("\n")) {
if (!line.trim() || line.startsWith("#")) continue;
if (line.startsWith(" ") || line.startsWith("\t")) continue;
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
const rawValue = line.slice(colonIdx + 1).trim();
if (!rawValue) continue; // block header like "filter-results:"
result[key] = parseScalarValue(rawValue);
}
return result;
}
// ---- Library decision file discovery ----
/**
* Find all library decision markdown files under docs/library-decisions/.
* Excludes _template.md and any file starting with `_`.
*/
export function findLibraryDecisionFiles(repoRoot = REPO_ROOT) {
const dir = path.join(repoRoot, "docs", "library-decisions");
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.sort()
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
.map((f) => path.join(dir, f));
}
// ---- Sub-processor parsing from library traces ----
const TRACE_FIELDS = [
"package",
"version",
"decision",
"data-sent",
"region",
"dpa-signed",
"sccs-required",
"contact",
];
/**
* Walk library decision files and return sub-processor entries where
* is-sub-processor: true. Each entry gets source: "library-trace" injected.
*/
export function parseLibraryTraceSubProcessors(repoRoot = REPO_ROOT) {
const files = findLibraryDecisionFiles(repoRoot);
const entries = [];
for (const filePath of files) {
let src;
try {
src = fs.readFileSync(filePath, "utf8");
} catch {
continue;
}
const meta = parseFrontmatter(src);
if (!meta || meta["is-sub-processor"] !== true) continue;
const entry = { source: "library-trace" };
for (const field of TRACE_FIELDS) {
if (meta[field] !== undefined) entry[field] = meta[field];
}
entries.push(entry);
}
return entries;
}
// ---- Manual entries ----
/**
* Parse a simple YAML list-of-objects: each item starts with "- key: value"
* and continuation lines are indented with spaces or tabs.
* Returns an array of plain objects.
*/
export function parseSimpleYamlList(src) {
const entries = [];
let current = null;
for (const line of src.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (line.startsWith("- ")) {
if (current) entries.push(current);
current = {};
const rest = line.slice(2).trim();
const colonIdx = rest.indexOf(":");
if (colonIdx !== -1) {
const key = rest.slice(0, colonIdx).trim();
const rawVal = rest.slice(colonIdx + 1).trim();
if (rawVal) current[key] = parseScalarValue(rawVal);
}
} else if (current && (line.startsWith(" ") || line.startsWith("\t"))) {
const colonIdx = line.indexOf(":");
if (colonIdx !== -1) {
const key = line.slice(0, colonIdx).trim();
const rawVal = line.slice(colonIdx + 1).trim();
if (rawVal) current[key] = parseScalarValue(rawVal);
}
}
}
if (current) entries.push(current);
return entries;
}
/**
* Load manual sub-processor entries from compliance/sub-processors.manual.yml.
* Returns empty array if the file doesn't exist (graceful skip).
* Injects source: "manual" into each entry.
*/
export function loadManualEntries(repoRoot = REPO_ROOT) {
const manualPath = path.resolve(repoRoot, MANUAL_PATH);
if (!fs.existsSync(manualPath)) return [];
let src;
try {
src = fs.readFileSync(manualPath, "utf8");
} catch {
return [];
}
return parseSimpleYamlList(src).map((entry) => ({
...entry,
source: "manual",
}));
}
// ---- Builder ----
/**
* Merge library-trace and manual sub-processor entries.
* Sorts by package name for deterministic output.
*/
export function buildSubProcessors(traced, manual) {
const all = [...traced, ...manual];
all.sort((a, b) =>
String(a.package ?? "").localeCompare(String(b.package ?? "")),
);
return all;
}
// ---- YAML serialization ----
const YAML_HEADER = [
"# compliance/sub-processors.yml — Third-party sub-processor inventory",
"# Generated by scripts/compliance/emit-sub-processors.mjs — do not edit manually.",
"# Run `pnpm compliance:sub-processors` 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;
}
// package first (natural identifier), then remaining fields alphabetically
const ENTRY_FIELDS = [
"package",
"contact",
"data-sent",
"decision",
"dpa-signed",
"region",
"sccs-required",
"source",
"version",
];
/**
* Render sub-processors as deterministic YAML.
* Entries sorted by package name (done in buildSubProcessors).
* Fields rendered in a fixed order: package first, rest alphabetical.
*/
export function renderSubProcessorsYaml(entries) {
let yaml = YAML_HEADER + "\n";
yaml += "sub-processors:\n";
if (entries.length === 0) {
yaml += " []\n";
return yaml;
}
for (const entry of entries) {
let first = true;
for (const field of ENTRY_FIELDS) {
const value = entry[field];
if (value === undefined) continue;
const rendered =
typeof value === "boolean" ? String(value) : yamlStr(String(value));
if (first) {
yaml += ` - ${field}: ${rendered}\n`;
first = false;
} else {
yaml += ` ${field}: ${rendered}\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-sub-processors.mjs [--print | --check]",
" (default): write compliance/sub-processors.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 traced = parseLibraryTraceSubProcessors(repoRoot);
const manual = loadManualEntries(repoRoot);
const entries = buildSubProcessors(traced, manual);
const yaml = renderSubProcessorsYaml(entries);
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:sub-processors] --check: no committed file at ${OUTPUT_PATH}\n` +
`Run \`pnpm compliance:sub-processors\` 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:sub-processors — ${OUTPUT_PATH} is up to date`);
process.exit(0);
}
process.stderr.write(
`✗ compliance:sub-processors — ${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:sub-processors — wrote ${OUTPUT_PATH}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@@ -0,0 +1,512 @@
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 {
parseFrontmatter,
findLibraryDecisionFiles,
parseLibraryTraceSubProcessors,
parseSimpleYamlList,
loadManualEntries,
buildSubProcessors,
renderSubProcessorsYaml,
unifiedDiff,
OUTPUT_PATH,
MANUAL_PATH,
} from "./emit-sub-processors.mjs";
// ---- Fixtures ----
const SUB_PROCESSOR_MD = `---
package: stripe
version: "^14.0.0"
tier: feature
decision: approved
date: 2026-05-18
deciders: [Danijel Martinek]
adr: null
lastRevalidated: null
is-sub-processor: true
processes-pii: true
data-sent: payment card details and billing address
region: eu-west-1
dpa-signed: true
sccs-required: false
contact: https://stripe.com/privacy
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: ok
cve-scan: clean
named-consumer: pass
socketRisk: clean
verification-commands:
- npm view stripe license
accepted-cves: []
---
## Filter: license
MIT.
`;
const NON_SUB_PROCESSOR_MD = `---
package: zod
version: "^3.0.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [Danijel Martinek]
adr: null
lastRevalidated: null
is-sub-processor: false
processes-pii: false
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
socketRisk: clean
verification-commands:
- npm view zod license
accepted-cves: []
---
`;
const ANOTHER_SUB_PROCESSOR_MD = `---
package: sendgrid
version: "^7.0.0"
tier: feature
decision: approved
date: 2026-05-18
deciders: [Danijel Martinek]
adr: null
lastRevalidated: null
is-sub-processor: true
processes-pii: true
data-sent: email address and name for transactional emails
region: eu
dpa-signed: false
sccs-required: true
contact: https://sendgrid.com/privacy
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: ok
cve-scan: clean
named-consumer: pass
socketRisk: clean
verification-commands:
- npm view @sendgrid/mail license
accepted-cves: []
---
`;
const MANUAL_YAML = `- package: aws-s3
data-sent: file uploads and user avatars
region: eu-west-1
dpa-signed: true
sccs-required: false
contact: https://aws.amazon.com/compliance/eu-data-privacy/
decision: approved
version: "^3.0.0"
`;
// ---- Test helpers ----
function makeLibraryDecisions(files) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "emit-sub-processors-"));
const dir = path.join(root, "docs", "library-decisions");
fs.mkdirSync(dir, { recursive: true });
for (const [name, src] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), src, "utf8");
}
return root;
}
function makeRepoWithManual(libraryFiles, manualYaml) {
const root = makeLibraryDecisions(libraryFiles);
if (manualYaml !== undefined) {
const complianceDir = path.join(root, "compliance");
fs.mkdirSync(complianceDir, { recursive: true });
fs.writeFileSync(path.join(root, MANUAL_PATH), manualYaml, "utf8");
}
return root;
}
// ---- Tests ----
describe("parseFrontmatter", () => {
test("parses top-level scalar fields", () => {
const meta = parseFrontmatter(SUB_PROCESSOR_MD);
assert.equal(meta.package, "stripe");
assert.equal(meta["is-sub-processor"], true);
assert.equal(meta["dpa-signed"], true);
assert.equal(meta["sccs-required"], false);
assert.equal(meta.decision, "approved");
assert.equal(meta.contact, "https://stripe.com/privacy");
});
test("parses false boolean correctly (is-sub-processor: false)", () => {
const meta = parseFrontmatter(NON_SUB_PROCESSOR_MD);
assert.equal(meta["is-sub-processor"], false);
assert.equal(meta["processes-pii"], false);
});
test("strips quotes from version strings", () => {
const meta = parseFrontmatter(SUB_PROCESSOR_MD);
assert.equal(meta.version, "^14.0.0");
});
test("skips nested block fields (filter-results)", () => {
const meta = parseFrontmatter(SUB_PROCESSOR_MD);
assert.ok(
!("license" in meta),
"should not include nested filter-results.license",
);
assert.ok(
!("filter-results" in meta),
"filter-results block header should be skipped",
);
});
test("returns null when no frontmatter is present", () => {
assert.equal(parseFrontmatter("no frontmatter here"), null);
});
test("returns null for empty string", () => {
assert.equal(parseFrontmatter(""), null);
});
});
describe("findLibraryDecisionFiles", () => {
test("finds all .md files and excludes _template.md", () => {
const root = makeLibraryDecisions({
"2026-05-14-stripe.md": SUB_PROCESSOR_MD,
"2026-05-14-zod.md": NON_SUB_PROCESSOR_MD,
"_template.md": "# template",
});
const files = findLibraryDecisionFiles(root);
assert.equal(files.length, 2);
assert.ok(files.every((f) => !path.basename(f).startsWith("_")));
});
test("returns files in sorted order", () => {
const root = makeLibraryDecisions({
"2026-05-18-stripe.md": SUB_PROCESSOR_MD,
"2026-05-14-zod.md": NON_SUB_PROCESSOR_MD,
});
const files = findLibraryDecisionFiles(root);
assert.ok(
path.basename(files[0]) < path.basename(files[1]),
"files should be sorted alphabetically",
);
});
test("returns empty array when docs/library-decisions does not exist", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "empty-repo-"));
assert.deepEqual(findLibraryDecisionFiles(root), []);
});
});
describe("parseLibraryTraceSubProcessors — discriminated union", () => {
test("returns only entries where is-sub-processor: true", () => {
const root = makeLibraryDecisions({
"2026-05-14-stripe.md": SUB_PROCESSOR_MD,
"2026-05-14-zod.md": NON_SUB_PROCESSOR_MD,
});
const entries = parseLibraryTraceSubProcessors(root);
assert.equal(entries.length, 1);
assert.equal(entries[0].package, "stripe");
});
test("sets source to library-trace for parsed entries", () => {
const root = makeLibraryDecisions({
"2026-05-14-stripe.md": SUB_PROCESSOR_MD,
});
const [entry] = parseLibraryTraceSubProcessors(root);
assert.equal(entry.source, "library-trace");
});
test("extracts all sub-processor fields from frontmatter", () => {
const root = makeLibraryDecisions({
"2026-05-14-stripe.md": SUB_PROCESSOR_MD,
});
const [entry] = parseLibraryTraceSubProcessors(root);
assert.equal(entry.package, "stripe");
assert.equal(entry.version, "^14.0.0");
assert.equal(entry.decision, "approved");
assert.equal(
entry["data-sent"],
"payment card details and billing address",
);
assert.equal(entry.region, "eu-west-1");
assert.equal(entry["dpa-signed"], true);
assert.equal(entry["sccs-required"], false);
assert.equal(entry.contact, "https://stripe.com/privacy");
});
test("returns empty array when no sub-processors exist", () => {
const root = makeLibraryDecisions({
"2026-05-14-zod.md": NON_SUB_PROCESSOR_MD,
});
assert.deepEqual(parseLibraryTraceSubProcessors(root), []);
});
test("returns empty array when library-decisions dir is absent", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "no-decisions-"));
assert.deepEqual(parseLibraryTraceSubProcessors(root), []);
});
test("handles multiple sub-processor files", () => {
const root = makeLibraryDecisions({
"2026-05-14-stripe.md": SUB_PROCESSOR_MD,
"2026-05-14-sendgrid.md": ANOTHER_SUB_PROCESSOR_MD,
"2026-05-14-zod.md": NON_SUB_PROCESSOR_MD,
});
const entries = parseLibraryTraceSubProcessors(root);
assert.equal(entries.length, 2);
const packages = entries.map((e) => e.package).sort();
assert.deepEqual(packages, ["sendgrid", "stripe"]);
});
});
describe("parseSimpleYamlList", () => {
test("parses a simple YAML list into an array of objects", () => {
const entries = parseSimpleYamlList(MANUAL_YAML);
assert.equal(entries.length, 1);
assert.equal(entries[0].package, "aws-s3");
assert.equal(entries[0]["dpa-signed"], true);
assert.equal(entries[0]["sccs-required"], false);
assert.equal(entries[0].region, "eu-west-1");
});
test("strips quotes from quoted values", () => {
const entries = parseSimpleYamlList(MANUAL_YAML);
assert.equal(entries[0].version, "^3.0.0");
});
test("parses multiple list items", () => {
const src = `- package: alpha
region: eu
- package: beta
region: us
`;
const entries = parseSimpleYamlList(src);
assert.equal(entries.length, 2);
assert.equal(entries[0].package, "alpha");
assert.equal(entries[1].package, "beta");
});
test("returns empty array for empty input", () => {
assert.deepEqual(parseSimpleYamlList(""), []);
});
test("returns empty array for comment-only input", () => {
assert.deepEqual(
parseSimpleYamlList("# just a comment\n# another line"),
[],
);
});
});
describe("loadManualEntries", () => {
test("returns empty array when manual file is absent (graceful skip)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "no-manual-"));
assert.deepEqual(loadManualEntries(root), []);
});
test("loads manual entries and injects source: manual", () => {
const root = makeRepoWithManual({}, MANUAL_YAML);
const entries = loadManualEntries(root);
assert.equal(entries.length, 1);
assert.equal(entries[0].package, "aws-s3");
assert.equal(entries[0].source, "manual");
});
test("preserves all fields from manual file", () => {
const root = makeRepoWithManual({}, MANUAL_YAML);
const [entry] = loadManualEntries(root);
assert.equal(entry["data-sent"], "file uploads and user avatars");
assert.equal(entry["dpa-signed"], true);
assert.equal(entry["sccs-required"], false);
assert.equal(entry.decision, "approved");
});
});
describe("buildSubProcessors — merge and sort", () => {
test("merges traced and manual entries sorted by package name", () => {
const traced = [{ package: "stripe", source: "library-trace" }];
const manual = [{ package: "aws-s3", source: "manual" }];
const merged = buildSubProcessors(traced, manual);
assert.equal(merged.length, 2);
assert.equal(merged[0].package, "aws-s3");
assert.equal(merged[1].package, "stripe");
});
test("preserves source field for each entry type", () => {
const traced = [{ package: "stripe", source: "library-trace" }];
const manual = [{ package: "aws-s3", source: "manual" }];
const merged = buildSubProcessors(traced, manual);
assert.equal(merged[0].source, "manual");
assert.equal(merged[1].source, "library-trace");
});
test("returns empty array when both inputs are empty", () => {
assert.deepEqual(buildSubProcessors([], []), []);
});
test("returns traced-only when no manual entries", () => {
const traced = [{ package: "stripe", source: "library-trace" }];
const merged = buildSubProcessors(traced, []);
assert.equal(merged.length, 1);
assert.equal(merged[0].source, "library-trace");
});
test("returns manual-only when no traced entries", () => {
const manual = [{ package: "aws-s3", source: "manual" }];
const merged = buildSubProcessors([], manual);
assert.equal(merged.length, 1);
assert.equal(merged[0].source, "manual");
});
test("sorts multiple entries alphabetically by package", () => {
const traced = [
{ package: "zod", source: "library-trace" },
{ package: "stripe", source: "library-trace" },
];
const merged = buildSubProcessors(traced, []);
assert.equal(merged[0].package, "stripe");
assert.equal(merged[1].package, "zod");
});
});
describe("renderSubProcessorsYaml", () => {
test("renders YAML header comment", () => {
const yaml = renderSubProcessorsYaml([]);
assert.ok(yaml.startsWith("# compliance/sub-processors.yml"));
assert.ok(yaml.includes("emit-sub-processors.mjs"));
});
test("renders empty list as sub-processors: []", () => {
const yaml = renderSubProcessorsYaml([]);
assert.ok(yaml.includes("sub-processors:"));
assert.ok(yaml.includes(" []"));
});
test("renders entries in package-name order", () => {
const entries = [
{ package: "aws-s3", source: "manual", decision: "approved" },
{ package: "stripe", source: "library-trace", decision: "approved" },
];
const yaml = renderSubProcessorsYaml(entries);
const awsIdx = yaml.indexOf("package: aws-s3");
const stripeIdx = yaml.indexOf("package: stripe");
assert.ok(awsIdx < stripeIdx, "aws-s3 should appear before stripe");
});
test("renders all defined fields per entry", () => {
const entries = [
{
package: "stripe",
version: "^14.0.0",
decision: "approved",
"data-sent": "payment card details",
region: "eu-west-1",
"dpa-signed": true,
"sccs-required": false,
contact: "https://stripe.com/privacy",
source: "library-trace",
},
];
const yaml = renderSubProcessorsYaml(entries);
assert.ok(yaml.includes("package: stripe"));
assert.ok(yaml.includes("version: ^14.0.0"));
assert.ok(yaml.includes("decision: approved"));
assert.ok(yaml.includes("data-sent: payment card details"));
assert.ok(yaml.includes("region: eu-west-1"));
assert.ok(yaml.includes("dpa-signed: true"));
assert.ok(yaml.includes("sccs-required: false"));
assert.ok(yaml.includes("contact:"));
assert.ok(yaml.includes("source: library-trace"));
});
test("omits undefined fields from entry", () => {
const entries = [{ package: "stripe", source: "library-trace" }];
const yaml = renderSubProcessorsYaml(entries);
assert.ok(
!yaml.includes("data-sent:"),
"should not render missing data-sent",
);
assert.ok(!yaml.includes("region:"), "should not render missing region");
assert.ok(
!yaml.includes("dpa-signed:"),
"should not render missing dpa-signed",
);
});
test("output is deterministic across multiple calls", () => {
const entries = [
{ package: "stripe", source: "library-trace", decision: "approved" },
];
assert.equal(
renderSubProcessorsYaml(entries),
renderSubProcessorsYaml(entries),
);
});
test("package field is the first field in each list item (starts with '- package:')", () => {
const entries = [{ package: "stripe", source: "library-trace" }];
const yaml = renderSubProcessorsYaml(entries);
assert.ok(
yaml.includes(" - package: stripe"),
"entry should start with ' - package:'",
);
});
test("OUTPUT_PATH is compliance/sub-processors.yml", () => {
assert.equal(OUTPUT_PATH, "compliance/sub-processors.yml");
});
});
describe("--check mode (integration)", () => {
test("passes when committed file matches generated output", () => {
const entries = [
{ package: "stripe", source: "library-trace", decision: "approved" },
];
const yaml = renderSubProcessorsYaml(entries);
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 entries = [
{ package: "stripe", source: "library-trace", decision: "approved" },
];
const yaml = renderSubProcessorsYaml(entries);
const stale = "# stale content\nsub-processors: []\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 filename header",
);
assert.ok(
diff.includes("- # stale content"),
"diff should show removed line",
);
assert.ok(diff.includes("Line"), "diff should include line numbers");
});
});

115
scripts/conformance.mjs Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env node
/**
* pnpm conformance — cross-feature drift gate.
*
* Walks every `packages/*\/src/feature.manifest.ts`, reuses the AST parser
* from `@repo/core-eslint` to extract per-use-case publishes/consumes,
* builds global publish + consume sets across all features, and fails on:
*
* - Orphan consumer: a feature declares `consumes: ["X"]` but no
* feature publishes "X".
*
* Exits 0 on success, 1 on any violation. Prints a tabular summary of
* the event graph for transparency.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseManifestUseCases } from "../packages/core-eslint/rules/_manifest-ast.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..");
export function findAllManifests(repoRoot = REPO_ROOT) {
const packagesDir = path.join(repoRoot, "packages");
if (!fs.existsSync(packagesDir)) return [];
const out = [];
for (const entry of fs.readdirSync(packagesDir)) {
const manifestPath = path.join(
packagesDir,
entry,
"src",
"feature.manifest.ts",
);
if (fs.existsSync(manifestPath)) {
out.push({ feature: entry, path: manifestPath });
}
}
return out;
}
export function buildEventGraph(manifests) {
const graph = new Map();
for (const { feature, path: manifestPath } of manifests) {
const useCases = parseManifestUseCases(manifestPath);
if (!useCases) continue;
for (const [useCase, entry] of Object.entries(useCases)) {
for (const event of entry.publishes) {
if (!graph.has(event))
graph.set(event, { publishers: [], consumers: [] });
graph.get(event).publishers.push({ feature, useCase });
}
for (const event of entry.consumes) {
if (!graph.has(event))
graph.set(event, { publishers: [], consumers: [] });
graph.get(event).consumers.push({ feature, useCase });
}
}
}
return graph;
}
export function findOrphanConsumers(graph) {
const orphans = [];
for (const [event, { publishers, consumers }] of graph.entries()) {
if (consumers.length > 0 && publishers.length === 0) {
orphans.push({ event, consumers });
}
}
return orphans;
}
function main() {
const manifests = findAllManifests();
console.log(`Found ${manifests.length} feature manifest(s):`);
for (const { feature } of manifests) console.log(` - ${feature}`);
console.log();
const graph = buildEventGraph(manifests);
if (graph.size === 0) {
console.log("No cross-feature events declared yet — nothing to check.");
process.exit(0);
}
console.log(`Event graph (${graph.size} event(s)):`);
for (const [event, { publishers, consumers }] of graph.entries()) {
console.log(` ${event}`);
console.log(
` publishers: ${publishers.length === 0 ? "(none)" : publishers.map((p) => `${p.feature}.${p.useCase}`).join(", ")}`,
);
console.log(
` consumers: ${consumers.length === 0 ? "(none)" : consumers.map((c) => `${c.feature}.${c.useCase}`).join(", ")}`,
);
}
console.log();
const orphans = findOrphanConsumers(graph);
if (orphans.length === 0) {
console.log("✓ pnpm conformance — passed");
process.exit(0);
}
console.error(`✗ pnpm conformance — ${orphans.length} orphan consumer(s):`);
for (const { event, consumers } of orphans) {
console.error(` ${event}`);
for (const c of consumers) {
console.error(
` consumed by ${c.feature}.${c.useCase}, but no feature publishes it`,
);
}
}
process.exit(1);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import {
findAllManifests,
buildEventGraph,
findOrphanConsumers,
} from "./conformance.mjs";
function makeRepo(features) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "conformance-"));
for (const [name, useCases] of Object.entries(features)) {
const dir = path.join(root, "packages", name, "src");
fs.mkdirSync(dir, { recursive: true });
const useCasesStr = Object.entries(useCases)
.map(
([ucName, uc]) =>
` ${ucName}: { mutates: ${uc.mutates ?? false}, audits: [], publishes: [${(uc.publishes ?? []).map((p) => `"${p}"`).join(", ")}], consumes: [${(uc.consumes ?? []).map((c) => `"${c}"`).join(", ")}] },`,
)
.join("\n");
fs.writeFileSync(
path.join(dir, "feature.manifest.ts"),
`export const ${name}Manifest = defineFeature({
name: "${name}",
requiredCores: [],
useCases: {
${useCasesStr}
},
realtimeChannels: [],
jobs: [],
} as const);`,
);
}
return root;
}
describe("conformance script", () => {
describe("findAllManifests", () => {
it("returns one entry per feature with a manifest", () => {
const root = makeRepo({
auth: { signIn: {} },
blog: { getArticles: {} },
});
const ms = findAllManifests(root);
expect(ms.map((m) => m.feature).sort()).toEqual(["auth", "blog"]);
});
it("skips packages without a manifest", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "conformance-empty-"));
fs.mkdirSync(path.join(root, "packages", "no-manifest", "src"), {
recursive: true,
});
expect(findAllManifests(root)).toEqual([]);
});
});
describe("buildEventGraph + findOrphanConsumers", () => {
it("finds zero orphans when consumers and publishers line up", () => {
const root = makeRepo({
auth: { signUp: { mutates: true, publishes: ["auth.signed-up"] } },
marketing: { onAuthSignedUp: { consumes: ["auth.signed-up"] } },
});
const manifests = findAllManifests(root);
const graph = buildEventGraph(manifests);
expect(findOrphanConsumers(graph)).toEqual([]);
});
it("flags orphan consumers", () => {
const root = makeRepo({
marketing: { onAuthSignedUp: { consumes: ["auth.signed-up"] } },
});
const manifests = findAllManifests(root);
const graph = buildEventGraph(manifests);
const orphans = findOrphanConsumers(graph);
expect(orphans).toHaveLength(1);
expect(orphans[0].event).toBe("auth.signed-up");
expect(orphans[0].consumers).toEqual([
{ feature: "marketing", useCase: "onAuthSignedUp" },
]);
});
it("treats publish-only events as fine (no consumers is not an orphan)", () => {
const root = makeRepo({
auth: { signUp: { mutates: true, publishes: ["auth.signed-up"] } },
});
const manifests = findAllManifests(root);
const graph = buildEventGraph(manifests);
expect(findOrphanConsumers(graph)).toEqual([]);
});
});
});

View File

@@ -0,0 +1,22 @@
TN:
SF:src/foo.ts
DA:1,5
DA:2,5
DA:3,0
LF:3
LH:2
BRF:2
BRH:1
FNF:1
FNH:1
end_of_record
SF:src/bar.ts
DA:1,10
DA:2,10
LF:2
LH:2
BRF:0
BRH:0
FNF:1
FNH:1
end_of_record

View File

@@ -0,0 +1,13 @@
TN:
SF:src/baz.ts
DA:1,3
DA:2,3
DA:3,3
DA:4,0
LF:4
LH:3
BRF:2
BRH:2
FNF:2
FNH:1
end_of_record

View File

@@ -0,0 +1,49 @@
diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.ts b/packages/auth/src/application/use-cases/sign-in.use-case.ts
index abc..def 100644
--- a/packages/auth/src/application/use-cases/sign-in.use-case.ts
+++ b/packages/auth/src/application/use-cases/sign-in.use-case.ts
@@ -1,0 +2,2 @@
+const a = 1;
+const b = 2;
@@ -4,1 +5,2 @@
-old
+const c = 3;
+const d = 4;
diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts
index abc..def 100644
--- a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts
+++ b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts
@@ -10,0 +11 @@
+new test line
diff --git a/packages/auth/src/entities/models/user.ts b/packages/auth/src/entities/models/user.ts
index abc..def 100644
--- a/packages/auth/src/entities/models/user.ts
+++ b/packages/auth/src/entities/models/user.ts
@@ -2,1 +2,1 @@
-old
+modified
diff --git a/packages/blog/src/application/use-cases/get-article.use-case.ts b/packages/blog/src/application/use-cases/get-article.use-case.ts
index abc..def 100644
--- a/packages/blog/src/application/use-cases/get-article.use-case.ts
+++ b/packages/blog/src/application/use-cases/get-article.use-case.ts
@@ -11,0 +12 @@
+const uncovered = true;
diff --git a/packages/media/src/application/use-cases/upload.use-case.ts b/packages/media/src/application/use-cases/upload.use-case.ts
new file mode 100644
index 0000000..abc
--- /dev/null
+++ b/packages/media/src/application/use-cases/upload.use-case.ts
@@ -0,0 +1,5 @@
+export const uploadUseCase = () => {
+ return "uploaded";
+};
+// Line 4
+// Line 5
diff --git a/CLAUDE.md b/CLAUDE.md
index abc..def 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,1 +1,2 @@
-old text
+new text
+more text

View File

@@ -0,0 +1,25 @@
TN:
SF:/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts
DA:1,5
DA:2,5
DA:3,5
DA:5,0
DA:6,0
DA:8,3
LF:6
LH:4
end_of_record
SF:/repo/packages/auth/src/entities/models/user.ts
DA:1,1
DA:2,1
DA:3,1
LF:3
LH:3
end_of_record
SF:/repo/packages/blog/src/application/use-cases/get-article.use-case.ts
DA:10,2
DA:11,2
DA:12,0
LF:3
LH:2
end_of_record

View File

@@ -0,0 +1,211 @@
#!/usr/bin/env node
// scripts/coverage/aggregate.mjs — L2 of the coverage architecture (ADR-020).
//
// Discovers every per-package lcov (`packages/*/coverage/lcov.info`,
// `apps/*/coverage/lcov.info`), normalizes their paths to repo-relative,
// merges into `coverage/lcov.info` at the repo root, and emits
// `coverage/summary.json` — the committed trend store.
//
// Output:
// - coverage/lcov.info (gitignored — large)
// - coverage/summary.json (committed — trend via `git log -- ...`)
// - stdout: short status line
// Exit: 0 on success, 1 if no lcov files found.
//
// Usage:
// pnpm coverage:aggregate # default discovery + emit
// pnpm coverage:aggregate -- --json # print summary to stdout
//
// Implementation: zero deps. Pure Node ESM.
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
/**
* Find every per-package / per-app lcov.info file under packages/* and apps/*.
* Returns absolute paths.
*/
export function discoverLcovs(repoRoot) {
const results = [];
for (const root of ["packages", "apps"]) {
const dir = path.join(repoRoot, root);
if (!fs.existsSync(dir)) continue;
for (const pkg of fs.readdirSync(dir)) {
const lcov = path.join(dir, pkg, "coverage", "lcov.info");
if (fs.existsSync(lcov)) {
results.push({
packageDir: path.join(root, pkg), // repo-relative
lcov,
});
}
}
}
return results.sort((a, b) => a.packageDir.localeCompare(b.packageDir));
}
/**
* Normalize lcov text so every SF line is repo-relative. Vitest emits paths
* relative to the package's vitest.config.ts (e.g. `src/foo.ts`), so we
* prepend `packages/<pkg>/` (or `apps/<pkg>/`) to each SF.
*/
export function normalizeLcov(text, packageDir) {
return text
.split("\n")
.map((line) => {
if (!line.startsWith("SF:")) return line;
const p = line.slice(3);
// If already absolute or already prefixed with packages/apps, leave alone
if (path.isAbsolute(p) || p.startsWith(packageDir + "/")) return line;
return `SF:${packageDir}/${p}`;
})
.join("\n");
}
/**
* Compute lcov-level summary stats from a parsed lcov map.
* Returns { statements, branches, functions, lines } as percentages
* (statements ≈ lines in V8's lcov output).
*
* Algorithm: walk all SF blocks (each record has LF/LH for line totals,
* BRF/BRH for branches, FNF/FNH for functions). Sum across files; divide.
*/
export function summarizeLcov(lcovText) {
let lf = 0,
lh = 0,
brf = 0,
brh = 0,
fnf = 0,
fnh = 0;
for (const line of lcovText.split("\n")) {
if (line.startsWith("LF:")) lf += Number(line.slice(3));
else if (line.startsWith("LH:")) lh += Number(line.slice(3));
else if (line.startsWith("BRF:")) brf += Number(line.slice(4));
else if (line.startsWith("BRH:")) brh += Number(line.slice(4));
else if (line.startsWith("FNF:")) fnf += Number(line.slice(4));
else if (line.startsWith("FNH:")) fnh += Number(line.slice(4));
}
const pct = (hit, found) =>
found === 0 ? 100 : Math.round((hit / found) * 10000) / 100;
return {
statements: pct(lh, lf), // V8 lcov: statements ≈ lines
branches: pct(brh, brf),
functions: pct(fnh, fnf),
lines: pct(lh, lf),
counts: { lf, lh, brf, brh, fnf, fnh },
};
}
/**
* Aggregate the discovered lcovs. Returns:
* {
* mergedLcov: string,
* summary: { generatedAt, commit, repo: {...}, byPackage: { ... } }
* }
*/
export function aggregate(repoRoot, opts = {}) {
const lcovs = opts.lcovs ?? discoverLcovs(repoRoot);
if (lcovs.length === 0) {
return { mergedLcov: "", summary: null, lcovs: [] };
}
const merged = [];
const byPackage = {};
for (const { packageDir, lcov } of lcovs) {
const text = fs.readFileSync(lcov, "utf8");
const normalized = normalizeLcov(text, packageDir);
merged.push(normalized);
// The package's @repo/<name> identifier comes from its package.json
const pkgJsonPath = path.join(repoRoot, packageDir, "package.json");
let pkgName = packageDir;
if (fs.existsSync(pkgJsonPath)) {
try {
pkgName =
JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")).name ?? packageDir;
} catch {
// fall through
}
}
byPackage[pkgName] = summarizeLcov(normalized);
}
const mergedLcov = merged.join("\n");
const repo = summarizeLcov(mergedLcov);
let commit = "unknown";
try {
commit = execSync("git rev-parse --short HEAD", {
cwd: repoRoot,
encoding: "utf8",
}).trim();
} catch {
// not in a git repo, leave as "unknown"
}
return {
mergedLcov,
lcovs,
summary: {
generatedAt: opts.now ?? new Date().toISOString(),
commit,
repo,
byPackage,
},
};
}
// ---- CLI ----
function parseArgs(argv) {
const out = { json: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--json") out.json = true;
else if (a === "--help" || a === "-h") {
console.log("Usage: pnpm coverage:aggregate [-- --json]");
process.exit(0);
}
}
return out;
}
function main() {
const args = parseArgs(process.argv);
const repoRoot = process.cwd();
const { mergedLcov, summary, lcovs } = aggregate(repoRoot);
if (lcovs.length === 0) {
process.stderr.write(
`[coverage:aggregate] No per-package lcov.info files found.\n` +
`Run \`pnpm test:coverage\` first.\n`,
);
process.exit(1);
}
const outDir = path.join(repoRoot, "coverage");
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, "lcov.info"), mergedLcov);
fs.writeFileSync(
path.join(outDir, "summary.json"),
JSON.stringify(summary, null, 2) + "\n",
);
if (args.json) {
process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
} else {
process.stdout.write(
`[coverage:aggregate] Merged ${lcovs.length} lcov(s); ` +
`repo coverage: statements ${summary.repo.statements}%, ` +
`branches ${summary.repo.branches}%, ` +
`functions ${summary.repo.functions}%, ` +
`lines ${summary.repo.lines}%\n` +
`Wrote coverage/lcov.info + coverage/summary.json\n`,
);
}
}
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
if (invokedDirectly) {
main();
}

View File

@@ -0,0 +1,174 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import {
discoverLcovs,
normalizeLcov,
summarizeLcov,
aggregate,
} from "./aggregate.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.join(__dirname, "__fixtures__");
const pkgA = fs.readFileSync(
path.join(FIXTURES, "aggregate-pkg-a.lcov"),
"utf8",
);
const pkgB = fs.readFileSync(
path.join(FIXTURES, "aggregate-pkg-b.lcov"),
"utf8",
);
describe("normalizeLcov", () => {
test("prefixes packageDir onto each SF line", () => {
const result = normalizeLcov(pkgA, "packages/auth");
assert.ok(result.includes("SF:packages/auth/src/foo.ts"));
assert.ok(result.includes("SF:packages/auth/src/bar.ts"));
assert.ok(!result.includes("SF:src/foo.ts\n")); // no unprefixed paths remain
});
test("leaves absolute paths untouched", () => {
const text = "SF:/absolute/path/foo.ts\nDA:1,5\nend_of_record";
const result = normalizeLcov(text, "packages/auth");
assert.ok(result.includes("SF:/absolute/path/foo.ts"));
});
test("doesn't double-prefix when already prefixed", () => {
const text = "SF:packages/auth/src/foo.ts\nDA:1,5\nend_of_record";
const result = normalizeLcov(text, "packages/auth");
assert.ok(result.includes("SF:packages/auth/src/foo.ts"));
assert.ok(!result.includes("SF:packages/auth/packages/auth/"));
});
test("preserves non-SF lines verbatim", () => {
const result = normalizeLcov(pkgA, "packages/auth");
assert.ok(result.includes("DA:1,5"));
assert.ok(result.includes("LH:2"));
assert.ok(result.includes("end_of_record"));
});
});
describe("summarizeLcov", () => {
test("computes percentages from LF/LH/BRF/BRH/FNF/FNH summary records", () => {
const summary = summarizeLcov(pkgA);
// LF=3+2=5, LH=2+2=4 -> 80% statements/lines
assert.equal(summary.statements, 80);
assert.equal(summary.lines, 80);
// BRF=2+0=2, BRH=1+0=1 -> 50% branches
assert.equal(summary.branches, 50);
// FNF=1+1=2, FNH=1+1=2 -> 100% functions
assert.equal(summary.functions, 100);
});
test("treats zero-found as 100% (avoids division by zero)", () => {
const text =
"SF:src/x.ts\nLF:0\nLH:0\nBRF:0\nBRH:0\nFNF:0\nFNH:0\nend_of_record";
const summary = summarizeLcov(text);
assert.equal(summary.statements, 100);
assert.equal(summary.branches, 100);
assert.equal(summary.functions, 100);
});
test("rounds percentages to 2 decimals", () => {
// LF=3, LH=2 -> 66.67%
const text = "SF:x\nLF:3\nLH:2\nend_of_record";
const summary = summarizeLcov(text);
assert.equal(summary.statements, 66.67);
});
});
describe("aggregate", () => {
test("returns null summary when no lcovs are found", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cov-agg-empty-"));
try {
const result = aggregate(tmpRoot);
assert.equal(result.summary, null);
assert.equal(result.lcovs.length, 0);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test("merges multiple lcovs and emits per-package summary", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cov-agg-merge-"));
try {
// Create packages/pkg-a + packages/pkg-b with their lcovs
fs.mkdirSync(path.join(tmpRoot, "packages", "pkg-a", "coverage"), {
recursive: true,
});
fs.mkdirSync(path.join(tmpRoot, "packages", "pkg-b", "coverage"), {
recursive: true,
});
fs.writeFileSync(
path.join(tmpRoot, "packages", "pkg-a", "coverage", "lcov.info"),
pkgA,
);
fs.writeFileSync(
path.join(tmpRoot, "packages", "pkg-b", "coverage", "lcov.info"),
pkgB,
);
// Synthetic package.json for name resolution
fs.writeFileSync(
path.join(tmpRoot, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "@repo/pkg-a" }),
);
fs.writeFileSync(
path.join(tmpRoot, "packages", "pkg-b", "package.json"),
JSON.stringify({ name: "@repo/pkg-b" }),
);
const result = aggregate(tmpRoot, { now: "2026-05-13T00:00:00Z" });
assert.equal(result.lcovs.length, 2);
assert.ok(result.mergedLcov.includes("SF:packages/pkg-a/src/foo.ts"));
assert.ok(result.mergedLcov.includes("SF:packages/pkg-b/src/baz.ts"));
// Per-package summaries
assert.ok(result.summary.byPackage["@repo/pkg-a"]);
assert.ok(result.summary.byPackage["@repo/pkg-b"]);
assert.equal(result.summary.byPackage["@repo/pkg-a"].statements, 80);
assert.equal(result.summary.byPackage["@repo/pkg-b"].statements, 75);
// Repo-level summary: lines hit 4+3=7 of 5+4=9 -> 77.78%
assert.equal(result.summary.repo.statements, 77.78);
assert.equal(result.summary.generatedAt, "2026-05-13T00:00:00Z");
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
});
describe("discoverLcovs", () => {
test("finds lcovs under packages/* and apps/*", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cov-discover-"));
try {
fs.mkdirSync(path.join(tmpRoot, "packages", "p1", "coverage"), {
recursive: true,
});
fs.mkdirSync(path.join(tmpRoot, "apps", "a1", "coverage"), {
recursive: true,
});
// p2 has no coverage dir
fs.mkdirSync(path.join(tmpRoot, "packages", "p2"), { recursive: true });
fs.writeFileSync(
path.join(tmpRoot, "packages", "p1", "coverage", "lcov.info"),
"",
);
fs.writeFileSync(
path.join(tmpRoot, "apps", "a1", "coverage", "lcov.info"),
"",
);
const found = discoverLcovs(tmpRoot);
assert.equal(found.length, 2);
const dirs = found.map((f) => f.packageDir).sort();
assert.deepEqual(dirs, ["apps/a1", "packages/p1"]);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
});

340
scripts/coverage/diff.mjs Normal file
View File

@@ -0,0 +1,340 @@
#!/usr/bin/env node
// scripts/coverage/diff.mjs — L1 of the coverage architecture (ADR-020).
//
// Reads the merged lcov (`coverage/lcov.info`) and the working tree's git
// diff against a base ref, then asserts cover-the-diff: every changed
// *executable* line must have execution count > 0.
//
// Output:
// - stdout: JSON `{ status, summary, uncovered: [{ file, line, kind }] }`
// (machine-readable for the dispatch loop)
// - stderr: human summary
// Exit: 0 on pass, 1 on fail.
//
// Usage:
// pnpm coverage:diff # default base: origin/main
// pnpm coverage:diff -- --base HEAD~1 # override base ref
// pnpm coverage:diff -- --lcov path/to.info # override lcov path
// pnpm coverage:diff -- --json # JSON only (no stderr)
//
// Implementation: zero deps. Pure Node ESM + child_process + fs.
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
/**
* Files that don't need diff-coverage gating. Test files, configs, docs,
* lockfiles, generated artifacts. Also covers the per-feature exclude
* patterns documented in vitest configs (DI bootstrap, interfaces, CMS
* collections, factories, contracts, UI).
*/
const ALLOWED_GLOBS = [
// Test artifacts
/\.test\.(ts|tsx|js|mjs)$/,
// Storybook story files — excluded from vitest by design; tested in Storybook runner
/\.stories\.(ts|tsx)$/,
/\/__factories__\//,
/\/__contracts__\//,
/\/__fixtures__\//,
/\/__seeds__\//,
// Configs
/\.config\.(ts|js|mjs|cjs)$/,
/(^|\/)package\.json$/,
/(^|\/)tsconfig.*\.json$/,
/(^|\/)turbo\.json$/,
// Docs / data
/\.md$/,
/\.json$/,
/\.jsonld$/, // JSON-LD context files (e.g. core-dsr/contexts/user-data.jsonld)
/\.ya?ml$/,
/\.gitignore$/,
/\.prettierignore$/,
/\.npmrc$/,
/(^|\/)\.env(\.[^/]+)?$/, // .env, .env.example, .env.local, etc.
// Shell scripts (not Vitest-covered)
/\.sh$/,
/\.bash$/,
// Dev-tooling scripts — tested via `node --test`, outside vitest's v8 lcov.
// (Their own test coverage is gated separately via the scripts' own tests.)
/^scripts\//,
/^turbo\/generators\//,
// Per-package coverage excludes (mirror vitest config)
/\/di\/bind-production\.ts$/,
/\/application\/repositories\//,
/\/application\/services\//,
/\/integrations\/cms\//,
/\/ui\//,
// Tooling packages that don't generate a vitest lcov (no @vitest/coverage-v8)
/^packages\/core-testing\//,
/^packages\/core-eslint\//,
// App packages (web-next, cms) do not configure
// @vitest/coverage-v8, so their source files never appear in the merged lcov.
/^apps\//,
// core-shared Sentry client init files — explicitly excluded from per-package
// vitest coverage in core-shared/vitest.config.ts ("Sentry client init —
// browser/node SDK init, tested in apps"); they have test files but coverage
// is excluded by design (browser SDK calls, not unit-testable in isolation).
/\/instrumentation\/sentry\//,
// Pure type-alias / interface files (no executable code)
/\.d\.ts$/, // ambient declaration files — no runtime code by definition
/\.interface\.ts$/,
/\/index\.ts$/, // barrel re-exports — no executable code
// Build artifacts
/\.tsbuildinfo$/,
/\.lock$/,
/(^|\/)dist\//,
/(^|\/)\.next\//,
/(^|\/)\.turbo\//,
/(^|\/)node_modules\//,
// Coverage output (anchored to package/app/root, NOT scripts/coverage/)
/^coverage\//,
/^packages\/[^/]+\/coverage\//,
/^apps\/[^/]+\/coverage\//,
];
function isAllowed(file) {
return ALLOWED_GLOBS.some((re) => re.test(file));
}
/**
* Parse lcov into a map of file -> Map<lineNumber, executionCount>.
* Only DA records are read; LF/LH/BRDA/BRF/BRH/etc. are ignored.
*
* lcov is a simple line-oriented format:
* SF:<file>
* DA:<line>,<count>
* ...
* end_of_record
*/
export function parseLcov(text) {
const result = new Map();
let currentFile = null;
let currentLines = null;
for (const line of text.split("\n")) {
if (line.startsWith("SF:")) {
currentFile = line.slice(3);
currentLines = new Map();
result.set(currentFile, currentLines);
} else if (line.startsWith("DA:") && currentLines) {
const [lineNo, count] = line.slice(3).split(",");
currentLines.set(Number(lineNo), Number(count));
} else if (line === "end_of_record") {
currentFile = null;
currentLines = null;
}
}
return result;
}
/**
* Parse `git diff --unified=0` output into a map of file -> Set<lineNumber>.
*
* Only NEW or MODIFIED lines in the new version are tracked (the `+N,M`
* portion of `@@ -A,B +N,M @@`). Removed-only hunks contribute no lines
* to check (the line is gone).
*
* Renamed files are tracked by their new path.
*/
export function parseGitDiff(text) {
const result = new Map();
let currentFile = null;
let currentLines = null;
for (const line of text.split("\n")) {
if (line.startsWith("+++ ")) {
const p = line.slice(4).trim();
if (p === "/dev/null") {
currentFile = null;
currentLines = null;
continue;
}
// Strip `b/` prefix git adds
currentFile = p.startsWith("b/") ? p.slice(2) : p;
currentLines = new Set();
result.set(currentFile, currentLines);
} else if (line.startsWith("@@ ") && currentLines) {
// @@ -A,B +N,M @@
const m = /@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
if (!m) continue;
const start = Number(m[1]);
const count = m[2] === undefined ? 1 : Number(m[2]);
if (count === 0) continue; // hunk has no lines in new version
for (let i = 0; i < count; i++) {
currentLines.add(start + i);
}
}
}
return result;
}
/**
* Given parsed lcov + parsed diff, return the list of uncovered hits.
* Each hit: { file, line, kind }
* - kind = "uncovered": line is executable in lcov but count is 0
* - kind = "no-coverage-data": file is not in lcov at all
* - kind = "non-executable": line has no DA record (not flagged; for
* visibility only, currently filtered out)
*/
export function computeDiffCoverage(diff, lcov, opts = {}) {
const repoRoot = opts.repoRoot ?? process.cwd();
const uncovered = [];
const fileSummaries = [];
for (const [file, lines] of diff) {
if (isAllowed(file)) continue;
// Match lcov keys (absolute paths) to diff keys (repo-relative)
let lcovLines = lcov.get(file);
if (!lcovLines) {
const abs = path.resolve(repoRoot, file);
lcovLines = lcov.get(abs);
}
if (!lcovLines) {
// Try matching by suffix in either direction. lcov paths can be:
// - absolute (vitest with `coverage.reportsDirectory` at default)
// - repo-relative (after `pnpm coverage:aggregate` normalizes)
// - package-relative (per-package lcov from `pnpm test:coverage`)
// The diff path is always repo-relative.
for (const [k, v] of lcov.entries()) {
if (file.endsWith("/" + k) || k.endsWith("/" + file) || k === file) {
lcovLines = v;
break;
}
}
}
if (!lcovLines) {
uncovered.push({ file, line: 0, kind: "no-coverage-data" });
fileSummaries.push({ file, changed: lines.size, missed: lines.size });
continue;
}
let missedInFile = 0;
for (const line of [...lines].sort((a, b) => a - b)) {
const count = lcovLines.get(line);
if (count === undefined) {
// Line isn't executable per lcov — skip (blank, comment, type-only)
continue;
}
if (count === 0) {
uncovered.push({ file, line, kind: "uncovered" });
missedInFile++;
}
}
fileSummaries.push({ file, changed: lines.size, missed: missedInFile });
}
return {
status: uncovered.length === 0 ? "pass" : "fail",
summary: {
filesChanged: diff.size,
filesGated: fileSummaries.length,
uncoveredCount: uncovered.length,
},
fileSummaries,
uncovered,
};
}
// ---- CLI ----
function parseArgs(argv) {
const out = { base: "origin/main", lcov: "coverage/lcov.info", json: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--base") out.base = argv[++i];
else if (a === "--lcov") out.lcov = argv[++i];
else if (a === "--json") out.json = true;
else if (a === "--help" || a === "-h") {
console.log(
"Usage: pnpm coverage:diff [-- --base <ref>] [--lcov <path>] [--json]",
);
process.exit(0);
}
}
return out;
}
function main() {
const args = parseArgs(process.argv);
const repoRoot = process.cwd();
// Load lcov
const lcovPath = path.resolve(repoRoot, args.lcov);
if (!fs.existsSync(lcovPath)) {
process.stderr.write(
`[coverage:diff] lcov file not found at ${lcovPath}\n` +
`Run \`pnpm test:coverage\` first, then \`pnpm coverage:aggregate\`.\n`,
);
// Emit JSON anyway so the dispatch loop can read it
process.stdout.write(
JSON.stringify({ status: "error", reason: "lcov-missing", lcovPath }) +
"\n",
);
process.exit(1);
}
const lcov = parseLcov(fs.readFileSync(lcovPath, "utf8"));
// Get diff
let diffText;
try {
diffText = execSync(`git diff --unified=0 --no-color ${args.base}...HEAD`, {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
} catch (err) {
process.stderr.write(
`[coverage:diff] git diff against base "${args.base}" failed: ${err.message}\n`,
);
process.stdout.write(
JSON.stringify({ status: "error", reason: "git-diff-failed" }) + "\n",
);
process.exit(1);
}
const diff = parseGitDiff(diffText);
// Compute
const result = computeDiffCoverage(diff, lcov, { repoRoot });
// Emit JSON to stdout
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
// Emit human summary to stderr (unless --json)
if (!args.json) {
const { status, summary, uncovered } = result;
if (status === "pass") {
process.stderr.write(
`[coverage:diff] PASS — ${summary.filesGated} file(s) gated, all changed lines covered.\n`,
);
} else {
process.stderr.write(
`[coverage:diff] FAIL — ${summary.uncoveredCount} uncovered hit(s) across ${summary.filesGated} file(s):\n`,
);
const byFile = new Map();
for (const u of uncovered) {
if (!byFile.has(u.file)) byFile.set(u.file, []);
byFile.get(u.file).push(u);
}
for (const [file, hits] of byFile) {
const noData = hits[0]?.kind === "no-coverage-data";
if (noData) {
process.stderr.write(
` ${file}\n no coverage data (new untested file?)\n`,
);
} else {
const lines = hits.map((h) => h.line).join(", ");
process.stderr.write(` ${file}\n uncovered lines: ${lines}\n`);
}
}
}
}
process.exit(result.status === "pass" ? 0 : 1);
}
// Only run main when invoked directly (not when imported by tests)
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
if (invokedDirectly) {
main();
}

View File

@@ -0,0 +1,311 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseLcov, parseGitDiff, computeDiffCoverage } from "./diff.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.join(__dirname, "__fixtures__");
const lcovText = fs.readFileSync(path.join(FIXTURES, "sample.lcov"), "utf8");
const diffText = fs.readFileSync(
path.join(FIXTURES, "sample-diff.patch"),
"utf8",
);
describe("parseLcov", () => {
test("groups DA records by SF file", () => {
const lcov = parseLcov(lcovText);
assert.equal(lcov.size, 3);
assert.ok(
lcov.has(
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
),
);
assert.ok(lcov.has("/repo/packages/auth/src/entities/models/user.ts"));
});
test("preserves per-line execution counts", () => {
const lcov = parseLcov(lcovText);
const lines = lcov.get(
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
);
assert.equal(lines.get(1), 5);
assert.equal(lines.get(5), 0);
assert.equal(lines.get(8), 3);
});
test("ignores non-DA records (LF, LH, BRDA)", () => {
const lcov = parseLcov(lcovText);
const lines = lcov.get("/repo/packages/auth/src/entities/models/user.ts");
assert.equal(lines.size, 3); // Only DA records, not LF/LH counters
});
});
describe("parseGitDiff", () => {
test("extracts new + modified line numbers per file from the new version", () => {
const diff = parseGitDiff(diffText);
const signIn = diff.get(
"packages/auth/src/application/use-cases/sign-in.use-case.ts",
);
// Hunks: +2,2 (lines 2,3) and +5,2 (lines 5,6)
assert.deepEqual(
[...signIn].sort((a, b) => a - b),
[2, 3, 5, 6],
);
});
test("strips the b/ prefix from new-version paths", () => {
const diff = parseGitDiff(diffText);
assert.ok(diff.has("packages/auth/src/entities/models/user.ts"));
assert.ok(!diff.has("b/packages/auth/src/entities/models/user.ts"));
});
test("handles single-line hunks (no comma in +N,M)", () => {
const diff = parseGitDiff(diffText);
const blog = diff.get(
"packages/blog/src/application/use-cases/get-article.use-case.ts",
);
// @@ -11,0 +12 @@ -> single line at 12
assert.deepEqual([...blog], [12]);
});
test("handles new files (entire file is in the diff)", () => {
const diff = parseGitDiff(diffText);
const upload = diff.get(
"packages/media/src/application/use-cases/upload.use-case.ts",
);
// @@ -0,0 +1,5 @@ -> lines 1-5
assert.deepEqual(
[...upload].sort((a, b) => a - b),
[1, 2, 3, 4, 5],
);
});
});
describe("computeDiffCoverage", () => {
test("passes when changed executable lines are all covered", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
["/repo/packages/auth/src/entities/models/user.ts", new Set([1, 2, 3])],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.uncovered.length, 0);
});
test("fails when a changed line is in lcov with count 0", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
[
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
new Set([5, 6]),
],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "fail");
assert.equal(result.uncovered.length, 2);
assert.deepEqual(result.uncovered.map((u) => u.line).sort(), [5, 6]);
assert.ok(result.uncovered.every((u) => u.kind === "uncovered"));
});
test("ignores lines without DA records (non-executable)", () => {
const lcov = parseLcov(lcovText);
// Line 4 has no DA record in the fixture
const diff = new Map([
[
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
new Set([4]),
],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
});
test("flags files with no coverage data (new untested file)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
[
"packages/media/src/application/use-cases/upload.use-case.ts",
new Set([1, 2, 3, 4, 5]),
],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "fail");
assert.equal(result.uncovered.length, 1);
assert.equal(result.uncovered[0].kind, "no-coverage-data");
});
test("skips allowed extensions (.md, .json, .test.ts, configs)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
["CLAUDE.md", new Set([1, 2])],
["package.json", new Set([1])],
["packages/auth/vitest.config.ts", new Set([1])],
[
"packages/auth/src/application/use-cases/sign-in.use-case.test.ts",
new Set([11]),
],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 4);
});
test("skips JSON-LD context files (.jsonld)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
// JSON-LD context files are static data assets with no executable code
// (e.g. packages/core-dsr/src/contexts/user-data.jsonld). v8 coverage
// never sees them, so they must be exempted from the no-coverage-data gate.
["packages/core-dsr/src/contexts/user-data.jsonld", new Set([1, 2, 3])],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 1);
});
test("skips TypeScript ambient declaration files (.d.ts)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
// Ambient declaration files have no runtime code — v8 coverage never
// sees them, so they must be exempted from the no-coverage-data gate.
[
"packages/core-shared/src/payload/payload-custom-ambient.d.ts",
new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
],
["packages/foo/src/bar/some-types.d.ts", new Set([1, 2])],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 2);
});
test("skips dotfile ignore configs (.prettierignore, .gitignore)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
[".prettierignore", new Set([1, 2])],
[".gitignore", new Set([1])],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 2);
});
test("skips .env template files (.env, .env.example, .env.local)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
[".env.example", new Set([1, 2, 3])],
[".env.local", new Set([1])],
[".env", new Set([1])],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 3);
});
test("skips Storybook story files (.stories.ts/.stories.tsx — excluded from vitest coverage)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
[
"packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.stories.tsx",
new Set([1, 2, 3, 4, 5]),
],
["packages/core-ui/src/atoms/button/button.stories.ts", new Set([1, 2])],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 2);
});
test("skips packages/core-testing/ (tooling package, no lcov generated)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
[
"packages/core-testing/src/instrumentation/recording-consent.ts",
new Set([1, 2, 3, 4, 5]),
],
[
"packages/core-testing/src/factory/define-factory.ts",
new Set([1, 2, 3]),
],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 2);
});
test("skips apps/ files (app packages don't configure @vitest/coverage-v8)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
// web-next and cms have no @vitest/coverage-v8 — never in lcov
["apps/web-next/instrumentation-client.ts", new Set([1, 2, 3, 4, 5, 6])],
["apps/web-next/middleware.ts", new Set([1, 2, 3, 4, 5])],
["apps/web-next/src/app/layout.tsx", new Set([1, 2, 3])],
["apps/cms/src/instrumentation.ts", new Set([1, 2, 3, 4, 5])],
["apps/cms/src/payload.config.ts", new Set([1, 2, 3])],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 5);
});
test("skips core-shared sentry init files (excluded from vitest coverage by design)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
// core-shared/vitest.config.ts excludes src/instrumentation/sentry/**
[
"packages/core-shared/src/instrumentation/sentry/init-client.ts",
new Set([1, 2, 3, 4, 5]),
],
[
"packages/core-shared/src/instrumentation/sentry/init-client-react.ts",
new Set([1, 2, 3, 4, 5]),
],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 2);
});
test("end-to-end fixture: mixed pass/fail/skip/no-data", () => {
const lcov = parseLcov(lcovText);
const diff = parseGitDiff(diffText);
const result = computeDiffCoverage(diff, lcov, { repoRoot: "/repo" });
assert.equal(result.status, "fail");
// CLAUDE.md, sign-in.use-case.test.ts -> skipped (allowlist)
// sign-in.use-case.ts lines 2,3,5,6 -> 5,6 are uncovered, 2,3 don't have DA records (not in lcov for those lines) so they don't count
// user.ts line 2 -> covered (count: 1)
// get-article.use-case.ts line 12 -> uncovered (count: 0)
// upload.use-case.ts -> no coverage data
// The expected uncovered set: sign-in lines 5,6 + get-article line 12 + upload (no-data)
const uncoveredKinds = result.uncovered.map((u) => u.kind);
assert.ok(uncoveredKinds.includes("no-coverage-data"));
assert.ok(uncoveredKinds.includes("uncovered"));
});
test("resolves repo-relative diff paths against lcov absolute paths", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
// Diff uses repo-relative; lcov has absolute. Suffix match should
// bridge them.
[
"packages/auth/src/application/use-cases/sign-in.use-case.ts",
new Set([8]),
],
]);
const result = computeDiffCoverage(diff, lcov, { repoRoot: "/repo" });
assert.equal(result.status, "pass");
});
});

158
scripts/coverage/mutate.mjs Normal file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env node
// scripts/coverage/mutate.mjs — L3 of the coverage architecture (ADR-020).
//
// Driver for Stryker mutation testing. Discovers every package with a
// stryker.config.json, then runs Stryker per-feature with the feature's
// vitest config + a narrowed mutate scope (entities + use-cases by default,
// per the shared base config).
//
// Usage:
// pnpm mutate # run for every feature with a config
// pnpm mutate -- --filter @repo/auth # run for one feature
// pnpm mutate -- --filter @repo/auth --since main # incremental mode
// pnpm mutate -- --json # machine-readable summary only
//
// This runs Stryker as a child process, not via dynamic import — Stryker's
// runtime expects to own the test process and our wrapping logic keeps the
// integration simple. Stryker handles concurrency internally.
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const STRYKER_BIN = "node_modules/.bin/stryker";
/**
* Walk packages/* and apps/* for stryker.config.json files.
* Returns: [{ packageDir, packageName, configPath }]
*/
export function discoverStrykerConfigs(repoRoot) {
const out = [];
for (const root of ["packages", "apps"]) {
const dir = path.join(repoRoot, root);
if (!fs.existsSync(dir)) continue;
for (const pkg of fs.readdirSync(dir)) {
const configPath = path.join(dir, pkg, "stryker.config.json");
if (!fs.existsSync(configPath)) continue;
const pkgJsonPath = path.join(dir, pkg, "package.json");
let packageName = `${root}/${pkg}`;
if (fs.existsSync(pkgJsonPath)) {
try {
packageName =
JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")).name ??
packageName;
} catch {
// fall through
}
}
out.push({
packageDir: path.join(root, pkg),
packageName,
configPath,
});
}
}
return out.sort((a, b) => a.packageDir.localeCompare(b.packageDir));
}
function parseArgs(argv) {
const out = { filter: null, since: null, json: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--filter") out.filter = argv[++i];
else if (a === "--since") out.since = argv[++i];
else if (a === "--json") out.json = true;
else if (a === "--help" || a === "-h") {
console.log(
"Usage: pnpm mutate [-- --filter <name>] [--since <ref>] [--json]",
);
process.exit(0);
}
}
return out;
}
function main() {
const args = parseArgs(process.argv);
const repoRoot = process.cwd();
const all = discoverStrykerConfigs(repoRoot);
const targets = args.filter
? all.filter(
(c) =>
c.packageName === args.filter || c.packageDir.endsWith(args.filter),
)
: all;
if (targets.length === 0) {
process.stderr.write(
`[mutate] No stryker.config.json found${args.filter ? ` matching ${args.filter}` : ""}.\n` +
`Each feature wanting L3 mutation needs a stryker.config.json. See docs/guides/coverage.md.\n`,
);
process.exit(args.filter ? 1 : 0);
}
const strykerBinAbs = path.join(repoRoot, STRYKER_BIN);
if (!fs.existsSync(strykerBinAbs)) {
process.stderr.write(
`[mutate] Stryker binary not found at ${STRYKER_BIN}. Run \`pnpm install\`.\n`,
);
process.exit(1);
}
const results = [];
for (const target of targets) {
if (!args.json) {
process.stderr.write(
`\n[mutate] === ${target.packageName} (${target.packageDir}) ===\n`,
);
}
const strykerArgs = ["run", target.configPath];
if (args.since) {
strykerArgs.push("--since", args.since);
}
const result = spawnSync(strykerBinAbs, strykerArgs, {
cwd: path.join(repoRoot, target.packageDir),
stdio: args.json ? "pipe" : "inherit",
env: { ...process.env, FORCE_COLOR: args.json ? "0" : "1" },
});
results.push({
package: target.packageName,
packageDir: target.packageDir,
exitCode: result.status,
status: result.status === 0 ? "pass" : "fail",
});
// If a feature fails, continue to next. Surface failures at the end so
// a single broken feature doesn't mask state for the others.
}
const anyFailed = results.some((r) => r.exitCode !== 0);
if (args.json) {
process.stdout.write(
JSON.stringify(
{ status: anyFailed ? "fail" : "pass", results },
null,
2,
) + "\n",
);
} else {
process.stderr.write("\n[mutate] Summary:\n");
for (const r of results) {
process.stderr.write(
` ${r.status === "pass" ? "✓" : "✗"} ${r.package}\n`,
);
}
}
process.exit(anyFailed ? 1 : 0);
}
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
if (invokedDirectly) {
main();
}

View File

@@ -0,0 +1,71 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { discoverStrykerConfigs } from "./mutate.mjs";
describe("discoverStrykerConfigs", () => {
test("finds stryker.config.json under packages/* and apps/*", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mutate-disc-"));
try {
fs.mkdirSync(path.join(tmpRoot, "packages", "p1"), { recursive: true });
fs.mkdirSync(path.join(tmpRoot, "apps", "a1"), { recursive: true });
fs.mkdirSync(path.join(tmpRoot, "packages", "no-stryker"), {
recursive: true,
});
fs.writeFileSync(
path.join(tmpRoot, "packages", "p1", "stryker.config.json"),
"{}",
);
fs.writeFileSync(
path.join(tmpRoot, "packages", "p1", "package.json"),
JSON.stringify({ name: "@repo/p1" }),
);
fs.writeFileSync(
path.join(tmpRoot, "apps", "a1", "stryker.config.json"),
"{}",
);
fs.writeFileSync(
path.join(tmpRoot, "apps", "a1", "package.json"),
JSON.stringify({ name: "@repo/a1" }),
);
const found = discoverStrykerConfigs(tmpRoot);
assert.equal(found.length, 2);
const names = found.map((f) => f.packageName).sort();
assert.deepEqual(names, ["@repo/a1", "@repo/p1"]);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test("falls back to packageDir when no package.json", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mutate-disc2-"));
try {
fs.mkdirSync(path.join(tmpRoot, "packages", "no-pkg-json"), {
recursive: true,
});
fs.writeFileSync(
path.join(tmpRoot, "packages", "no-pkg-json", "stryker.config.json"),
"{}",
);
const found = discoverStrykerConfigs(tmpRoot);
assert.equal(found.length, 1);
assert.equal(found[0].packageName, "packages/no-pkg-json");
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test("returns empty when nothing matches", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mutate-disc3-"));
try {
const found = discoverStrykerConfigs(tmpRoot);
assert.deepEqual(found, []);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,452 @@
#!/usr/bin/env node
/**
* Pre-commit guard: refuses the commit if a new runtime dependency in a
* feature- or core-tier package lacks a staged approved library trace.
*
* Exit codes:
* 0 — all staged deps have approved traces (or are app-tier / devDeps / peerDeps)
* 1 — one or more feature/core deps are missing an approved trace
*/
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseFrontmatter } from "./schema.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_REPO_ROOT = path.resolve(__dirname, "..", "..");
/** Derive package tier from its repo-relative path. */
function deriveTier(relPath) {
if (relPath.startsWith("apps/")) return "app";
if (relPath.startsWith("packages/core-")) return "core";
if (relPath.startsWith("packages/")) return "feature";
return "skip"; // root package.json or unknown path
}
function stagedFilesList(repoRoot, baseRef) {
const cmd = baseRef
? `git diff ${baseRef}...HEAD --name-only`
: "git diff --cached --name-only";
return execSync(cmd, { cwd: repoRoot, encoding: "utf8" })
.split("\n")
.filter(Boolean);
}
/**
* Return the names of runtime deps that are new in the staged version of
* relPath compared to HEAD. Returns [] when the file can't be read.
*
* When baseRef is set, compares HEAD against baseRef instead of the index.
*/
function getNewRuntimeDeps(relPath, repoRoot, baseRef) {
const currentRef = baseRef ? `HEAD:${relPath}` : `:${relPath}`;
const ancestorRef = baseRef ? `${baseRef}:${relPath}` : `HEAD:${relPath}`;
let staged;
try {
staged = JSON.parse(
execSync(`git show "${currentRef}"`, { cwd: repoRoot, encoding: "utf8" }),
);
} catch {
return [];
}
let base = {};
try {
base = JSON.parse(
execSync(`git show "${ancestorRef}"`, {
cwd: repoRoot,
encoding: "utf8",
}),
);
} catch {
// New file or initial commit — treat all deps as new
}
const baseDeps = new Set(Object.keys(base.dependencies ?? {}));
const stagedDeps = staged.dependencies ?? {};
return Object.keys(stagedDeps).filter(
(d) =>
!baseDeps.has(d) &&
// Workspace-protocol entries are internal monorepo packages — not
// third-party libraries, so they don't require a library trace.
!String(stagedDeps[d]).startsWith("workspace:"),
);
}
/**
* Search the staged-files list for a trace file whose name ends with
* `-<depName>.md` inside docs/library-decisions/.
*/
function findStagedTrace(depName, staged) {
const safe = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^docs/library-decisions/[^/]+-${safe}\\.md$`);
return staged.find((f) => re.test(f)) ?? null;
}
// ---------------------------------------------------------------------------
// Renovate-PR mode helpers
// ---------------------------------------------------------------------------
function getCurrentBranch(repoRoot) {
try {
return execSync("git rev-parse --abbrev-ref HEAD", {
cwd: repoRoot,
encoding: "utf8",
}).trim();
} catch {
return null;
}
}
function getLockfileDiff(repoRoot) {
try {
return execSync("git diff origin/main -- pnpm-lock.yaml", {
cwd: repoRoot,
encoding: "utf8",
});
} catch {
return "";
}
}
/**
* Matches a pnpm lockfile diff line for a package version entry.
* Handles both pnpm v6 format (`/pkg@ver:`) and v9 format (` pkg@ver: {}`),
* and both scoped (`@scope/name`) and unscoped packages.
*/
const LOCKFILE_LINE_RE =
/^([+-])\s*\/?(@[\w.-]+\/[\w.-]+|[\w.-]+)@(\d[-\w.+]*)(?=\s|:|$)/;
/** Parse a lockfile diff and return a Map of { depName → { from, to } }. */
function parseLockfileDiff(diff) {
const removed = new Map();
const added = new Map();
for (const line of diff.split("\n")) {
const m = LOCKFILE_LINE_RE.exec(line);
if (!m) continue;
const [, sign, name, version] = m;
if (sign === "-") removed.set(name, version);
else added.set(name, version);
}
const bumped = new Map();
for (const [name, to] of added) {
const from = removed.get(name);
if (from && from !== to) bumped.set(name, { from, to });
}
return bumped;
}
/** Classify a version bump as "major", "minor", "patch", or "unknown". */
function classifyBump(from, to) {
const m1 = from.match(/^(\d+)\.(\d+)/);
const m2 = to.match(/^(\d+)\.(\d+)/);
if (!m1 || !m2) return "unknown";
const fromMaj = +m1[1],
toMaj = +m2[1];
const fromMin = +m1[2],
toMin = +m2[2];
if (toMaj > fromMaj) return "major";
if (toMaj === fromMaj && toMin > fromMin) return "minor";
return "patch";
}
/**
* Return the set of direct runtime dependencies declared by all feature- and
* core-tier packages (packages/* on disk). App-tier deps are excluded.
*/
function getFeatureCoreDeps(repoRoot) {
const deps = new Set();
const packagesDir = path.join(repoRoot, "packages");
if (!fs.existsSync(packagesDir)) return deps;
let entries;
try {
entries = fs.readdirSync(packagesDir);
} catch {
return deps;
}
for (const pkg of entries) {
const pkgJsonPath = path.join(packagesDir, pkg, "package.json");
if (!fs.existsSync(pkgJsonPath)) continue;
try {
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
for (const dep of Object.keys(pkgJson.dependencies ?? {})) {
deps.add(dep);
}
} catch {
// skip malformed package.json
}
}
return deps;
}
/**
* Locate an existing (committed) trace file for depName under
* docs/library-decisions/ inside repoRoot. Returns the absolute path or null.
*/
function findExistingTrace(depName, repoRoot) {
const traceDir = path.join(repoRoot, "docs", "library-decisions");
if (!fs.existsSync(traceDir)) return null;
let entries;
try {
entries = fs.readdirSync(traceDir);
} catch {
return null;
}
if (depName.startsWith("@")) {
// Scoped package: @scope/name → look for a dir *-@scope, then name.md inside
const slashIdx = depName.indexOf("/");
const scope = depName.slice(0, slashIdx); // e.g. "@sentry"
const name = depName.slice(slashIdx + 1); // e.g. "node"
for (const entry of entries) {
if (!entry.endsWith(`-${scope}`)) continue;
const entryPath = path.join(traceDir, entry);
try {
if (!fs.statSync(entryPath).isDirectory()) continue;
} catch {
continue;
}
const tracePath = path.join(entryPath, `${name}.md`);
if (fs.existsSync(tracePath)) return tracePath;
}
} else {
// Unscoped package: look for *-<name>.md
const safe = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^[^/]+-${safe}\\.md$`);
for (const entry of entries) {
if (!re.test(entry)) continue;
const entryPath = path.join(traceDir, entry);
try {
if (!fs.statSync(entryPath).isFile()) continue;
} catch {
continue;
}
return entryPath;
}
}
return null;
}
/**
* Renovate-PR mode: for every feature/core-tier dep that receives a major
* version bump, the corresponding trace must have lastRevalidated === today.
*
* Returns an array of error objects; empty array means clean.
* { dep, from, to, reason: "stale" | "no-trace" | "parse-error", ... }
*/
export function checkRenovatePr(
repoRoot = DEFAULT_REPO_ROOT,
{ branch, diff, today } = {},
) {
const effectiveBranch =
branch ?? process.env.GITHUB_HEAD_REF ?? getCurrentBranch(repoRoot);
if (!effectiveBranch?.startsWith("renovate/")) return [];
const lockfileDiff = diff ?? getLockfileDiff(repoRoot);
const todayStr = today ?? new Date().toISOString().slice(0, 10);
const bumped = parseLockfileDiff(lockfileDiff);
const featureCoreDeps = getFeatureCoreDeps(repoRoot);
const errors = [];
for (const [depName, { from, to }] of bumped) {
if (!featureCoreDeps.has(depName)) continue;
if (classifyBump(from, to) !== "major") continue;
const traceFile = findExistingTrace(depName, repoRoot);
if (!traceFile) {
errors.push({ dep: depName, from, to, reason: "no-trace" });
continue;
}
try {
const content = fs.readFileSync(traceFile, "utf8");
const fm = parseFrontmatter(content);
if (fm.lastRevalidated !== todayStr) {
errors.push({
dep: depName,
from,
to,
reason: "stale",
lastRevalidated: fm.lastRevalidated ?? null,
tracePath: path.relative(repoRoot, traceFile),
});
}
} catch (e) {
errors.push({
dep: depName,
from,
to,
reason: "parse-error",
detail: String(e.message),
});
}
}
return errors;
}
/**
* Run the library-decisions check against repoRoot.
*
* Returns an array of error objects, each with:
* { pkgJson, dep, reason: "no-trace" | "not-approved" | "parse-error", ... }
*
* An empty array means the commit is clean.
*/
export function checkLibraryDecisions(
repoRoot = DEFAULT_REPO_ROOT,
{ stagedAgainst } = {},
) {
const staged = stagedFilesList(repoRoot, stagedAgainst);
const pkgJsons = staged.filter(
(f) => f === "package.json" || f.endsWith("/package.json"),
);
const errors = [];
for (const relPath of pkgJsons) {
const tier = deriveTier(relPath);
if (tier === "app" || tier === "skip") continue;
for (const dep of getNewRuntimeDeps(relPath, repoRoot, stagedAgainst)) {
const stagedTrace = findStagedTrace(dep, staged);
if (!stagedTrace) {
// Fall back to an already-committed trace — if one exists and is
// approved, the dep was previously evaluated and doesn't need
// re-staging just because a new package adopts it.
const committedTrace = findExistingTrace(dep, repoRoot);
if (committedTrace) {
try {
const content = fs.readFileSync(committedTrace, "utf8");
const fm = parseFrontmatter(content);
if (fm.decision !== "approved") {
errors.push({
pkgJson: relPath,
dep,
reason: "not-approved",
decision: fm.decision,
});
}
} catch (e) {
errors.push({
pkgJson: relPath,
dep,
reason: "parse-error",
detail: String(e.message),
});
}
continue;
}
errors.push({ pkgJson: relPath, dep, reason: "no-trace" });
continue;
}
try {
const traceRef = stagedAgainst
? `HEAD:${stagedTrace}`
: `:${stagedTrace}`;
const content = execSync(`git show "${traceRef}"`, {
cwd: repoRoot,
encoding: "utf8",
});
const fm = parseFrontmatter(content);
if (fm.decision !== "approved") {
errors.push({
pkgJson: relPath,
dep,
reason: "not-approved",
decision: fm.decision,
});
}
} catch (e) {
errors.push({
pkgJson: relPath,
dep,
reason: "parse-error",
detail: String(e.message),
});
}
}
}
return errors;
}
// CLI entry point — only runs when executed directly, not when imported.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
if (args.includes("--renovate-pr")) {
let branch;
const branchIdx = args.indexOf("--branch");
if (branchIdx !== -1) {
branch = args[branchIdx + 1];
if (!branch || branch.startsWith("--")) {
console.error("Error: --branch requires a branch name argument");
process.exit(1);
}
}
const renovateErrors = checkRenovatePr(DEFAULT_REPO_ROOT, { branch });
if (!renovateErrors.length) process.exit(0);
const today = new Date().toISOString().slice(0, 10);
console.error(
"✗ library-decisions/check: major version bump requires re-evaluation.\n",
);
for (const e of renovateErrors) {
console.error(` ${e.dep} (${e.from}${e.to}):`);
if (e.reason === "stale") {
console.error(
` ✗ trace lastRevalidated is "${e.lastRevalidated}" — must be today (${today})`,
);
console.error(` Trace: ${e.tracePath}`);
} else if (e.reason === "no-trace") {
console.error(` ✗ no trace found in docs/library-decisions/`);
} else {
console.error(` ✗ trace error — ${e.detail ?? ""}`);
}
console.error(
` Run the evaluate-library skill: .claude/skills/evaluate-library/SKILL.md`,
);
}
process.exit(1);
}
let stagedAgainst;
const flagIdx = args.indexOf("--staged-against");
if (flagIdx !== -1) {
stagedAgainst = args[flagIdx + 1];
if (!stagedAgainst || stagedAgainst.startsWith("--")) {
console.error("Error: --staged-against requires a base ref argument");
process.exit(1);
}
}
const errors = checkLibraryDecisions(DEFAULT_REPO_ROOT, { stagedAgainst });
if (!errors.length) process.exit(0);
console.error(
"✗ library-decisions/check: new runtime deps require an approved trace.\n",
);
const groups = {};
for (const e of errors) {
(groups[e.pkgJson] ??= []).push(e);
}
for (const [pkg, errs] of Object.entries(groups)) {
console.error(` ${pkg}:`);
for (const e of errs) {
const msg =
e.reason === "no-trace"
? "no staged trace in docs/library-decisions/"
: e.reason === "not-approved"
? `trace decision is "${e.decision}" (expected "approved")`
: `trace parse error — ${e.detail}`;
console.error(`${e.dep}: ${msg}`);
}
}
console.error(
"\n Evaluate the library first: .claude/skills/evaluate-library/SKILL.md",
);
process.exit(1);
}

View File

@@ -0,0 +1,383 @@
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 { execSync } from "node:child_process";
import { checkLibraryDecisions, checkRenovatePr } from "./check.mjs";
/** Create a temp git repo with one initial commit so HEAD exists. */
function makeRepo() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libcheck-"));
const g = (cmd) => execSync(cmd, { cwd: dir, stdio: "pipe" });
g("git init");
g("git config user.email test@test.com");
g("git config user.name Test");
g("git config commit.gpgsign false");
fs.writeFileSync(path.join(dir, ".gitkeep"), "");
g("git add .gitkeep");
g("git commit -m init");
return { dir, g };
}
/** Write a package.json under relDir and commit it as the baseline. */
function commitPkg(dir, g, relDir, pkg) {
const pkgDir = path.join(dir, relDir);
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "package.json"),
JSON.stringify(pkg, null, 2),
);
g(`git add ${relDir}/package.json`);
g("git commit -m add-pkg");
}
/** Overwrite a package.json and stage the result (no new commit). */
function stagePkg(dir, g, relDir, pkg) {
fs.writeFileSync(
path.join(dir, relDir, "package.json"),
JSON.stringify(pkg, null, 2),
);
g(`git add ${relDir}/package.json`);
}
function traceFm(depName, decision) {
return `package: ${depName}
version: "^1.0.0"
tier: feature
decision: ${decision}
date: 2026-05-14
deciders: [alice]
adr: null
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: ok
cve-scan: clean
named-consumer: pass
verification-commands:
- pnpm audit --audit-level=moderate`;
}
/** Write a trace file and stage it. */
function stageTrace(dir, g, depName, decision = "approved") {
const traceDir = path.join(dir, "docs", "library-decisions");
fs.mkdirSync(traceDir, { recursive: true });
const file = `2026-05-14-${depName}.md`;
fs.writeFileSync(
path.join(traceDir, file),
`---\n${traceFm(depName, decision)}\n---\n\n`,
);
g(`git add docs/library-decisions/${file}`);
}
describe("checkLibraryDecisions", () => {
test("new feature-tier dep without trace → exit 1", () => {
const { dir, g } = makeRepo();
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
stagePkg(dir, g, "packages/feat-a", {
dependencies: { "new-lib": "^1.0.0" },
});
const errs = checkLibraryDecisions(dir);
assert.equal(errs.length, 1);
assert.equal(errs[0].dep, "new-lib");
assert.equal(errs[0].reason, "no-trace");
});
test("new feature-tier dep with approved trace staged → exit 0", () => {
const { dir, g } = makeRepo();
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
stagePkg(dir, g, "packages/feat-a", {
dependencies: { "new-lib": "^1.0.0" },
});
stageTrace(dir, g, "new-lib", "approved");
assert.deepEqual(checkLibraryDecisions(dir), []);
});
test("new feature-tier dep with rejected-decision trace staged → exit 1", () => {
const { dir, g } = makeRepo();
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
stagePkg(dir, g, "packages/feat-a", {
dependencies: { "new-lib": "^1.0.0" },
});
stageTrace(dir, g, "new-lib", "rejected");
const errs = checkLibraryDecisions(dir);
assert.equal(errs.length, 1);
assert.equal(errs[0].dep, "new-lib");
assert.equal(errs[0].reason, "not-approved");
assert.equal(errs[0].decision, "rejected");
});
test("new app-tier dep → exit 0", () => {
const { dir, g } = makeRepo();
commitPkg(dir, g, "apps/web", { dependencies: {} });
stagePkg(dir, g, "apps/web", { dependencies: { "new-lib": "^1.0.0" } });
assert.deepEqual(checkLibraryDecisions(dir), []);
});
test("new devDependency → exit 0", () => {
const { dir, g } = makeRepo();
commitPkg(dir, g, "packages/feat-a", {});
stagePkg(dir, g, "packages/feat-a", {
devDependencies: { "test-lib": "^1.0.0" },
});
assert.deepEqual(checkLibraryDecisions(dir), []);
});
test("multi-file diff with mixed pass/fail → exit 1 with per-package report", () => {
const { dir, g } = makeRepo();
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
commitPkg(dir, g, "packages/feat-b", { dependencies: {} });
stagePkg(dir, g, "packages/feat-a", {
dependencies: { "lib-a": "^1.0.0" },
});
stagePkg(dir, g, "packages/feat-b", {
dependencies: { "lib-b": "^1.0.0" },
});
stageTrace(dir, g, "lib-a", "approved"); // feat-a passes; no trace for lib-b
const errs = checkLibraryDecisions(dir);
assert.equal(errs.length, 1);
assert.equal(errs[0].pkgJson, "packages/feat-b/package.json");
assert.equal(errs[0].dep, "lib-b");
assert.equal(errs[0].reason, "no-trace");
});
test("peerDependencies-only change → exit 0", () => {
const { dir, g } = makeRepo();
commitPkg(dir, g, "packages/feat-a", {});
stagePkg(dir, g, "packages/feat-a", {
peerDependencies: { react: "^18.0.0" },
});
assert.deepEqual(checkLibraryDecisions(dir), []);
});
test("new workspace-protocol dep (internal monorepo package) → exit 0", () => {
const { dir, g } = makeRepo();
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
stagePkg(dir, g, "packages/feat-a", {
dependencies: { "@repo/core-shared": "workspace:*" },
});
assert.deepEqual(checkLibraryDecisions(dir), []);
});
test("new feature-tier dep with already-committed approved trace → exit 0", () => {
const { dir, g } = makeRepo();
// Commit trace first (simulates an existing workspace-approved library)
stageTrace(dir, g, "existing-lib", "approved");
g("git commit -m add-trace");
// Now add a new package that depends on the same library
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
stagePkg(dir, g, "packages/feat-a", {
dependencies: { "existing-lib": "^1.0.0" },
});
// No staged trace needed — the committed trace is the fallback
assert.deepEqual(checkLibraryDecisions(dir), []);
});
test("new feature-tier dep with already-committed rejected trace → exit 1", () => {
const { dir, g } = makeRepo();
stageTrace(dir, g, "bad-lib", "rejected");
g("git commit -m add-trace");
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
stagePkg(dir, g, "packages/feat-a", {
dependencies: { "bad-lib": "^1.0.0" },
});
const errs = checkLibraryDecisions(dir);
assert.equal(errs.length, 1);
assert.equal(errs[0].dep, "bad-lib");
assert.equal(errs[0].reason, "not-approved");
});
test("--staged-against mode: new feature-tier dep without trace → exit 1", () => {
const { dir, g } = makeRepo();
// Baseline commit: feature package with no deps
commitPkg(dir, g, "packages/feat-a", { dependencies: {} });
// Second commit: adds new-lib — no trace file committed alongside it
commitPkg(dir, g, "packages/feat-a", {
dependencies: { "new-lib": "^1.0.0" },
});
// HEAD has new-lib; HEAD~1 doesn't — no trace in the diff → exit 1
const errs = checkLibraryDecisions(dir, { stagedAgainst: "HEAD~1" });
assert.equal(errs.length, 1);
assert.equal(errs[0].dep, "new-lib");
assert.equal(errs[0].reason, "no-trace");
});
});
// ---------------------------------------------------------------------------
// checkRenovatePr — integration tests
// ---------------------------------------------------------------------------
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "renprc-"));
}
function writeFixture(dir, relPath, content) {
const full = path.join(dir, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
function featurePkg(dir, name, deps) {
writeFixture(
dir,
`packages/${name}/package.json`,
JSON.stringify({ dependencies: deps }, null, 2),
);
}
function appPkg(dir, name, deps) {
writeFixture(
dir,
`apps/${name}/package.json`,
JSON.stringify({ dependencies: deps }, null, 2),
);
}
function traceFixture(dir, depName, lastRevalidated) {
const lr = lastRevalidated == null ? "null" : lastRevalidated;
const fm = `package: ${depName}
version: "^1.0.0"
tier: feature
decision: approved
date: 2026-05-14
lastRevalidated: ${lr}
deciders: [alice]
adr: null
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: ok
cve-scan: clean
named-consumer: pass
socketRisk: clean
verification-commands:
- pnpm audit`;
writeFixture(
dir,
`docs/library-decisions/2026-05-14-${depName}.md`,
`---\n${fm}\n---\n\n`,
);
}
function buildLockfileDiff(pkg, fromVer, toVer) {
return [
"--- a/pnpm-lock.yaml",
"+++ b/pnpm-lock.yaml",
"@@ -10,7 +10,7 @@ packages:",
`- ${pkg}@${fromVer}: {}`,
`+ ${pkg}@${toVer}: {}`,
"",
].join("\n");
}
describe("checkRenovatePr", () => {
test("minor bump on feature-tier dep → pass", () => {
const dir = makeTempDir();
featurePkg(dir, "feat-a", { "some-lib": "^1.0.0" });
const errs = checkRenovatePr(dir, {
branch: "renovate/some-lib-1.1.0",
diff: buildLockfileDiff("some-lib", "1.0.0", "1.1.0"),
today: "2026-05-14",
});
assert.deepEqual(errs, []);
});
test("major bump + fresh lastRevalidated → pass", () => {
const dir = makeTempDir();
featurePkg(dir, "feat-a", { "some-lib": "^2.0.0" });
traceFixture(dir, "some-lib", "2026-05-14");
const errs = checkRenovatePr(dir, {
branch: "renovate/some-lib-2.0.0",
diff: buildLockfileDiff("some-lib", "1.2.3", "2.0.0"),
today: "2026-05-14",
});
assert.deepEqual(errs, []);
});
test("major bump + stale lastRevalidated → fail with pointer", () => {
const dir = makeTempDir();
featurePkg(dir, "feat-a", { "some-lib": "^1.0.0" });
traceFixture(dir, "some-lib", "2026-01-01");
const errs = checkRenovatePr(dir, {
branch: "renovate/some-lib-2.0.0",
diff: buildLockfileDiff("some-lib", "1.2.3", "2.0.0"),
today: "2026-05-14",
});
assert.equal(errs.length, 1);
assert.equal(errs[0].dep, "some-lib");
assert.equal(errs[0].from, "1.2.3");
assert.equal(errs[0].to, "2.0.0");
assert.equal(errs[0].reason, "stale");
assert.equal(errs[0].lastRevalidated, "2026-01-01");
assert.ok(typeof errs[0].tracePath === "string");
assert.ok(errs[0].tracePath.includes("some-lib"));
});
test("major bump on app-tier dep → pass", () => {
const dir = makeTempDir();
// dep only in apps/, not in packages/ → app-tier exemption
appPkg(dir, "web", { "some-lib": "^1.0.0" });
const errs = checkRenovatePr(dir, {
branch: "renovate/some-lib-2.0.0",
diff: buildLockfileDiff("some-lib", "1.2.3", "2.0.0"),
today: "2026-05-14",
});
assert.deepEqual(errs, []);
});
test("patch bump in Renovate branch → pass", () => {
const dir = makeTempDir();
featurePkg(dir, "feat-a", { "some-lib": "^1.0.0" });
const errs = checkRenovatePr(dir, {
branch: "renovate/some-lib-1.2.4",
diff: buildLockfileDiff("some-lib", "1.2.3", "1.2.4"),
today: "2026-05-14",
});
assert.deepEqual(errs, []);
});
test("non-Renovate branch with major bump → pass", () => {
const dir = makeTempDir();
featurePkg(dir, "feat-a", { "some-lib": "^1.0.0" });
const errs = checkRenovatePr(dir, {
branch: "main",
diff: buildLockfileDiff("some-lib", "1.2.3", "2.0.0"),
today: "2026-05-14",
});
assert.deepEqual(errs, []);
});
});

View File

@@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* Weekly trace revalidation: re-runs verification-commands for every
* approved/pre-shipped trace, classifies soft/hard divergence, and manages
* GitHub issues via the gh CLI.
*
* Soft drift → rolling "library-policy/dashboard" issue (create or update)
* Hard drift → per-dep "library-policy/re-evaluation" issue (skip duplicates)
* Refreshed → close open re-evaluation issue when lastRevalidated set + clean
* Rejected → skipped entirely
*/
import { execSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseFrontmatter } from "./schema.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_REPO_ROOT = path.resolve(__dirname, "..", "..");
const LABEL_DASHBOARD = "library-policy/dashboard";
const LABEL_RE_EVAL = "library-policy/re-evaluation";
// Patterns in command output that signal hard divergence (evaluation would change)
const HARD_PATTERNS = [
/CVE-\d{4}-\d{4,}/i,
/\babandoned\b/i,
/\bhigh\s+severity\b/i,
/\bcritical\s+severity\b/i,
];
// Patterns in command output that signal soft divergence (minor drift)
const SOFT_PATTERNS = [
/\bdormant\b/i,
/\bwarning\b/i,
/\boutdated\b/i,
/\bdeprecated\b/i,
];
// ---- Trace discovery ----
function findAllTraceFiles(traceDir) {
const files = [];
if (!fs.existsSync(traceDir)) return files;
for (const entry of fs.readdirSync(traceDir)) {
if (entry.startsWith("_")) continue;
const fullPath = path.join(traceDir, entry);
const stat = fs.statSync(fullPath);
if (stat.isFile() && entry.endsWith(".md")) {
files.push(fullPath);
} else if (stat.isDirectory()) {
// Scoped packages: date-@scope/ directory containing name.md files
for (const sub of fs.readdirSync(fullPath)) {
if (sub.endsWith(".md") && !sub.startsWith("_")) {
files.push(path.join(fullPath, sub));
}
}
}
}
return files;
}
// ---- Command execution ----
function defaultCommandRunner(cmd, cwd) {
try {
const stdout = execSync(cmd, {
cwd,
encoding: "utf8",
timeout: 60_000,
stdio: ["ignore", "pipe", "pipe"],
});
return { exitCode: 0, output: stdout };
} catch (e) {
const out = (e.stdout ?? "") + (e.stderr ?? "");
return { exitCode: e.status ?? 1, output: out };
}
}
// ---- Classification ----
function classifyOutput(exitCode, output) {
if (exitCode !== 0) {
const snippet =
output.trim().slice(0, 300) || `command failed (exit ${exitCode})`;
return { kind: "hard", finding: snippet };
}
for (const re of HARD_PATTERNS) {
const m = output.match(re);
if (m) return { kind: "hard", finding: m[0] };
}
for (const re of SOFT_PATTERNS) {
const m = output.match(re);
if (m) return { kind: "soft", finding: m[0] };
}
return { kind: "none" };
}
function revalidateTrace(fm, commandRunner, repoRoot) {
const raw = fm["verification-commands"];
const cmds = Array.isArray(raw) ? raw : [];
let softFinding = null;
for (const cmd of cmds) {
const { exitCode, output } = commandRunner(cmd, repoRoot);
const result = classifyOutput(exitCode, output);
if (result.kind === "hard") {
return { status: "hard", finding: result.finding };
}
if (result.kind === "soft" && softFinding === null) {
softFinding = result.finding;
}
}
return softFinding !== null
? { status: "soft", finding: softFinding }
: { status: "ok", finding: null };
}
// ---- GitHub issue helpers ----
function defaultGhRunner(args) {
const result = spawnSync("gh", args, { encoding: "utf8" });
return { exitCode: result.status ?? 0, output: result.stdout ?? "" };
}
function listOpenIssues(label, ghRunner) {
const { output } = ghRunner([
"issue",
"list",
"--label",
label,
"--state",
"open",
"--json",
"number,title,body",
]);
try {
return JSON.parse(output || "[]");
} catch {
return [];
}
}
function createIssue(title, label, body, ghRunner) {
ghRunner([
"issue",
"create",
"--title",
title,
"--label",
label,
"--body",
body,
]);
}
function updateIssue(number, body, ghRunner) {
ghRunner(["issue", "edit", String(number), "--body", body]);
}
function closeIssue(number, comment, ghRunner) {
ghRunner(["issue", "close", String(number), "--comment", comment]);
}
// ---- Dashboard body ----
function buildDashboardBody(softResults, today) {
return [
`## Library trace soft drift — ${today}`,
"",
"The following traces have minor drift in their verification commands.",
"These discrepancies do not immediately require re-evaluation but should be reviewed.",
"",
"| Package | Finding |",
"| ------- | ------- |",
...softResults.map((r) => `| \`${r.pkg}@${r.version}\` | ${r.finding} |`),
"",
"To refresh a trace, run the `/evaluate-library` skill and update `lastRevalidated`.",
].join("\n");
}
// ---- Main export ----
/**
* Walk all approved/pre-shipped traces, re-run their verification-commands,
* classify divergence, and manage GitHub issues accordingly.
*
* Returns { hard: [...], soft: [...] } for inspection / testing.
*/
export function revalidate(repoRoot = DEFAULT_REPO_ROOT, options = {}) {
const {
commandRunner = defaultCommandRunner,
ghRunner = defaultGhRunner,
today = new Date().toISOString().slice(0, 10),
} = options;
const traceDir = path.join(repoRoot, "docs", "library-decisions");
const traceFiles = findAllTraceFiles(traceDir);
const hardResults = [];
const softResults = [];
const cleanResults = []; // status: ok
for (const tracePath of traceFiles) {
let fm;
try {
const content = fs.readFileSync(tracePath, "utf8");
fm = parseFrontmatter(content);
} catch {
continue;
}
if (fm.decision !== "approved" && fm.decision !== "pre-shipped") continue;
const { status, finding } = revalidateTrace(fm, commandRunner, repoRoot);
const entry = {
tracePath,
pkg: fm.package,
version: fm.version,
lastRevalidated: fm.lastRevalidated ?? null,
finding,
};
if (status === "hard") hardResults.push(entry);
else if (status === "soft") softResults.push(entry);
else cleanResults.push(entry);
}
// Phase 1: close stale re-evaluation issues for deps that have since been
// re-evaluated (lastRevalidated set) and currently show no hard drift.
const openRevalIssues = listOpenIssues(LABEL_RE_EVAL, ghRunner);
const closedNums = new Set();
for (const issue of openRevalIssues) {
const m = issue.title.match(/^re-evaluate:\s+(.+?)@/);
if (!m) continue;
const issuePkg = m[1].trim();
const cleanEntry = cleanResults.find(
(e) => e.pkg === issuePkg && e.lastRevalidated != null,
);
if (cleanEntry) {
closeIssue(
issue.number,
`Closing: \`${issuePkg}\` trace was revalidated on ${cleanEntry.lastRevalidated}. ` +
`No hard drift detected in latest run. Run \`/evaluate-library\` for a full re-walk if needed.`,
ghRunner,
);
closedNums.add(issue.number);
}
}
// Phase 2: open per-dep issues for hard drift, skipping duplicates.
for (const result of hardResults) {
const alreadyOpen = openRevalIssues.some(
(i) =>
!closedNums.has(i.number) &&
i.title.includes(`re-evaluate: ${result.pkg}@`),
);
if (alreadyOpen) continue;
const titleFinding = result.finding.split("\n")[0].slice(0, 80).trim();
const title = `re-evaluate: ${result.pkg}@${result.version}${titleFinding}`;
const body = [
`## Revalidation finding`,
"",
`**Package:** \`${result.pkg}@${result.version}\``,
`**Trace:** \`${path.relative(repoRoot, result.tracePath)}\``,
`**Finding:** ${result.finding}`,
"",
"## Next steps",
"",
"Run the `/evaluate-library` skill to re-walk the evaluation for this package:",
"```",
".claude/skills/evaluate-library/SKILL.md",
"```",
"",
`> Generated by the weekly trace revalidation workflow on ${today}.`,
].join("\n");
createIssue(title, LABEL_RE_EVAL, body, ghRunner);
}
// Phase 3: update rolling dashboard issue for soft drift.
if (softResults.length > 0) {
const dashboardBody = buildDashboardBody(softResults, today);
const openDashboard = listOpenIssues(LABEL_DASHBOARD, ghRunner);
if (openDashboard.length > 0) {
updateIssue(openDashboard[0].number, dashboardBody, ghRunner);
} else {
createIssue(
`Library trace drift dashboard — ${today}`,
LABEL_DASHBOARD,
dashboardBody,
ghRunner,
);
}
}
return { hard: hardResults, soft: softResults };
}
// ---- CLI entry point ----
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const result = revalidate();
if (result.hard.length === 0 && result.soft.length === 0) {
console.log("✓ All traces clean — no drift detected.");
process.exit(0);
}
if (result.hard.length > 0) {
console.log(
`\n✗ Hard drift detected for ${result.hard.length} package(s):`,
);
for (const r of result.hard) {
console.log(` ${r.pkg}@${r.version}: ${r.finding}`);
}
}
if (result.soft.length > 0) {
console.log(
`\n⚠ Soft drift detected for ${result.soft.length} package(s):`,
);
for (const r of result.soft) {
console.log(` ${r.pkg}@${r.version}: ${r.finding}`);
}
}
}

View File

@@ -0,0 +1,513 @@
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 { revalidate } from "./revalidate.mjs";
// ---- Fixture helpers ----
function makeTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "revalidate-"));
}
function writeTrace(dir, pkg, opts = {}) {
const {
decision = "approved",
lastRevalidated = null,
commands = ["echo ok"],
socketRisk = "clean",
} = opts;
const lr = lastRevalidated == null ? "null" : lastRevalidated;
const cmdLines = commands.map((c) => ` - ${c}`).join("\n");
const content = `---
package: ${pkg}
version: "^1.0.0"
tier: feature
decision: ${decision}
date: 2026-05-14
deciders: [alice]
adr: null
lastRevalidated: ${lr}
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: ok
cve-scan: clean
named-consumer: pass
socketRisk: ${socketRisk}
verification-commands:
${cmdLines}
---
## Body
`;
const traceDir = path.join(dir, "docs", "library-decisions");
fs.mkdirSync(traceDir, { recursive: true });
fs.writeFileSync(path.join(traceDir, `2026-05-14-${pkg}.md`), content);
}
/**
* Build a mock command runner that maps exact command strings to results.
* Unrecognised commands return { exitCode: 0, output: "" } by default.
*/
function makeCommandMock(responses = {}) {
const calls = [];
function commandRunner(cmd) {
calls.push(cmd);
const r = responses[cmd];
return r ?? { exitCode: 0, output: "" };
}
return { commandRunner, calls };
}
/**
* Build a mock gh CLI runner. Accepts an initial set of open issues keyed by
* label. Tracks all calls; `gh issue create` appends to the label bucket.
*/
function makeGhMock(initialIssuesByLabel = {}) {
const calls = [];
const issuesByLabel = JSON.parse(JSON.stringify(initialIssuesByLabel));
let nextNumber = 1000;
function ghRunner(args) {
calls.push([...args]);
if (args[0] === "issue" && args[1] === "list") {
const labelIdx = args.indexOf("--label");
const label = labelIdx >= 0 ? args[labelIdx + 1] : "";
return {
exitCode: 0,
output: JSON.stringify(issuesByLabel[label] ?? []),
};
}
if (args[0] === "issue" && args[1] === "create") {
const titleIdx = args.indexOf("--title");
const labelIdx = args.indexOf("--label");
const title = titleIdx >= 0 ? args[titleIdx + 1] : "Untitled";
const label = labelIdx >= 0 ? args[labelIdx + 1] : "";
if (label) {
issuesByLabel[label] = issuesByLabel[label] ?? [];
issuesByLabel[label].push({ number: ++nextNumber, title, body: "" });
}
return { exitCode: 0, output: "" };
}
if (args[0] === "issue" && args[1] === "edit") {
return { exitCode: 0, output: "" };
}
if (args[0] === "issue" && args[1] === "close") {
const num = parseInt(args[2], 10);
for (const label of Object.keys(issuesByLabel)) {
issuesByLabel[label] = issuesByLabel[label].filter(
(i) => i.number !== num,
);
}
return { exitCode: 0, output: "" };
}
return { exitCode: 0, output: "" };
}
return { ghRunner, calls, issuesByLabel };
}
function createCalls(calls) {
return calls.filter((a) => a[0] === "issue" && a[1] === "create");
}
function closeCalls(calls) {
return calls.filter((a) => a[0] === "issue" && a[1] === "close");
}
// ---- Tests ----
describe("revalidate", () => {
test("no-drift trace → no issue created or closed", () => {
const dir = makeTmpDir();
writeTrace(dir, "clean-lib", {
commands: ["echo all-good"],
});
const { commandRunner } = makeCommandMock({
"echo all-good": { exitCode: 0, output: "all-good" },
});
const { ghRunner, calls } = makeGhMock();
const result = revalidate(dir, {
commandRunner,
ghRunner,
today: "2026-05-14",
});
assert.deepEqual(result.hard, []);
assert.deepEqual(result.soft, []);
assert.equal(createCalls(calls).length, 0);
assert.equal(closeCalls(calls).length, 0);
});
test("soft-drift trace → dashboard issue created", () => {
const dir = makeTmpDir();
writeTrace(dir, "drifting-lib", {
commands: ["npm view drifting-lib version"],
});
const { commandRunner } = makeCommandMock({
"npm view drifting-lib version": {
exitCode: 0,
output: "package is dormant — no recent releases",
},
});
const { ghRunner, calls } = makeGhMock();
const result = revalidate(dir, {
commandRunner,
ghRunner,
today: "2026-05-14",
});
assert.equal(result.soft.length, 1);
assert.equal(result.soft[0].pkg, "drifting-lib");
const created = createCalls(calls);
assert.equal(created.length, 1);
const titleIdx = created[0].indexOf("--title");
const labelIdx = created[0].indexOf("--label");
const title = created[0][titleIdx + 1];
const label = created[0][labelIdx + 1];
assert.ok(
title.startsWith("Library trace drift dashboard"),
`title: ${title}`,
);
assert.equal(label, "library-policy/dashboard");
});
test("soft-drift with existing dashboard issue → issue updated, not duplicated", () => {
const dir = makeTmpDir();
writeTrace(dir, "drifting-lib", {
commands: ["echo outdated package"],
});
const { commandRunner } = makeCommandMock({
"echo outdated package": {
exitCode: 0,
output: "outdated package detected",
},
});
const existingIssue = {
number: 55,
title: "Library trace drift dashboard — 2026-05-07",
body: "",
};
const { ghRunner, calls } = makeGhMock({
"library-policy/dashboard": [existingIssue],
});
revalidate(dir, { commandRunner, ghRunner, today: "2026-05-14" });
assert.equal(
createCalls(calls).length,
0,
"should not create a new dashboard issue",
);
const editCalls = calls.filter((a) => a[0] === "issue" && a[1] === "edit");
assert.equal(editCalls.length, 1);
assert.equal(editCalls[0][2], "55");
});
test("hard-drift trace → per-dep re-evaluation issue created with correct labels and title", () => {
const dir = makeTmpDir();
writeTrace(dir, "risky-lib", {
commands: ["pnpm audit --audit-level=moderate"],
});
const { commandRunner } = makeCommandMock({
"pnpm audit --audit-level=moderate": {
exitCode: 1,
output: "high severity vulnerability in risky-lib",
},
});
const { ghRunner, calls } = makeGhMock();
const result = revalidate(dir, {
commandRunner,
ghRunner,
today: "2026-05-14",
});
assert.equal(result.hard.length, 1);
assert.equal(result.hard[0].pkg, "risky-lib");
const created = createCalls(calls);
assert.equal(created.length, 1);
const titleIdx = created[0].indexOf("--title");
const labelIdx = created[0].indexOf("--label");
const title = created[0][titleIdx + 1];
const label = created[0][labelIdx + 1];
assert.ok(
title.startsWith("re-evaluate: risky-lib@"),
`title should start with "re-evaluate: risky-lib@", got: ${title}`,
);
assert.ok(
title.includes(" — "),
`title should contain em-dash separator, got: ${title}`,
);
assert.equal(label, "library-policy/re-evaluation");
});
test("hard-drift with CVE in output → issue title includes CVE reference", () => {
const dir = makeTmpDir();
writeTrace(dir, "cve-lib", {
commands: ["socket scan cve-lib"],
});
const { commandRunner } = makeCommandMock({
"socket scan cve-lib": {
exitCode: 0,
output: "CVE-2024-12345 found in transitive dependency",
},
});
const { ghRunner, calls } = makeGhMock();
const result = revalidate(dir, {
commandRunner,
ghRunner,
today: "2026-05-14",
});
assert.equal(result.hard.length, 1);
const created = createCalls(calls);
assert.equal(created.length, 1);
const titleIdx = created[0].indexOf("--title");
assert.ok(
created[0][titleIdx + 1].includes("CVE-2024-12345"),
`title should reference the CVE`,
);
});
test("duplicate-issue guard → no second issue opened when open issue already exists", () => {
const dir = makeTmpDir();
writeTrace(dir, "already-flagged", {
commands: ["pnpm audit"],
});
const { commandRunner } = makeCommandMock({
"pnpm audit": {
exitCode: 1,
output: "critical severity vulnerability",
},
});
const existingIssue = {
number: 77,
title: "re-evaluate: already-flagged@^1.0.0 — previous finding",
body: "",
};
const { ghRunner, calls } = makeGhMock({
"library-policy/re-evaluation": [existingIssue],
});
revalidate(dir, { commandRunner, ghRunner, today: "2026-05-14" });
assert.equal(
createCalls(calls).length,
0,
"should not open a duplicate re-evaluation issue",
);
});
test("stale-issue close on refreshed lastRevalidated → open issue closed with comment", () => {
const dir = makeTmpDir();
writeTrace(dir, "refreshed-lib", {
lastRevalidated: "2026-05-14",
commands: ["npm view refreshed-lib license"],
});
const { commandRunner } = makeCommandMock({
"npm view refreshed-lib license": { exitCode: 0, output: "MIT" },
});
const openIssue = {
number: 42,
title: "re-evaluate: refreshed-lib@^1.0.0 — old finding from last week",
body: "",
};
const { ghRunner, calls } = makeGhMock({
"library-policy/re-evaluation": [openIssue],
});
revalidate(dir, { commandRunner, ghRunner, today: "2026-05-14" });
const closed = closeCalls(calls);
assert.equal(closed.length, 1, "should close the stale issue");
assert.equal(closed[0][2], "42", "should close issue number 42");
const commentIdx = closed[0].indexOf("--comment");
assert.ok(commentIdx >= 0, "close call should include --comment flag");
assert.ok(
closed[0][commentIdx + 1].includes("2026-05-14"),
"comment should reference the revalidation date",
);
});
test("clean trace with null lastRevalidated does not close open issue", () => {
const dir = makeTmpDir();
writeTrace(dir, "unrevalidated-lib", {
lastRevalidated: null,
commands: ["echo ok"],
});
const { commandRunner } = makeCommandMock({
"echo ok": { exitCode: 0, output: "ok" },
});
const openIssue = {
number: 33,
title: "re-evaluate: unrevalidated-lib@^1.0.0 — earlier finding",
body: "",
};
const { ghRunner, calls } = makeGhMock({
"library-policy/re-evaluation": [openIssue],
});
revalidate(dir, { commandRunner, ghRunner, today: "2026-05-14" });
assert.equal(
closeCalls(calls).length,
0,
"should not close issue when lastRevalidated is null",
);
});
test("rejected-trace skip → no commands run, no issues created", () => {
const dir = makeTmpDir();
writeTrace(dir, "rejected-lib", {
decision: "rejected",
commands: ["pnpm audit"],
});
let commandsCalled = 0;
const commandRunner = () => {
commandsCalled++;
return { exitCode: 0, output: "" };
};
const { ghRunner, calls } = makeGhMock();
const result = revalidate(dir, {
commandRunner,
ghRunner,
today: "2026-05-14",
});
assert.equal(
commandsCalled,
0,
"should not run commands for rejected traces",
);
assert.equal(createCalls(calls).length, 0, "should not create any issues");
assert.deepEqual(result.hard, []);
assert.deepEqual(result.soft, []);
});
test("pre-shipped trace is processed like approved", () => {
const dir = makeTmpDir();
writeTrace(dir, "preshipped-lib", {
decision: "pre-shipped",
commands: ["echo clean"],
});
const { commandRunner, calls: cmdCalls } = makeCommandMock({
"echo clean": { exitCode: 0, output: "clean" },
});
const { ghRunner } = makeGhMock();
const result = revalidate(dir, {
commandRunner,
ghRunner,
today: "2026-05-14",
});
assert.equal(
cmdCalls.length,
1,
"should run commands for pre-shipped traces",
);
assert.deepEqual(result.hard, []);
assert.deepEqual(result.soft, []);
});
test("multiple traces: independent classification per trace", () => {
const dir = makeTmpDir();
writeTrace(dir, "clean-pkg", { commands: ["echo ok"] });
writeTrace(dir, "soft-pkg", { commands: ["echo package is deprecated"] });
writeTrace(dir, "hard-pkg", { commands: ["pnpm audit"] });
const { commandRunner } = makeCommandMock({
"echo ok": { exitCode: 0, output: "ok" },
"echo package is deprecated": {
exitCode: 0,
output: "package is deprecated",
},
"pnpm audit": { exitCode: 1, output: "vulnerability found" },
});
const { ghRunner, calls } = makeGhMock();
const result = revalidate(dir, {
commandRunner,
ghRunner,
today: "2026-05-14",
});
assert.equal(result.hard.length, 1);
assert.equal(result.hard[0].pkg, "hard-pkg");
assert.equal(result.soft.length, 1);
assert.equal(result.soft[0].pkg, "soft-pkg");
// one re-eval issue for hard, one dashboard issue for soft
const created = createCalls(calls);
assert.equal(created.length, 2);
const labels = created.map((c) => {
const idx = c.indexOf("--label");
return c[idx + 1];
});
assert.ok(labels.includes("library-policy/re-evaluation"));
assert.ok(labels.includes("library-policy/dashboard"));
});
test("trace with empty verification-commands → treated as ok (no drift)", () => {
const dir = makeTmpDir();
// writeTrace with commands:[] produces an empty block sequence which
// parseFrontmatter returns as {} (object, not array). revalidate.mjs
// must handle this gracefully.
writeTrace(dir, "no-cmds-lib", { commands: [] });
const { commandRunner, calls: cmdCalls } = makeCommandMock();
const { ghRunner, calls: ghCalls } = makeGhMock();
const result = revalidate(dir, {
commandRunner,
ghRunner,
today: "2026-05-14",
});
assert.equal(
cmdCalls.length,
0,
"no commands should run for empty commands list",
);
assert.deepEqual(result.hard, []);
assert.deepEqual(result.soft, []);
assert.equal(createCalls(ghCalls).length, 0);
});
});

View File

@@ -0,0 +1,145 @@
// scripts/library-decisions/schema.mjs
// Zod-validated schema for library decision trace files.
// Shared by: evaluate-library skill, pre-commit check, sandcastle reviewer.
import { z } from "zod";
import fs from "node:fs";
// ---- Zod schema ----
const filterResultsSchema = z
.object({
license: z.string().min(1),
types: z.string().min(1),
maintenance: z.enum(["active", "dormant", "abandoned"]),
"boundary-fit": z.enum(["pass", "fail"]),
"shadow-check": z.string().min(1),
"eu-residency": z.enum(["ok", "n/a", "self-hostable", "fail"]),
"cve-scan": z.string().min(1),
"named-consumer": z.enum(["pass", "fail"]),
socketRisk: z.union([z.literal("clean"), z.literal("flagged"), z.string()]),
})
.strict();
export const traceSchema = z
.object({
package: z.string().min(1),
version: z.string().min(1),
tier: z.enum(["app", "feature", "core"]),
decision: z.enum(["approved", "rejected"]),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
deciders: z.array(z.string()),
adr: z.string().nullable(),
lastRevalidated: z.string().nullable(),
"filter-results": filterResultsSchema,
"verification-commands": z.array(z.string()),
"accepted-cves": z.array(z.string()).optional(),
})
.strict();
// ---- Helpers ----
/** Strip surrounding single- or double-quotes from a YAML scalar. */
function unquote(s) {
if (
(s.startsWith('"') && s.endsWith('"')) ||
(s.startsWith("'") && s.endsWith("'"))
) {
return s.slice(1, -1);
}
return s;
}
/** Parse a YAML inline flow array: `[a, "b", c]` → `["a", "b", "c"]`. */
function parseInlineArray(rawValue) {
const inner = rawValue.slice(1, -1).trim();
return inner === "" ? [] : inner.split(",").map((s) => unquote(s.trim()));
}
/**
* Consume indented children (nested object or block-sequence array) starting
* at `startIdx` in `lines`. Returns the parsed value and the index of the
* first non-indented line (i.e. where the parent should resume scanning).
*/
function parseNestedBlock(lines, startIdx) {
const nested = {};
const arr = [];
let i = startIdx;
while (i < lines.length && /^ {2}/.test(lines[i])) {
const child = lines[i];
const itemMatch = child.match(/^ {2}- (.+)$/);
if (itemMatch) {
arr.push(unquote(itemMatch[1].trim()));
} else {
const nestedMatch = child.match(/^ {2}([\w-]+):\s*(.*)$/);
if (nestedMatch) {
nested[nestedMatch[1]] = unquote(nestedMatch[2].trim());
}
}
i++;
}
return { value: arr.length > 0 ? arr : nested, nextIdx: i };
}
/**
* Parse the YAML frontmatter of a markdown file into a plain JS object.
* Handles the trace format: scalar values, inline flow arrays, one-level
* nested objects, and block-sequence arrays.
*/
export function parseFrontmatter(text) {
const match = text.match(/^---\n([\s\S]+?)\n---/);
if (!match) throw new Error("No YAML frontmatter found");
const lines = match[1].split("\n");
const result = {};
let i = 0;
while (i < lines.length) {
const line = lines[i];
const topMatch = line.match(/^([\w-]+):\s*(.*)$/);
if (!topMatch) {
i++;
continue;
}
const key = topMatch[1];
const rawValue = topMatch[2].trim();
if (rawValue === "") {
const { value, nextIdx } = parseNestedBlock(lines, i + 1);
result[key] = value;
i = nextIdx;
} else if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
result[key] = parseInlineArray(rawValue);
i++;
} else {
const v = unquote(rawValue);
result[key] = v === "null" ? null : v;
i++;
}
}
return result;
}
// ---- Public API ----
/**
* Validate an already-parsed frontmatter object against the trace schema.
* Throws ZodError on failure; returns the parsed (typed) value on success.
*/
export function validateTrace(raw) {
return traceSchema.parse(raw);
}
/**
* Read a trace `.md` file, parse its frontmatter, and validate it.
* Throws on missing frontmatter or schema violations.
*/
export function parseTrace(filePath) {
const text = fs.readFileSync(filePath, "utf8");
const raw = parseFrontmatter(text);
return validateTrace(raw);
}

View File

@@ -0,0 +1,220 @@
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 { validateTrace, parseTrace, parseFrontmatter } from "./schema.mjs";
function validRaw(overrides = {}) {
return {
package: "example-lib",
version: "^1.0.0",
tier: "feature",
decision: "approved",
date: "2026-05-14",
deciders: ["alice"],
adr: null,
lastRevalidated: null,
"filter-results": {
license: "MIT",
types: "native",
maintenance: "active",
"boundary-fit": "pass",
"shadow-check": "pass",
"eu-residency": "ok",
"cve-scan": "clean",
"named-consumer": "pass",
socketRisk: "clean",
},
"verification-commands": ["pnpm audit --audit-level=moderate"],
...overrides,
};
}
function writeTempTrace(frontmatter, body = "") {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "trace-"));
const file = path.join(dir, "trace.md");
fs.writeFileSync(file, `---\n${frontmatter}\n---\n\n${body}`);
return file;
}
const VALID_FM = `package: example-lib
version: "^1.0.0"
tier: feature
decision: approved
date: 2026-05-14
deciders: [alice, bob]
adr: null
lastRevalidated: null
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: ok
cve-scan: clean
named-consumer: pass
socketRisk: clean
verification-commands:
- pnpm audit --audit-level=moderate`;
describe("validateTrace > valid cases", () => {
test("valid trace round-trips without error", () => {
const result = validateTrace(validRaw());
assert.equal(result.package, "example-lib");
assert.equal(result["filter-results"].license, "MIT");
assert.deepEqual(result["verification-commands"], [
"pnpm audit --audit-level=moderate",
]);
});
test("accepted-cves absent is valid", () => {
const result = validateTrace(validRaw());
assert.equal(result["accepted-cves"], undefined);
});
test("accepted-cves present is valid", () => {
const result = validateTrace(
validRaw({ "accepted-cves": ["CVE-2024-0001"] }),
);
assert.deepEqual(result["accepted-cves"], ["CVE-2024-0001"]);
});
test("null adr is valid", () => {
assert.equal(validateTrace(validRaw({ adr: null })).adr, null);
});
test("string adr is valid", () => {
assert.equal(validateTrace(validRaw({ adr: "adr-022" })).adr, "adr-022");
});
});
describe("validateTrace > rejection cases", () => {
test("missing required field throws", () => {
const raw = validRaw();
delete raw.package;
assert.throws(() => validateTrace(raw), /invalid_type|Required/i);
});
test("invalid tier enum throws", () => {
assert.throws(
() => validateTrace(validRaw({ tier: "invalid" })),
/invalid_enum_value|Invalid enum value/i,
);
});
test("invalid maintenance enum throws", () => {
const raw = validRaw({
"filter-results": {
...validRaw()["filter-results"],
maintenance: "stale",
},
});
assert.throws(
() => validateTrace(raw),
/invalid_enum_value|Invalid enum value/i,
);
});
test("unknown key in filter-results rejected by strict schema", () => {
const raw = validRaw({
"filter-results": {
...validRaw()["filter-results"],
"unknown-filter": "x",
},
});
assert.throws(
() => validateTrace(raw),
/unrecognized_keys|Unrecognized key/i,
);
});
test("missing socketRisk in filter-results fails validation", () => {
const raw = validRaw();
delete raw["filter-results"].socketRisk;
assert.throws(() => validateTrace(raw), /invalid_type|Required/i);
});
});
describe("validateTrace > socketRisk", () => {
test('socketRisk "clean" round-trips', () => {
const result = validateTrace(validRaw());
assert.equal(result["filter-results"].socketRisk, "clean");
});
test('socketRisk "flagged" round-trips', () => {
const raw = validRaw({
"filter-results": {
...validRaw()["filter-results"],
socketRisk: "flagged",
},
});
assert.equal(validateTrace(raw)["filter-results"].socketRisk, "flagged");
});
test("socketRisk arbitrary string round-trips", () => {
const raw = validRaw({
"filter-results": {
...validRaw()["filter-results"],
socketRisk: "obfuscated-code",
},
});
assert.equal(
validateTrace(raw)["filter-results"].socketRisk,
"obfuscated-code",
);
});
});
describe("validateTrace > lastRevalidated", () => {
test("lastRevalidated null is valid", () => {
assert.equal(
validateTrace(validRaw({ lastRevalidated: null })).lastRevalidated,
null,
);
});
test("lastRevalidated ISO date string is valid", () => {
assert.equal(
validateTrace(validRaw({ lastRevalidated: "2026-05-14" }))
.lastRevalidated,
"2026-05-14",
);
});
});
describe("parseFrontmatter", () => {
test("parses scalar, inline array, nested object, block array", () => {
const text = `---\n${VALID_FM}\n---\n\n## Filter: license`;
const raw = parseFrontmatter(text);
assert.equal(raw.package, "example-lib");
assert.equal(raw.version, "^1.0.0");
assert.deepEqual(raw.deciders, ["alice", "bob"]);
assert.equal(raw.adr, null);
assert.equal(raw["filter-results"].license, "MIT");
assert.deepEqual(raw["verification-commands"], [
"pnpm audit --audit-level=moderate",
]);
});
test("throws when no frontmatter delimiters found", () => {
assert.throws(
() => parseFrontmatter("# No frontmatter"),
/No YAML frontmatter/,
);
});
});
describe("parseTrace", () => {
test("reads and validates a valid trace file", () => {
const file = writeTempTrace(VALID_FM, "## Filter: license\n\nok");
assert.equal(parseTrace(file).package, "example-lib");
});
test("throws on missing required field in file", () => {
const fm = VALID_FM.replace(/^package: example-lib\n/m, "");
const file = writeTempTrace(fm, "");
assert.throws(() => parseTrace(file), /invalid_type|Required/i);
});
});

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Stamp every staged `docs/work/**\/*.md` file's frontmatter `updated:` field
* to the current ISO 8601 timestamp. Runs from `.husky/pre-commit` before
* `pnpm work rebuild-state`, so `_state.json` sees the fresh value.
*
* Idempotent: re-running produces the same result. The script silently no-ops
* on files without frontmatter or without an `updated:` line to replace —
* adds the field after `created:` if missing.
*
* Only stamps files explicitly listed in the staged diff; never walks the
* tree. That way the timestamp tracks "the last commit that actually
* modified the file," not "the last commit period."
*/
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const REPO_ROOT = execSync("git rev-parse --show-toplevel", {
encoding: "utf8",
}).trim();
function stagedWorkDocs() {
const out = execSync("git diff --cached --name-only --diff-filter=ACMR", {
cwd: REPO_ROOT,
encoding: "utf8",
});
return out
.split("\n")
.map((p) => p.trim())
.filter(
(p) =>
p.startsWith("docs/work/") &&
!p.startsWith("docs/work/archive/") &&
p.endsWith(".md") &&
!p.endsWith("README.md"),
);
}
function stampUpdated(content, isoNow) {
const fmMatch = content.match(/^(---\n)([\s\S]+?)(\n---)/);
if (!fmMatch) return content;
const [full, openDelim, body, closeDelim] = fmMatch;
let newBody;
if (/^updated:\s*/m.test(body)) {
newBody = body.replace(/^updated:\s*.*$/m, `updated: ${isoNow}`);
} else if (/^created:\s*/m.test(body)) {
// Insert `updated:` immediately after the `created:` line.
newBody = body.replace(/^(created:\s*.*)$/m, `$1\nupdated: ${isoNow}`);
} else {
return content;
}
return content.replace(full, `${openDelim}${newBody}${closeDelim}`);
}
function main() {
const files = stagedWorkDocs();
if (files.length === 0) return;
const isoNow = new Date().toISOString();
let touched = 0;
for (const rel of files) {
const abs = path.join(REPO_ROOT, rel);
if (!fs.existsSync(abs)) continue;
const original = fs.readFileSync(abs, "utf8");
const next = stampUpdated(original, isoNow);
if (next === original) continue;
fs.writeFileSync(abs, next);
execSync(`git add ${JSON.stringify(rel)}`, { cwd: REPO_ROOT });
touched++;
}
if (touched > 0) {
console.log(
`bump-updated-timestamps: stamped ${touched} file(s) at ${isoNow}`,
);
}
}
main();

180
scripts/work/cli.mjs Normal file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* pnpm work — CLI for the local work-system. Routes subcommands to their
* respective modules (state-builder, dispatch, decompose, prd-ship). Each
* subcommand module also exposes a `runCli(args)` entry point that this
* file calls directly; the sibling modules NEVER run as a side effect of
* being imported.
*
* Subcommands:
* rebuild-state Rewrites docs/work/_system/_state.json from the current markdown
* status Prints a tree of all epics + their stories
* next Prints the first ready story (or "All done" / "Blocked: ...")
* ready Prints every ready story
* blocked Prints every blocked story + what each is waiting on
* dispatch Print the next dispatch plan; with --execute invokes
* sandcastle to run the implementer + reviewer pair.
* --execute LOOPS through every ready task by default;
* bound with --once or --max-tasks N. After each
* approved slice the orchestrator ticks the bullet,
* flips story/epic status if complete, and commits
* the state mutation as `chore(work): ...` on top of
* the implementer's slice commit.
* decompose <id> Validate an approved PRD + print the decompose plan;
* with --execute invokes sandcastle's decomposer agent
* to write the epic folder + per-story files
* prd-ship <id> Flip a PRD's status to `shipped` (run after its
* seed epic completes); --commits / --auto-commits
* optional; idempotent on already-shipped
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildState } from "./state-builder.mjs";
import { runCli as runPrdShip } from "./prd-ship.mjs";
import { runCli as runDecompose } from "./decompose.mjs";
import { runCli as runDispatch } from "./dispatch.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const WORK_ROOT = path.join(REPO_ROOT, "docs", "work");
const SYSTEM_DIR = path.join(WORK_ROOT, "_system");
const STATE_FILE = path.join(SYSTEM_DIR, "_state.json");
function rebuildState() {
const state = buildState(WORK_ROOT);
if (!fs.existsSync(SYSTEM_DIR)) fs.mkdirSync(SYSTEM_DIR, { recursive: true });
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n");
console.log(
`Rebuilt ${path.relative(REPO_ROOT, STATE_FILE)} with ${Object.keys(state.epics).length} epic(s).`,
);
}
function printStatus() {
const state = buildState(WORK_ROOT);
const epicIds = Object.keys(state.epics).sort();
if (epicIds.length === 0) {
console.log("No epics found under docs/work/.");
return;
}
for (const epicId of epicIds) {
const epic = state.epics[epicId];
const storyIds = Object.keys(epic.stories).sort();
const storyTotals = storyIds.reduce(
(acc, sid) => ({
total: acc.total + epic.stories[sid].ac_total,
done: acc.done + epic.stories[sid].ac_completed,
}),
{ total: 0, done: 0 },
);
const epicMark = mark(epic.status);
console.log(
`${epicMark} ${epicId}${epic.title} (${storyTotals.done}/${storyTotals.total} tasks done)`,
);
for (const sid of storyIds) {
const s = epic.stories[sid];
console.log(
` ${mark(s.status)} ${sid} (${s.ac_completed}/${s.ac_total}) — ${s.title}`,
);
}
}
}
function printNext() {
const state = buildState(WORK_ROOT);
if (state.ready.length === 0) {
if (state.blocked.length > 0) {
console.log("No ready stories. Blocked:");
for (const b of state.blocked) {
console.log(
` ${b.epic} / ${b.story} — waiting on: ${b.waiting_on.join(", ")}`,
);
}
} else {
console.log("All epics + stories are done. ✓");
}
return;
}
const r = state.ready[0];
console.log(`${r.epic} / ${r.story}${r.title}`);
console.log(` (use \`pnpm work ready\` to see all ready stories)`);
}
function printReady() {
const state = buildState(WORK_ROOT);
if (state.ready.length === 0) {
console.log(
"No ready stories. Run `pnpm work blocked` to see what's waiting on what.",
);
return;
}
console.log(
`${state.ready.length} ready stor${state.ready.length === 1 ? "y" : "ies"}:`,
);
for (const r of state.ready) {
console.log(` ${r.epic} / ${r.story}${r.title}`);
}
}
function printBlocked() {
const state = buildState(WORK_ROOT);
if (state.blocked.length === 0) {
console.log("No blocked stories.");
return;
}
console.log(
`${state.blocked.length} blocked stor${state.blocked.length === 1 ? "y" : "ies"}:`,
);
for (const b of state.blocked) {
console.log(` ${b.epic} / ${b.story}${b.title}`);
console.log(` waiting on: ${b.waiting_on.join(", ")}`);
}
}
function mark(status) {
if (status === "done") return "✓";
if (status === "in-progress") return "→";
if (status === "blocked") return "✗";
return "○";
}
function usage() {
console.log(
"Usage: pnpm work <rebuild-state|status|next|ready|blocked|dispatch|decompose|prd-ship>",
);
console.log(
" dispatch Print the next dispatch plan (use --execute to invoke sandcastle; loops by default — bound with --once / --max-tasks N)",
);
console.log(
" decompose <prd-id> Decompose an approved PRD into epic + stories (use --execute to invoke sandcastle)",
);
console.log(
" prd-ship <id> Flip a PRD's status to `shipped` (run after its epic completes)",
);
process.exit(2);
}
const cmd = process.argv[2];
if (cmd === "rebuild-state") rebuildState();
else if (cmd === "status") printStatus();
else if (cmd === "next") printNext();
else if (cmd === "ready") printReady();
else if (cmd === "blocked") printBlocked();
else if (cmd === "dispatch") {
// dispatch.mjs handles its own --execute flag
runDispatch(process.argv.slice(3)).catch((e) => {
console.error(e);
process.exit(1);
});
} else if (cmd === "prd-ship") {
const exitCode = runPrdShip(process.argv.slice(3), {
repoRoot: REPO_ROOT,
workRoot: WORK_ROOT,
});
process.exit(exitCode);
} else if (cmd === "decompose") {
runDecompose(process.argv.slice(3), {
repoRoot: REPO_ROOT,
workRoot: WORK_ROOT,
}).then((code) => process.exit(code));
} else usage();

59
scripts/work/cli.test.mjs Normal file
View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { execSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLI = path.join(__dirname, "cli.mjs");
function run(args) {
try {
return execSync(`node "${CLI}" ${args}`, { encoding: "utf8" });
} catch (e) {
return e.stdout + e.stderr;
}
}
describe("pnpm work cli", () => {
it("prints usage when no subcommand is given", () => {
const out = run("");
expect(out).toContain("Usage:");
expect(out).toContain("rebuild-state");
expect(out).toContain("status");
expect(out).toContain("next");
expect(out).toContain("ready");
expect(out).toContain("blocked");
});
it("rebuild-state writes _state.json", () => {
const out = run("rebuild-state");
expect(out).toContain("Rebuilt");
expect(out).toContain("epic");
});
it("status prints a tree", () => {
const out = run("status");
// We expect at least one epic-line marker character
expect(out).toMatch(/[✓→○]/);
});
it("next prints the next non-done story OR confirms all done", () => {
const out = run("next");
expect(out.length).toBeGreaterThan(0);
});
it("ready prints something", () => {
const out = run("ready");
expect(out.length).toBeGreaterThan(0);
});
it("blocked prints something", () => {
const out = run("blocked");
expect(out.length).toBeGreaterThan(0);
});
it("dispatch prints a plan", () => {
const out = run("dispatch");
expect(out).toContain("Dispatch plan");
});
});

265
scripts/work/decompose.mjs Normal file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env node
/**
* scripts/work/decompose.mjs
*
* Decomposer dispatcher — takes an approved PRD and invokes the decomposer
* agent to write the epic folder + per-requirement story files under
* docs/work/epics/<epic-slug>/.
*
* Default mode (no --execute): print the dispatch plan + validate the PRD
* (refuses to proceed on draft / in-review / shipped). Safe anywhere.
*
* --execute mode: requires @ai-hero/sandcastle + auth (Claude subscription
* via ~/.claude OR ANTHROPIC_API_KEY). Mirrors `pnpm work dispatch --execute`.
*
* Usage:
* pnpm work decompose <prd-id>
* pnpm work decompose <prd-id> --execute
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { findPrdPath, parseFrontmatter } from "./prd-ship.mjs";
import { resolveClaudeAuth } from "./dispatch.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const WORK_ROOT = path.join(REPO_ROOT, "docs", "work");
const SANDCASTLE_DIR = path.join(REPO_ROOT, ".sandcastle");
/**
* Validate that a PRD is in a decomposable state. Throws on draft (must go
* through human review), in-review (review not yet complete), shipped (epic
* already exists), or missing.
*
* Returns the parsed frontmatter + body for the caller to pass into the
* decomposer.
*/
export function validatePrdForDecompose(prdPath) {
if (!fs.existsSync(prdPath)) {
throw new Error(`PRD file not found: ${prdPath}`);
}
const text = fs.readFileSync(prdPath, "utf8");
const { frontmatter } = parseFrontmatter(text);
const status = frontmatter.status;
if (status === "draft") {
throw new Error(
`PRD status is "draft" — human review required before decomposing. ` +
`Flip status to "approved" in the PRD frontmatter, then re-run.`,
);
}
if (status === "in-review") {
throw new Error(
`PRD status is "in-review" — review must complete (status -> approved) before decomposing.`,
);
}
if (status === "shipped") {
throw new Error(
`PRD status is "shipped" — its epic should already exist under docs/work/. ` +
`Re-decomposing a shipped PRD is not supported.`,
);
}
if (status !== "approved") {
throw new Error(
`Unexpected PRD status "${status}" — expected "approved" to decompose.`,
);
}
return { frontmatter, text };
}
/**
* Print what would happen on --execute. Validation runs in both modes; this
* is the "preview" companion of executeDecompose().
*/
export function printDecomposePlan(prdId, prdPath, frontmatter) {
console.log("=== Decompose plan ===");
console.log(` PRD: ${path.relative(REPO_ROOT, prdPath)}`);
console.log(` Id: ${prdId}`);
console.log(` Title: ${frontmatter.title ?? "(no title)"}`);
console.log(` Status: ${frontmatter.status} (eligible to decompose)`);
console.log();
console.log(` Decomposer prompt: .sandcastle/decomposer.prompt.md`);
console.log(
` Output: docs/work/epics/<epic-slug>/_epic.md + per-requirement story files`,
);
console.log();
console.log("To run for real:");
console.log(
" - With Claude subscription: `claude login` (one-time) then `pnpm work decompose <id> --execute`",
);
console.log(
" - With API key: `ANTHROPIC_API_KEY=... pnpm work decompose <id> --execute`",
);
console.log();
console.log(
"(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and auth — see docs/guides/runbook.md.)",
);
}
/**
* Invoke sandcastle with the decomposer prompt + the PRD file content. The
* decomposer agent writes the epic + stories to disk inside the sandbox; the
* orchestrator then has those files in a branch the human can review.
*/
export async function executeDecompose(prdId, prdPath, prdText) {
const auth = resolveClaudeAuth();
if (auth.mode === "missing") {
console.error("✗ --execute requires either:");
console.error(
" 1. Claude Code logged in on host (run `claude login` first; ~/.claude/ becomes the auth source — this is the recommended path for Pro/Max subscribers)",
);
console.error(" 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)");
console.error("");
console.error(
" Override Claude creds path via SANDCASTLE_CLAUDE_CREDS_DIR.",
);
process.exit(1);
}
console.log(
`Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`,
);
console.log(`Decomposing PRD: ${prdId}`);
let sandcastleRoot;
let dockerProvider;
try {
sandcastleRoot = await import("@ai-hero/sandcastle");
const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker");
dockerProvider = dockerModule.docker;
} catch {
console.error(
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
);
process.exit(1);
}
const dockerOpts = {};
const agentOpts = {};
if (auth.mode === "subscription") {
dockerOpts.mounts = [
{
hostPath: auth.hostPath,
sandboxPath: auth.sandboxPath,
readonly: false,
},
];
} else if (auth.mode === "api-key") {
agentOpts.env = auth.env;
}
const sandbox = dockerProvider(dockerOpts);
const agent = sandcastleRoot.claudeCode("claude-sonnet-4-6", agentOpts);
const decomposerPrompt = path.join(SANDCASTLE_DIR, "decomposer.prompt.md");
let result;
try {
result = await sandcastleRoot.run({
agent,
sandbox,
promptFile: decomposerPrompt,
promptArgs: { PRD_FILE_CONTENT: prdText },
cwd: REPO_ROOT,
// Sandcastle's default maxIterations: 1 cut the agent off after its
// first response — files were written inside the sandbox but never
// captured as commits. Decompose is a small authoring task (read
// context, write epic + stories, commit); 10 iterations is enough
// room. Tune via env SANDCASTLE_DECOMPOSE_ITERATIONS.
maxIterations: Number(process.env.SANDCASTLE_DECOMPOSE_ITERATIONS ?? 10),
// Stop iterating the moment the agent emits this marker. Without it,
// sandcastle re-invokes the model up to maxIterations even when the
// work is already done — the prompt instructs the agent to emit
// <promise>COMPLETE</promise> on its final line.
completionSignal: "<promise>COMPLETE</promise>",
});
} catch (e) {
console.error("✗ Decomposer dispatch failed:", e.message);
if (/Image '.+' not found locally/.test(e.message ?? "")) {
console.error(
" One-time setup: pnpm exec sandcastle docker build-image",
);
}
if (
/Not logged in|Please run \/login/.test(e.message ?? "") &&
process.platform === "darwin"
) {
console.error(
" macOS users: Claude Code stores credentials in the Keychain, not in ~/.claude/. Extract once:",
);
console.error(
` security find-generic-password -s "Claude Code-credentials" -a "$USER" -w > ~/.claude/.credentials.json`,
);
console.error(" chmod 600 ~/.claude/.credentials.json");
console.error(
" OR fall back to API key: export ANTHROPIC_API_KEY=sk-ant-...",
);
}
console.error(
" See docs/guides/runbook.md → 'Using Sandcastle' for setup.",
);
process.exit(1);
}
console.log(
`Decomposer returned. Branch: ${result.branch}, Commits: ${result.commits.length}`,
);
console.log();
console.log("=== Suggested next steps ===");
console.log(
` 1. Inspect the new epic folder under docs/work/epics/<epic-slug>/ on branch ${result.branch}`,
);
console.log(
` 2. Review the generated stories + tasks; edit anything that should change`,
);
console.log(` 3. Merge the branch to main`);
console.log(
` 4. pnpm work rebuild-state && pnpm work next # see the first ready task`,
);
console.log(` 5. pnpm work dispatch --execute # dispatch it`);
}
// ---- CLI ----
function usage() {
console.error("Usage: pnpm work decompose <prd-id> [--execute]");
process.exit(2);
}
export async function runCli(args, { workRoot }) {
const positional = args.filter((a) => !a.startsWith("--"));
const prdId = positional[0];
if (!prdId) {
usage();
}
const prdPath = findPrdPath(workRoot, prdId);
if (!prdPath) {
console.error(`PRD with id="${prdId}" not found under docs/work/prds/`);
return 1;
}
let frontmatter;
let prdText;
try {
const result = validatePrdForDecompose(prdPath);
frontmatter = result.frontmatter;
prdText = result.text;
} catch (err) {
console.error(`Cannot decompose ${prdId}: ${err.message}`);
return 1;
}
if (args.includes("--execute")) {
await executeDecompose(prdId, prdPath, prdText);
return 0;
}
printDecomposePlan(prdId, prdPath, frontmatter);
return 0;
}
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
if (invokedDirectly) {
runCli(process.argv.slice(2), {
repoRoot: REPO_ROOT,
workRoot: WORK_ROOT,
}).then((code) => process.exit(code));
}

View File

@@ -0,0 +1,162 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { validatePrdForDecompose, runCli } from "./decompose.mjs";
function writePrd(dir, id, status) {
const file = path.join(dir, `${id}.prd.md`);
fs.writeFileSync(
file,
`---
id: ${id}
title: Test PRD
type: prd
status: ${status}
author: tester
created: 2026-05-13
---
body content
`,
);
return file;
}
function setupRepo() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "decompose-"));
fs.mkdirSync(path.join(tmp, "prds"), { recursive: true });
return tmp;
}
describe("validatePrdForDecompose", () => {
test("accepts an approved PRD", () => {
const tmp = setupRepo();
try {
const file = writePrd(path.join(tmp, "prds"), "test-1", "approved");
const result = validatePrdForDecompose(file);
assert.equal(result.frontmatter.status, "approved");
assert.ok(result.text.includes("body content"));
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("rejects draft (must go through human review)", () => {
const tmp = setupRepo();
try {
const file = writePrd(path.join(tmp, "prds"), "test-2", "draft");
assert.throws(
() => validatePrdForDecompose(file),
/status is "draft".*Flip status to "approved"/,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("rejects in-review", () => {
const tmp = setupRepo();
try {
const file = writePrd(path.join(tmp, "prds"), "test-3", "in-review");
assert.throws(
() => validatePrdForDecompose(file),
/status is "in-review"/,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("rejects shipped (epic already exists)", () => {
const tmp = setupRepo();
try {
const file = writePrd(path.join(tmp, "prds"), "test-4", "shipped");
assert.throws(() => validatePrdForDecompose(file), /status is "shipped"/);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("rejects unknown status", () => {
const tmp = setupRepo();
try {
const file = writePrd(path.join(tmp, "prds"), "test-5", "cancelled");
assert.throws(
() => validatePrdForDecompose(file),
/Unexpected PRD status "cancelled"/,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("rejects missing file", () => {
assert.throws(
() => validatePrdForDecompose("/nonexistent/path.prd.md"),
/PRD file not found/,
);
});
});
describe("runCli (print mode)", () => {
test("returns 1 + writes error when PRD id not found", async () => {
const tmp = setupRepo();
const errors = [];
const origError = console.error;
console.error = (m) => errors.push(m);
try {
const code = await runCli(["nonexistent-id"], {
repoRoot: tmp,
workRoot: tmp,
});
assert.equal(code, 1);
assert.ok(errors.some((e) => /not found under docs\/work\/prds/.test(e)));
} finally {
console.error = origError;
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("returns 1 + writes error when PRD is draft", async () => {
const tmp = setupRepo();
const errors = [];
const origError = console.error;
console.error = (m) => errors.push(m);
try {
writePrd(path.join(tmp, "prds"), "draft-prd", "draft");
const code = await runCli(["draft-prd"], {
repoRoot: tmp,
workRoot: tmp,
});
assert.equal(code, 1);
assert.ok(errors.some((e) => /Cannot decompose/.test(e)));
} finally {
console.error = origError;
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("prints plan when PRD is approved + returns 0", async () => {
const tmp = setupRepo();
const logs = [];
const origLog = console.log;
console.log = (m) => logs.push(m);
try {
writePrd(path.join(tmp, "prds"), "approved-prd", "approved");
const code = await runCli(["approved-prd"], {
repoRoot: tmp,
workRoot: tmp,
});
assert.equal(code, 0);
const all = logs.join("\n");
assert.match(all, /Decompose plan/);
assert.match(all, /approved-prd/);
assert.match(all, /eligible/);
} finally {
console.log = origLog;
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});

666
scripts/work/dispatch.mjs Normal file
View File

@@ -0,0 +1,666 @@
#!/usr/bin/env node
/**
* pnpm work dispatch — orchestrator that picks the next ready task and
* (with --execute) invokes sandcastle to run the implementer then reviewer.
*
* Default mode prints the dispatch plan without invoking sandcastle —
* safe to run anywhere. --execute requires EITHER:
* 1. Claude Code logged in on host (~/.claude/ — recommended for subscribers)
* 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync, execFileSync } from "node:child_process";
import { buildState } from "./state-builder.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const WORK_ROOT = path.join(REPO_ROOT, "docs", "work");
const SANDCASTLE_DIR = path.join(REPO_ROOT, ".sandcastle");
/**
* Returns the first ready story's first unchecked AC bullet, or null if
* there's no work to dispatch.
*
* Shape: { epic, story, title, storyPath, storyContent, bulletLine, bulletIndex }
*/
export function findNextTask(workRoot = WORK_ROOT) {
const state = buildState(workRoot);
if (state.ready.length === 0) return null;
const next = state.ready[0];
const storyPath = path.join(
workRoot,
"epics",
next.epic,
next.story,
"_story.md",
);
if (!fs.existsSync(storyPath)) return null;
const storyContent = fs.readFileSync(storyPath, "utf8");
const { bulletLine, bulletIndex } = findFirstUncheckedBullet(storyContent);
if (bulletLine === null) return null;
return {
epic: next.epic,
story: next.story,
title: next.title,
storyPath,
storyContent,
bulletLine,
bulletIndex,
};
}
/**
* Scans the story content for the first `- [ ]` bullet INSIDE the `## Tasks`
* section. Returns the matched line + its 0-based index within the file's
* line array (used by the orchestrator if it later tick-edits the file).
*/
export function findFirstUncheckedBullet(content) {
const lines = content.split("\n");
let inTasks = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("## ")) {
inTasks = /^##\s+Tasks\b/i.test(line);
continue;
}
if (!inTasks) continue;
if (/^[\s>-]*\[\s\]/.test(line)) {
return { bulletLine: line, bulletIndex: i };
}
}
return { bulletLine: null, bulletIndex: -1 };
}
/**
* Builds the task spec string passed to sandcastle as TASK_FILE_CONTENT.
* The implementer prompt template uses this verbatim. An optional
* `rejection_notes` argument is appended when the orchestrator re-dispatches
* the implementer after a reviewer reject.
*/
export function buildTaskSpec(next, rejectionNotes = null) {
const base = `# Current task
## Epic
${next.epic}
## Story
${next.story}${next.title}
## Current bullet
${next.bulletLine.trim()}
## Full story for context
${next.storyContent}`;
if (!rejectionNotes) return base;
return `${base}
## Previous attempt was REJECTED — fix these before re-committing
${rejectionNotes}`;
}
/**
* Extract the LAST structured JSON object emitted by the agent. The
* implementer + reviewer prompts both ask the agent to return JSON; in
* practice agents wrap it in a \`\`\`json ... \`\`\` fence, but we tolerate
* a bare \`{ ... }\` block at the end of stdout too. Returns null on no
* parsable match.
*/
export function parseAgentJson(stdout) {
if (!stdout) return null;
// 1. Code-fenced JSON: take the LAST ```json ... ``` block.
const fenceMatches = [...stdout.matchAll(/```json\s*\n([\s\S]*?)\n\s*```/g)];
if (fenceMatches.length > 0) {
const inner = fenceMatches[fenceMatches.length - 1][1].trim();
try {
return JSON.parse(inner);
} catch {
// fall through to bare-brace fallback
}
}
// 2. Bare braces: walk backwards from the last "}" to its match. Defensive
// against partial output or extra trailing characters from the completion
// signal.
const lastClose = stdout.lastIndexOf("}");
if (lastClose === -1) return null;
let depth = 0;
for (let i = lastClose; i >= 0; i--) {
if (stdout[i] === "}") depth++;
else if (stdout[i] === "{") depth--;
if (depth === 0) {
const candidate = stdout.slice(i, lastClose + 1);
try {
return JSON.parse(candidate);
} catch {
return null;
}
}
}
return null;
}
/**
* Replace the `- [ ]` checkbox at the given line index with `- [x]`. Pure
* over the file's text — returns the new content.
*/
export function tickBulletInContent(content, bulletIndex) {
const lines = content.split("\n");
if (bulletIndex < 0 || bulletIndex >= lines.length) return content;
lines[bulletIndex] = lines[bulletIndex].replace(/\[\s\]/, "[x]");
return lines.join("\n");
}
/**
* Count remaining `- [ ]` checkboxes inside the `## Tasks` section.
*/
export function countUncheckedBullets(content) {
const lines = content.split("\n");
let inTasks = false;
let count = 0;
for (const line of lines) {
if (line.startsWith("## ")) {
inTasks = /^##\s+Tasks\b/i.test(line);
continue;
}
if (!inTasks) continue;
if (/^[\s>-]*\[\s\]/.test(line)) count++;
}
return count;
}
/**
* Edit the `status:` line inside the leading `---\n...\n---` frontmatter
* block. Returns the new content, or the original if no frontmatter or no
* `status:` key was found.
*/
export function setFrontmatterStatus(content, newStatus) {
const fmMatch = content.match(/^(---\n)([\s\S]+?)(\n---)/);
if (!fmMatch) return content;
const [full, openDelim, body, closeDelim] = fmMatch;
if (!/^status:\s*/m.test(body)) return content;
const newBody = body.replace(/^status:\s*.*$/m, `status: ${newStatus}`);
return content.replace(full, `${openDelim}${newBody}${closeDelim}`);
}
/**
* Read the `status:` value from frontmatter. Returns null on no frontmatter
* or no key.
*/
export function readFrontmatterStatus(content) {
const fmMatch = content.match(/^---\n([\s\S]+?)\n---/);
if (!fmMatch) return null;
const m = fmMatch[1].match(/^status:\s*(.*)$/m);
return m ? m[1].trim() : null;
}
/**
* Tick the bullet in an epic's `## Stories` section that links to the given
* story folder. Idempotent: returns false if the bullet is already ticked or
* not present. Mirrors the per-task tick that already happens inside story
* files, applied at the parent-epic granularity.
*/
export function tickStoryBulletInEpic(workRoot, epicId, storyId) {
const epicFile = path.join(workRoot, "epics", epicId, "_epic.md");
if (!fs.existsSync(epicFile)) return false;
const content = fs.readFileSync(epicFile, "utf8");
const lines = content.split("\n");
let inStories = false;
let changed = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith("## ")) {
inStories = /^##\s+Stories\b/i.test(lines[i]);
continue;
}
if (!inStories) continue;
if (
lines[i].includes(`(${storyId}/_story.md)`) ||
lines[i].includes(`(./${storyId}/_story.md)`)
) {
if (/\[\s\]/.test(lines[i])) {
lines[i] = lines[i].replace(/\[\s\]/, "[x]");
changed = true;
}
break;
}
}
if (changed) fs.writeFileSync(epicFile, lines.join("\n"));
return changed;
}
/**
* If all stories in an epic are `status: done`, flip the epic's own
* frontmatter to `status: done`. Returns true if it flipped, false otherwise.
*/
export function flipEpicDoneIfAllStoriesDone(workRoot, epicId) {
const epicDir = path.join(workRoot, "epics", epicId);
const epicFile = path.join(epicDir, "_epic.md");
if (!fs.existsSync(epicFile)) return false;
const epicContent = fs.readFileSync(epicFile, "utf8");
if (readFrontmatterStatus(epicContent) === "done") return false;
for (const sub of fs.readdirSync(epicDir)) {
const subPath = path.join(epicDir, sub);
if (!fs.statSync(subPath).isDirectory()) continue;
const storyFile = path.join(subPath, "_story.md");
if (!fs.existsSync(storyFile)) continue;
const storyStatus = readFrontmatterStatus(
fs.readFileSync(storyFile, "utf8"),
);
if (storyStatus !== "done") return false;
}
fs.writeFileSync(epicFile, setFrontmatterStatus(epicContent, "done"));
return true;
}
/**
* Resolve the auth method for sandcastle dispatch.
*
* Priority:
* 1. Subscription (primary) — mount host's ~/.claude/ into the sandbox.
* Active when the host's Claude creds directory exists. The path
* defaults to ~/.claude/ and can be overridden via the
* SANDCASTLE_CLAUDE_CREDS_DIR env var.
* 2. API key (fallback) — pass ANTHROPIC_API_KEY (or OPENAI_API_KEY)
* through to the sandbox env.
* 3. Neither available → returns { mode: "missing" } and the dispatcher
* prints a clear error before exiting.
*
* Returns: { mode: "subscription", hostPath, sandboxPath }
* | { mode: "api-key", env }
* | { mode: "missing" }
*/
export function resolveClaudeAuth({
env = process.env,
home = os.homedir(),
} = {}) {
// 1. Subscription path
const credsHostPath =
env.SANDCASTLE_CLAUDE_CREDS_DIR ?? path.join(home, ".claude");
if (fs.existsSync(credsHostPath)) {
return {
mode: "subscription",
hostPath: credsHostPath,
// Inside the sandbox, claude looks at the agent user's home — tilde
// expansion in MountConfig handles the actual /home/agent/.claude
// resolution.
sandboxPath: "~/.claude",
};
}
// 2. API key fallback
if (env.ANTHROPIC_API_KEY) {
return {
mode: "api-key",
env: { ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY },
};
}
if (env.OPENAI_API_KEY) {
return { mode: "api-key", env: { OPENAI_API_KEY: env.OPENAI_API_KEY } };
}
// 3. Neither available
return { mode: "missing" };
}
function printPlan() {
const next = findNextTask();
if (!next) {
console.log("No ready task to dispatch.");
console.log("Run `pnpm work blocked` to see what's waiting on what.");
process.exit(0);
}
console.log("=== Dispatch plan ===");
console.log(` Epic: ${next.epic}`);
console.log(` Story: ${next.story}${next.title}`);
console.log(` Bullet: ${next.bulletLine.trim()}`);
console.log(` Prompt: .sandcastle/implementer.prompt.md`);
console.log();
console.log("To execute this dispatch:");
console.log(
" - With Claude subscription: `claude login` (one-time) then `pnpm work dispatch --execute`",
);
console.log(
" - With API key: `ANTHROPIC_API_KEY=... pnpm work dispatch --execute`",
);
console.log();
console.log(
"By default --execute LOOPS through every ready task. Flags to bound it:",
);
console.log(" --once stop after one approved slice");
console.log(" --max-tasks N stop after N approved slices");
console.log();
console.log(
"(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and auth — see above.)",
);
}
/**
* Print the macOS / sandcastle-image / auth hints when a sandcastle run
* blows up. Shared between implementer + reviewer error paths.
*/
function explainSandcastleError(stage, e) {
console.error(`${stage} dispatch failed:`, e.message);
if (/Image '.+' not found locally/.test(e.message ?? "")) {
console.error(" One-time setup: pnpm exec sandcastle docker build-image");
}
if (
/Not logged in|Please run \/login/.test(e.message ?? "") &&
process.platform === "darwin"
) {
console.error(
" macOS users: Claude Code stores credentials in the Keychain, not in ~/.claude/. Extract once:",
);
console.error(
` security find-generic-password -s "Claude Code-credentials" -a "$USER" -w > ~/.claude/.credentials.json`,
);
console.error(" chmod 600 ~/.claude/.credentials.json");
console.error(
" OR fall back to API key: export ANTHROPIC_API_KEY=sk-ant-...",
);
}
console.error(" See docs/guides/runbook.md → 'Using Sandcastle' for setup.");
}
/**
* Run one slice end-to-end: implementer + reviewer, with a fix-up cycle on
* reject (capped at maxAttempts). Each slice is an independent sandcastle
* session — sandcastle's `resumeSession` is incompatible with the
* multi-iteration budgets a TDD slice requires (applies to iteration 1 only).
*
* Outcome variants:
* "approved" (implJson, reviewJson)
* "rejected-final" (lastRejectNotes)
* "blocked" (implJson)
* "error" (reason)
*/
async function runOneSlice({ sandcastleRoot, sandbox, agent, next }) {
const maxAttempts = Number(process.env.SANDCASTLE_MAX_ATTEMPTS ?? 3);
const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md");
const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md");
let rejectionNotes = null;
let lastRejectNotes = null;
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
const taskSpec = buildTaskSpec(next, rejectionNotes);
let implResult;
try {
implResult = await sandcastleRoot.run({
agent,
sandbox,
promptFile: implementerPrompt,
promptArgs: { TASK_FILE_CONTENT: taskSpec },
cwd: REPO_ROOT,
// Implementer runs a full TDD slice (read context, red test, green
// impl, run all five gates, commit). 30 iterations matches typical
// slice shape. Tune via env SANDCASTLE_IMPLEMENTER_ITERATIONS.
maxIterations: Number(
process.env.SANDCASTLE_IMPLEMENTER_ITERATIONS ?? 30,
),
// Stop iterating the moment the agent emits this marker. Without
// it, sandcastle re-invokes the model up to maxIterations even
// when the work is already done.
completionSignal: "<promise>COMPLETE</promise>",
});
} catch (e) {
explainSandcastleError("Implementer", e);
return { outcome: "error", attempts, reason: e.message };
}
console.log(
`Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`,
);
const implJson = parseAgentJson(implResult.stdout);
if (
implJson?.status === "blocked" ||
implJson?.status === "needs-clarification"
) {
return { outcome: "blocked", attempts, implJson };
}
let diff = "";
try {
diff = execSync(`git diff main..${implResult.branch}`, {
encoding: "utf8",
cwd: REPO_ROOT,
});
} catch {
diff = "(diff unavailable)";
}
let reviewResult;
try {
reviewResult = await sandcastleRoot.run({
agent,
sandbox,
promptFile: reviewerPrompt,
promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff },
cwd: REPO_ROOT,
// Reviewer reads the diff + task spec and decides (approve/reject).
// Smaller surface than the implementer; 10 iterations is plenty.
// Tune via env SANDCASTLE_REVIEWER_ITERATIONS.
maxIterations: Number(process.env.SANDCASTLE_REVIEWER_ITERATIONS ?? 10),
// See implementer comment above.
completionSignal: "<promise>COMPLETE</promise>",
});
} catch (e) {
explainSandcastleError("Reviewer", e);
return { outcome: "error", attempts, reason: e.message };
}
const reviewJson = parseAgentJson(reviewResult.stdout);
if (reviewJson?.decision === "approve") {
return { outcome: "approved", attempts, implJson, reviewJson };
}
if (reviewJson?.decision === "reject") {
rejectionNotes =
reviewJson.notes ?? "(reviewer rejected without notes — re-attempt)";
lastRejectNotes = rejectionNotes;
console.log(
`↺ Attempt ${attempts}/${maxAttempts} rejected. Re-dispatching implementer with notes.`,
);
continue;
}
return {
outcome: "error",
attempts,
reason: `reviewer returned no parseable decision; stdout:\n${reviewResult.stdout}`,
};
}
return { outcome: "rejected-final", attempts, lastRejectNotes };
}
/**
* After an `approved` slice: tick the bullet, flip the story status if all
* bullets are now ticked (or todo→in-progress on the first tick), flip the
* epic status if all its stories are done, and commit the mutation on the
* host. The implementer's slice commit is already on main; this is a
* separate bookkeeping commit so the slice commit stays clean.
*/
function applyApprovedState(next) {
let content = fs.readFileSync(next.storyPath, "utf8");
content = tickBulletInContent(content, next.bulletIndex);
const currentStatus = readFrontmatterStatus(content);
let storyFlipped = false;
if (countUncheckedBullets(content) === 0 && currentStatus !== "done") {
content = setFrontmatterStatus(content, "done");
storyFlipped = true;
} else if (currentStatus === "todo") {
content = setFrontmatterStatus(content, "in-progress");
}
fs.writeFileSync(next.storyPath, content);
let epicFlipped = false;
let epicBulletTicked = false;
if (storyFlipped) {
epicBulletTicked = tickStoryBulletInEpic(WORK_ROOT, next.epic, next.story);
epicFlipped = flipEpicDoneIfAllStoriesDone(WORK_ROOT, next.epic);
}
const filesToStage = [path.relative(REPO_ROOT, next.storyPath)];
if (epicFlipped || epicBulletTicked) {
filesToStage.push(
path.relative(
REPO_ROOT,
path.join(WORK_ROOT, "epics", next.epic, "_epic.md"),
),
);
}
const commitMsg = epicFlipped
? `chore(work): finish epic ${next.epic}`
: storyFlipped
? `chore(work): finish ${next.story}`
: `chore(work): tick task in ${next.story}`;
execFileSync("git", ["add", ...filesToStage], { cwd: REPO_ROOT });
execFileSync("git", ["commit", "-m", commitMsg], {
cwd: REPO_ROOT,
stdio: "inherit",
});
console.log(`${commitMsg}`);
}
/**
* Pick a slice, dispatch implementer + reviewer (with reject fix-up cycle),
* apply state mutation on approve, loop until exhausted or a cap is hit.
*
* Flags:
* --once stop after one slice (legacy behavior)
* --max-tasks N stop after N approved slices (default: unlimited)
*/
async function executeDispatch({ maxTasks }) {
const auth = resolveClaudeAuth();
if (auth.mode === "missing") {
console.error("✗ --execute requires either:");
console.error(
" 1. Claude Code logged in on host (run `claude login` first; ~/.claude/ becomes the auth source — this is the recommended path for Pro/Max subscribers)",
);
console.error(" 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)");
console.error("");
console.error(
" Override Claude creds path via SANDCASTLE_CLAUDE_CREDS_DIR.",
);
process.exit(1);
}
console.log(
`Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`,
);
let sandcastleRoot;
let dockerProvider;
try {
sandcastleRoot = await import("@ai-hero/sandcastle");
const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker");
dockerProvider = dockerModule.docker;
} catch {
console.error(
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
);
process.exit(1);
}
const dockerOpts = {};
const agentOpts = {};
if (auth.mode === "subscription") {
dockerOpts.mounts = [
{
hostPath: auth.hostPath,
sandboxPath: auth.sandboxPath,
readonly: false,
},
];
} else if (auth.mode === "api-key") {
agentOpts.env = auth.env;
}
const sandbox = dockerProvider(dockerOpts);
const agent = sandcastleRoot.claudeCode("claude-sonnet-4-6", agentOpts);
let approved = 0;
while (true) {
if (maxTasks !== null && approved >= maxTasks) {
console.log(`\nHit --max-tasks=${maxTasks} cap; stopping.`);
break;
}
const next = findNextTask();
if (!next) {
console.log("\nNo more ready tasks. Dispatch loop complete.");
break;
}
console.log(
`\n--- Slice ${approved + 1}: ${next.epic} / ${next.story} ---`,
);
console.log(` Bullet: ${next.bulletLine.trim()}`);
const result = await runOneSlice({ sandcastleRoot, sandbox, agent, next });
if (result.outcome === "approved") {
applyApprovedState(next);
approved++;
continue;
}
if (result.outcome === "rejected-final") {
console.error(
`\n✗ Slice rejected after ${result.attempts} attempts. Stopping dispatch loop.`,
);
if (result.lastRejectNotes) {
console.error(`Last rejection notes:\n${result.lastRejectNotes}`);
}
process.exit(1);
}
if (result.outcome === "blocked") {
console.error(
`\n✗ Implementer reported ${result.implJson?.status ?? "blocked"}. Stopping dispatch loop.`,
);
if (result.implJson?.notes) {
console.error(`Implementer notes:\n${result.implJson.notes}`);
}
process.exit(1);
}
// outcome === "error"
console.error(`\n✗ Slice errored: ${result.reason ?? "(no reason)"}`);
process.exit(1);
}
console.log(`\nDispatched ${approved} slice(s).`);
}
/**
* Explicit CLI entry. Exported so cli.mjs can dispatch into this module
* without relying on a top-level side effect (which would also fire when
* sibling work scripts import `resolveClaudeAuth`, etc.).
*
* Flags:
* --execute run sandcastle (default: print plan only)
* --once stop after one approved slice (default: loop until done)
* --max-tasks N stop after N approved slices
*/
export async function runCli(args) {
if (!args.includes("--execute")) {
printPlan();
return;
}
let maxTasks = null;
if (args.includes("--once")) maxTasks = 1;
const maxTasksFlagIdx = args.indexOf("--max-tasks");
if (maxTasksFlagIdx !== -1) {
const raw = args[maxTasksFlagIdx + 1];
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 1) {
console.error(`✗ --max-tasks expects a positive integer, got: ${raw}`);
process.exit(2);
}
maxTasks = parsed;
}
await executeDispatch({ maxTasks });
}
// When invoked directly (`node scripts/work/dispatch.mjs ...`), run the CLI.
// When imported by cli.mjs or any sibling, do nothing — the caller decides.
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
if (invokedDirectly) {
runCli(process.argv.slice(2));
}

View File

@@ -0,0 +1,200 @@
import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import {
findNextTask,
findFirstUncheckedBullet,
buildTaskSpec,
resolveClaudeAuth,
} from "./dispatch.mjs";
function makeWorkTree({ epics }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dispatch-"));
for (const [epicId, epicData] of Object.entries(epics)) {
const epicDir = path.join(root, epicId);
fs.mkdirSync(epicDir, { recursive: true });
fs.writeFileSync(
path.join(epicDir, "_epic.md"),
`---\nid: ${epicId}\ntitle: ${epicId}\nstatus: ${epicData.status ?? "in-progress"}\n---\n`,
);
for (const [storyId, storyData] of Object.entries(epicData.stories ?? {})) {
const storyDir = path.join(epicDir, storyId);
fs.mkdirSync(storyDir, { recursive: true });
const tasks = (storyData.tasks ?? [])
.map((t) => `- [${t.done ? "x" : " "}] ${t.label}`)
.join("\n");
const deps = storyData.depends_on
? `depends-on: [${storyData.depends_on.join(", ")}]\n`
: "";
fs.writeFileSync(
path.join(storyDir, "_story.md"),
`---\nid: ${storyId}\ntitle: ${storyId}\nstatus: ${storyData.status ?? "todo"}\n${deps}---\n\n## Tasks\n${tasks}\n`,
);
}
}
return root;
}
describe("findFirstUncheckedBullet", () => {
it("returns the first - [ ] line under ## Tasks", () => {
const content = `## Tasks
- [x] done
- [ ] open
- [ ] another
`;
const { bulletLine } = findFirstUncheckedBullet(content);
expect(bulletLine).toBe("- [ ] open");
});
it("returns null when no unchecked bullets remain", () => {
const content = `## Tasks
- [x] done
- [x] also done
`;
expect(findFirstUncheckedBullet(content).bulletLine).toBeNull();
});
it("only checks inside the Tasks section", () => {
const content = `## Goal
- [ ] not a task
## Tasks
- [x] done
`;
expect(findFirstUncheckedBullet(content).bulletLine).toBeNull();
});
});
describe("findNextTask", () => {
it("returns the next bullet from the first ready story", () => {
const root = makeWorkTree({
epics: {
e: {
stories: {
s: {
status: "todo",
tasks: [
{ label: "a", done: true },
{ label: "b", done: false },
],
},
},
},
},
});
const next = findNextTask(root);
expect(next.epic).toBe("e");
expect(next.story).toBe("s");
expect(next.bulletLine.trim()).toBe("- [ ] b");
});
it("returns null when no stories are ready", () => {
const root = makeWorkTree({
epics: {
e: {
stories: {
s: {
status: "done",
tasks: [{ label: "a", done: true }],
},
},
},
},
});
expect(findNextTask(root)).toBeNull();
});
it("returns null when ready story has no unchecked bullets", () => {
const root = makeWorkTree({
epics: {
e: {
stories: {
s: {
status: "todo",
tasks: [{ label: "a", done: true }],
},
},
},
},
});
expect(findNextTask(root)).toBeNull();
});
});
describe("buildTaskSpec", () => {
it("includes epic, story, bullet, and full story content", () => {
const next = {
epic: "e",
story: "s",
title: "Story",
storyContent: "## Goal\n\nSomething.\n## Tasks\n- [ ] do thing",
bulletLine: "- [ ] do thing",
};
const spec = buildTaskSpec(next);
expect(spec).toContain("e");
expect(spec).toContain("s — Story");
expect(spec).toContain("- [ ] do thing");
expect(spec).toContain("## Goal");
});
});
describe("resolveClaudeAuth", () => {
it("returns subscription mode when ~/.claude exists on host", () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-sub-"));
fs.mkdirSync(path.join(tmpHome, ".claude"));
const result = resolveClaudeAuth({ env: {}, home: tmpHome });
expect(result.mode).toBe("subscription");
expect(result.hostPath).toBe(path.join(tmpHome, ".claude"));
expect(result.sandboxPath).toBe("~/.claude");
});
it("honours SANDCASTLE_CLAUDE_CREDS_DIR override", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "auth-override-"));
const overrideDir = path.join(tmpRoot, "custom-claude");
fs.mkdirSync(overrideDir);
const result = resolveClaudeAuth({
env: { SANDCASTLE_CLAUDE_CREDS_DIR: overrideDir },
home: "/nonexistent",
});
expect(result.mode).toBe("subscription");
expect(result.hostPath).toBe(overrideDir);
});
it("falls back to ANTHROPIC_API_KEY when ~/.claude does not exist", () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-key-"));
// No .claude directory created
const result = resolveClaudeAuth({
env: { ANTHROPIC_API_KEY: "sk-test" },
home: tmpHome,
});
expect(result.mode).toBe("api-key");
expect(result.env).toEqual({ ANTHROPIC_API_KEY: "sk-test" });
});
it("falls back to OPENAI_API_KEY when only that is set", () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-openai-"));
const result = resolveClaudeAuth({
env: { OPENAI_API_KEY: "sk-openai" },
home: tmpHome,
});
expect(result.mode).toBe("api-key");
expect(result.env).toEqual({ OPENAI_API_KEY: "sk-openai" });
});
it("returns missing when neither subscription nor API key available", () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-missing-"));
const result = resolveClaudeAuth({ env: {}, home: tmpHome });
expect(result.mode).toBe("missing");
});
it("prefers subscription over API key when both available", () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-both-"));
fs.mkdirSync(path.join(tmpHome, ".claude"));
const result = resolveClaudeAuth({
env: { ANTHROPIC_API_KEY: "sk-test" },
home: tmpHome,
});
expect(result.mode).toBe("subscription");
});
});

240
scripts/work/prd-ship.mjs Normal file
View File

@@ -0,0 +1,240 @@
/**
* scripts/work/prd-ship.mjs — flip a PRD's status to `shipped`.
*
* Invoked when an epic completes and its seed PRD's implementation is fully
* landed. Writes back to the PRD's frontmatter:
* - status: <approved|in-review> -> shipped
* - shipped: <ISO date> (today, UTC)
* - shipping-commits: [<sha1>, <sha2>, ...] (optional)
*
* Refuses to flip from `draft` (must go through human review first —
* draft -> approved is the human step, NOT automated by this command).
* Refuses to flip if already `shipped` (idempotent fail-soft).
*
* Usage:
* pnpm work prd-ship <prd-id>
* pnpm work prd-ship <prd-id> --commits sha1,sha2,sha3
* pnpm work prd-ship <prd-id> --auto-commits # derive from `git log` since the PRD's
* # created date on the PRD file's first
* # appearance in git history
*/
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
/**
* Parse the top YAML frontmatter block of a markdown file. Returns
* { frontmatter: Record<string, string|string[]>, body: string,
* raw: { frontmatterText, frontmatterStart, frontmatterEnd } }
*
* Hand-rolled YAML-ish parser — handles scalar lines and "- item" lists,
* the only two shapes the PRD/epic/story frontmatter uses. Sufficient for
* this use case; not a general YAML parser.
*/
export function parseFrontmatter(text) {
if (!text.startsWith("---\n")) {
return { frontmatter: {}, body: text, raw: null };
}
const end = text.indexOf("\n---\n", 4);
if (end === -1) return { frontmatter: {}, body: text, raw: null };
const fmText = text.slice(4, end);
const body = text.slice(end + 5);
const fm = {};
let currentList = null;
for (const line of fmText.split("\n")) {
if (line.startsWith(" - ")) {
if (currentList) currentList.push(line.slice(4).trim());
continue;
}
currentList = null;
const m = /^([a-zA-Z_-]+):\s*(.*)$/.exec(line);
if (!m) continue;
const [, key, value] = m;
if (value === "") {
// Empty value — could be the start of a list
fm[key] = [];
currentList = fm[key];
} else {
fm[key] = value;
}
}
return {
frontmatter: fm,
body,
raw: { frontmatterText: fmText, frontmatterStart: 4, frontmatterEnd: end },
};
}
/**
* Serialize a parsed frontmatter back to YAML text. Preserves declared key
* order for keys that were in the original; appends new keys at the end.
*/
export function serializeFrontmatter(originalText, newFrontmatter) {
const lines = [];
const originalKeyOrder = [];
if (originalText) {
for (const line of originalText.split("\n")) {
const m = /^([a-zA-Z_-]+):/.exec(line);
if (m) originalKeyOrder.push(m[1]);
}
}
const seen = new Set();
for (const key of originalKeyOrder) {
if (seen.has(key)) continue;
seen.add(key);
if (!(key in newFrontmatter)) continue;
emitKey(lines, key, newFrontmatter[key]);
}
for (const key of Object.keys(newFrontmatter)) {
if (seen.has(key)) continue;
emitKey(lines, key, newFrontmatter[key]);
}
return lines.join("\n");
}
function emitKey(lines, key, value) {
if (Array.isArray(value)) {
lines.push(`${key}:`);
for (const item of value) lines.push(` - ${item}`);
} else {
lines.push(`${key}: ${value}`);
}
}
/**
* Flip a PRD's status to "shipped". Pure function over the file's text.
* Returns the new file text. Throws on illegal transitions.
*/
export function flipPrdStatus(text, { shippedDate, commits } = {}) {
const { frontmatter, body, raw } = parseFrontmatter(text);
if (!raw) {
throw new Error("PRD has no parseable frontmatter");
}
const current = frontmatter.status;
if (current === "shipped") {
throw new Error("PRD is already marked shipped (idempotent fail-soft)");
}
if (current === "draft") {
throw new Error(
"PRD is still draft — flip to approved (human review) before shipping",
);
}
if (current !== "approved" && current !== "in-review") {
throw new Error(
`Unexpected PRD status "${current}" — expected approved or in-review`,
);
}
const newFm = { ...frontmatter };
newFm.status = "shipped";
newFm.shipped = shippedDate ?? new Date().toISOString().slice(0, 10);
if (commits && commits.length > 0) {
newFm["shipping-commits"] = commits;
}
const newFmText = serializeFrontmatter(raw.frontmatterText, newFm);
return `---\n${newFmText}\n---\n${body}`;
}
/**
* Derive shipping commits for a PRD by walking `git log` for the PRD's
* linked epic folder. Best-effort; returns empty array if the epic folder
* doesn't exist or git isn't available.
*/
export function deriveShippingCommits(repoRoot, prdId, workRoot) {
// Find the epic whose `prd:` matches this prdId
const epicsRoot = path.join(workRoot, "epics");
if (!fs.existsSync(epicsRoot)) return [];
const entries = fs.readdirSync(epicsRoot, { withFileTypes: true });
let epicDir = null;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const epicFile = path.join(epicsRoot, entry.name, "_epic.md");
if (!fs.existsSync(epicFile)) continue;
const { frontmatter } = parseFrontmatter(fs.readFileSync(epicFile, "utf8"));
if (frontmatter.prd === prdId) {
epicDir = entry.name;
break;
}
}
if (!epicDir) return [];
try {
const log = execSync(
`git log --format=%h --reverse -- docs/work/epics/${epicDir}/`,
{ cwd: repoRoot, encoding: "utf8" },
);
return log.trim().split("\n").filter(Boolean);
} catch {
return [];
}
}
export function findPrdPath(workRoot, prdId) {
const prdsDir = path.join(workRoot, "prds");
if (!fs.existsSync(prdsDir)) return null;
for (const file of fs.readdirSync(prdsDir)) {
if (!file.endsWith(".prd.md")) continue;
const text = fs.readFileSync(path.join(prdsDir, file), "utf8");
const { frontmatter } = parseFrontmatter(text);
if (frontmatter.id === prdId) {
return path.join(prdsDir, file);
}
}
return null;
}
// ---- CLI ----
export function runCli(args, { repoRoot, workRoot }) {
const prdId = args[0];
if (!prdId) {
process.stderr.write(
"Usage: pnpm work prd-ship <prd-id> [--commits sha1,sha2,...] [--auto-commits]\n",
);
return 2;
}
let commits;
let autoCommits = false;
for (let i = 1; i < args.length; i++) {
if (args[i] === "--commits") {
commits = args[++i]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (args[i] === "--auto-commits") {
autoCommits = true;
}
}
const prdPath = findPrdPath(workRoot, prdId);
if (!prdPath) {
process.stderr.write(
`PRD with id="${prdId}" not found under docs/work/prds/\n`,
);
return 1;
}
if (autoCommits && !commits) {
commits = deriveShippingCommits(repoRoot, prdId, workRoot);
}
const text = fs.readFileSync(prdPath, "utf8");
let newText;
try {
newText = flipPrdStatus(text, { commits });
} catch (err) {
process.stderr.write(`Cannot ship PRD ${prdId}: ${err.message}\n`);
return 1;
}
fs.writeFileSync(prdPath, newText);
process.stdout.write(
`Shipped ${prdId} (${path.relative(repoRoot, prdPath)})` +
(commits ? ` with ${commits.length} commit(s)` : "") +
"\n",
);
return 0;
}

View File

@@ -0,0 +1,190 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import {
parseFrontmatter,
serializeFrontmatter,
flipPrdStatus,
findPrdPath,
} from "./prd-ship.mjs";
describe("parseFrontmatter", () => {
test("parses scalar keys from a basic frontmatter block", () => {
const text = `---
id: test-1
title: Hello
status: approved
---
body
`;
const { frontmatter, body } = parseFrontmatter(text);
assert.equal(frontmatter.id, "test-1");
assert.equal(frontmatter.title, "Hello");
assert.equal(frontmatter.status, "approved");
assert.match(body, /body/);
});
test("parses YAML list values (indented '- item')", () => {
const text = `---
id: test-2
shipping-commits:
- abc123
- def456
---
body
`;
const { frontmatter } = parseFrontmatter(text);
assert.deepEqual(frontmatter["shipping-commits"], ["abc123", "def456"]);
});
test("returns empty frontmatter when none present", () => {
const { frontmatter, body } = parseFrontmatter("just body content\n");
assert.deepEqual(frontmatter, {});
assert.equal(body, "just body content\n");
});
});
describe("flipPrdStatus", () => {
const baseText = `---
id: test-prd
title: Test PRD
type: prd
status: approved
created: 2026-01-01
---
# body
`;
test("flips approved -> shipped with today's date", () => {
const result = flipPrdStatus(baseText, { shippedDate: "2026-05-13" });
const { frontmatter } = parseFrontmatter(result);
assert.equal(frontmatter.status, "shipped");
assert.equal(frontmatter.shipped, "2026-05-13");
});
test("flips in-review -> shipped", () => {
const text = baseText.replace("status: approved", "status: in-review");
const result = flipPrdStatus(text, { shippedDate: "2026-05-13" });
const { frontmatter } = parseFrontmatter(result);
assert.equal(frontmatter.status, "shipped");
});
test("preserves body", () => {
const result = flipPrdStatus(baseText, { shippedDate: "2026-05-13" });
assert.match(result, /\n# body\n/);
});
test("preserves other frontmatter keys", () => {
const result = flipPrdStatus(baseText, { shippedDate: "2026-05-13" });
const { frontmatter } = parseFrontmatter(result);
assert.equal(frontmatter.id, "test-prd");
assert.equal(frontmatter.title, "Test PRD");
assert.equal(frontmatter.created, "2026-01-01");
});
test("adds shipping-commits when supplied", () => {
const result = flipPrdStatus(baseText, {
shippedDate: "2026-05-13",
commits: ["abc123", "def456"],
});
const { frontmatter } = parseFrontmatter(result);
assert.deepEqual(frontmatter["shipping-commits"], ["abc123", "def456"]);
});
test("refuses to flip draft (must go through human review)", () => {
const text = baseText.replace("status: approved", "status: draft");
assert.throws(() => flipPrdStatus(text), /still draft/);
});
test("refuses to flip already-shipped (idempotent fail-soft)", () => {
const text = baseText.replace("status: approved", "status: shipped");
assert.throws(() => flipPrdStatus(text), /already marked shipped/);
});
test("refuses unexpected statuses", () => {
const text = baseText.replace("status: approved", "status: cancelled");
assert.throws(() => flipPrdStatus(text), /Unexpected PRD status/);
});
test("refuses files without frontmatter", () => {
assert.throws(
() => flipPrdStatus("no frontmatter here"),
/no parseable frontmatter/,
);
});
});
describe("findPrdPath", () => {
test("finds a PRD by its id field", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "prd-find-"));
try {
const prdsDir = path.join(tmpRoot, "prds");
fs.mkdirSync(prdsDir, { recursive: true });
fs.writeFileSync(
path.join(prdsDir, "2026-05-13-something.prd.md"),
`---
id: 2026-05-13-something
status: approved
---
body
`,
);
const found = findPrdPath(tmpRoot, "2026-05-13-something");
assert.ok(found);
assert.match(found, /something\.prd\.md$/);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test("returns null when no PRD matches", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "prd-find2-"));
try {
fs.mkdirSync(path.join(tmpRoot, "prds"), { recursive: true });
const found = findPrdPath(tmpRoot, "nonexistent");
assert.equal(found, null);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
});
describe("serializeFrontmatter", () => {
test("preserves key order from the original", () => {
const orig = `id: x
title: y
status: approved`;
const result = serializeFrontmatter(orig, {
id: "x",
title: "y",
status: "shipped",
});
const lines = result.split("\n");
assert.equal(lines[0], "id: x");
assert.equal(lines[1], "title: y");
assert.equal(lines[2], "status: shipped");
});
test("appends new keys at the end", () => {
const orig = `id: x
status: approved`;
const result = serializeFrontmatter(orig, {
id: "x",
status: "shipped",
shipped: "2026-05-13",
});
assert.match(result, /shipped: 2026-05-13\n?$/);
});
test("emits array values as YAML lists", () => {
const result = serializeFrontmatter("", {
"shipping-commits": ["abc", "def"],
});
assert.match(result, /shipping-commits:\n {2}- abc\n {2}- def/);
});
});

View File

@@ -0,0 +1,235 @@
import fs from "node:fs";
import path from "node:path";
/**
* Walk the `docs/work/epics/` tree under `workRoot` and return a structured
* state object. Each epic folder under `epics/` must contain `_epic.md`;
* each story subfolder must contain `_story.md`.
*
* Returns:
* {
* updated_at: ISO string,
* epics: {
* <epic-id>: {
* status: "todo" | "in-progress" | "done",
* title: string,
* stories: {
* <story-id>: {
* status: "todo" | "in-progress" | "done",
* title: string,
* ac_total: number, // total checkboxes in Tasks section
* ac_completed: number, // - [x] checkboxes
* }
* }
* }
* }
* }
*/
export function buildState(workRoot) {
const state = {
updated_at: new Date().toISOString(),
epics: {},
};
const epicsRoot = path.join(workRoot, "epics");
if (!fs.existsSync(epicsRoot)) return state;
for (const entry of fs.readdirSync(epicsRoot)) {
const epicDir = path.join(epicsRoot, entry);
if (!fs.statSync(epicDir).isDirectory()) continue;
const epicFile = path.join(epicDir, "_epic.md");
if (!fs.existsSync(epicFile)) continue;
const epicMeta = parseFrontmatter(epicFile);
const epicEntry = {
status: epicMeta.status ?? "todo",
title: epicMeta.title ?? entry,
prd: epicMeta.prd && epicMeta.prd !== "null" ? epicMeta.prd : null,
stories: {},
};
for (const sub of fs.readdirSync(epicDir)) {
const subPath = path.join(epicDir, sub);
if (!fs.statSync(subPath).isDirectory()) continue;
const storyFile = path.join(subPath, "_story.md");
if (!fs.existsSync(storyFile)) continue;
const storyMeta = parseFrontmatter(storyFile);
const storyContent = fs.readFileSync(storyFile, "utf8");
const { total, completed } = countTaskCheckboxes(storyContent);
epicEntry.stories[storyMeta.id ?? sub] = {
status: storyMeta.status ?? "todo",
title: storyMeta.title ?? sub,
ac_total: total,
ac_completed: completed,
depends_on: Array.isArray(storyMeta["depends-on"])
? storyMeta["depends-on"]
: [],
blocks: Array.isArray(storyMeta.blocks) ? storyMeta.blocks : [],
};
}
state.epics[epicMeta.id ?? entry] = epicEntry;
}
const { ready, blocked } = computeReadyBlocked(state);
state.ready = ready;
state.blocked = blocked;
state.needs_prd_ship = computeNeedsPrdShip(state, workRoot);
return state;
}
/**
* Find epics whose status is "done" + have a non-null `prd:` link + the
* linked PRD's status is NOT yet "shipped". Surfacing these in state lets
* the reviewer (or a post-merge hook) trigger `pnpm work prd-ship <id>`.
*/
export function computeNeedsPrdShip(state, workRoot) {
const out = [];
const prdsDir = path.join(workRoot, "prds");
if (!fs.existsSync(prdsDir)) return out;
// Index PRDs by id -> { path, status }
const prdIndex = new Map();
for (const file of fs.readdirSync(prdsDir)) {
if (!file.endsWith(".prd.md")) continue;
const prdPath = path.join(prdsDir, file);
const meta = parseFrontmatter(prdPath);
if (meta.id) prdIndex.set(meta.id, { path: prdPath, status: meta.status });
}
for (const [epicId, epic] of Object.entries(state.epics)) {
if (epic.status !== "done") continue;
if (!epic.prd) continue;
const prd = prdIndex.get(epic.prd);
if (!prd) continue; // PRD link points to a missing file — orchestrator concern, not ours
if (prd.status === "shipped") continue; // already done
out.push({
epic: epicId,
prd: epic.prd,
prd_status: prd.status ?? "unknown",
action: `pnpm work prd-ship ${epic.prd} --auto-commits`,
});
}
return out;
}
/**
* Given a built state object, compute the ready + blocked story sets.
*
* "ready" = not done AND all depends_on stories are done
* "blocked" = not done AND at least one depends_on story is not done
*
* depends_on references are either same-epic (just story id) or cross-epic
* (`<epic>/<story>`).
*
* Returns { ready: [...], blocked: [...] } where each entry is:
* { epic, story, title, [waiting_on?] }
*/
export function computeReadyBlocked(state) {
// Build a flat map: "<epic>/<story>" => { status, title, depends_on }
const flat = new Map();
for (const [epic, epicEntry] of Object.entries(state.epics)) {
for (const [story, storyEntry] of Object.entries(epicEntry.stories)) {
flat.set(`${epic}/${story}`, {
epic,
story,
status: storyEntry.status,
title: storyEntry.title,
depends_on: storyEntry.depends_on,
});
}
}
// Resolve a depends_on reference (which may be `<epic>/<story>` or just `<story>`)
// relative to the current epic.
function resolveRef(ref, currentEpic) {
if (ref.includes("/")) return ref;
return `${currentEpic}/${ref}`;
}
const ready = [];
const blocked = [];
for (const entry of flat.values()) {
if (entry.status === "done") continue;
const refs = entry.depends_on.map((r) => resolveRef(r, entry.epic));
const waitingOn = refs.filter((r) => {
const dep = flat.get(r);
return !dep || dep.status !== "done";
});
if (waitingOn.length === 0) {
ready.push({ epic: entry.epic, story: entry.story, title: entry.title });
} else {
blocked.push({
epic: entry.epic,
story: entry.story,
title: entry.title,
waiting_on: waitingOn,
});
}
}
return { ready, blocked };
}
/**
* Read a markdown file's YAML frontmatter (between leading `---` delimiters)
* and return a flat object of string or array values. Numeric / bool values are
* left as strings; array values like `[a, b, "c"]` are parsed into JS arrays.
*/
export function parseFrontmatter(filePath) {
const src = fs.readFileSync(filePath, "utf8");
const match = src.match(/^---\n([\s\S]+?)\n---/);
if (!match) return {};
const out = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^([\w-]+):\s*(.*)$/);
if (!m) continue;
let value = m[2].trim();
// Array values like [a, b, "c"] or []
if (value.startsWith("[") && value.endsWith("]")) {
const inner = value.slice(1, -1).trim();
if (inner === "") {
out[m[1]] = [];
} else {
out[m[1]] = inner.split(",").map((s) => {
let v = s.trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
return v;
});
}
continue;
}
// Strings (with optional quotes)
if (value.startsWith('"') && value.endsWith('"'))
value = value.slice(1, -1);
if (value.startsWith("'") && value.endsWith("'"))
value = value.slice(1, -1);
out[m[1]] = value;
}
return out;
}
/**
* Count `- [x]` and `- [ ]` checkboxes inside the file's `## Tasks` section.
* Returns { total, completed }.
*/
export function countTaskCheckboxes(content) {
const lines = content.split("\n");
let inTasks = false;
let total = 0;
let completed = 0;
for (const line of lines) {
if (line.startsWith("## ")) {
inTasks = /^##\s+Tasks\b/i.test(line);
continue;
}
if (!inTasks) continue;
const m = line.match(/^[\s>-]*\[(.)\]/);
if (m) {
total++;
if (m[1] === "x" || m[1] === "X") completed++;
}
}
return { total, completed };
}

View File

@@ -0,0 +1,387 @@
import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import {
buildState,
parseFrontmatter,
countTaskCheckboxes,
computeReadyBlocked,
} from "./state-builder.mjs";
function makeWorkTree({ epics }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-state-"));
for (const [epicId, epicData] of Object.entries(epics)) {
const epicDir = path.join(root, epicId);
fs.mkdirSync(epicDir, { recursive: true });
fs.writeFileSync(
path.join(epicDir, "_epic.md"),
`---
id: ${epicId}
title: ${epicData.title ?? epicId}
status: ${epicData.status ?? "todo"}
---
`,
);
for (const [storyId, storyData] of Object.entries(epicData.stories ?? {})) {
const storyDir = path.join(epicDir, storyId);
fs.mkdirSync(storyDir, { recursive: true });
const tasksBlock = (storyData.tasks ?? [])
.map((t) => `- [${t.done ? "x" : " "}] ${t.label}`)
.join("\n");
fs.writeFileSync(
path.join(storyDir, "_story.md"),
`---
id: ${storyId}
title: ${storyData.title ?? storyId}
status: ${storyData.status ?? "todo"}
---
## Tasks
${tasksBlock}
`,
);
}
}
return root;
}
describe("buildState", () => {
it("returns an empty epics map for a non-existent workRoot", () => {
const state = buildState("/nonexistent/path");
expect(state.epics).toEqual({});
expect(typeof state.updated_at).toBe("string");
});
it("collects epics + stories with status + task counts", () => {
const root = makeWorkTree({
epics: {
epic1: {
status: "in-progress",
stories: {
story1: {
status: "done",
tasks: [
{ label: "a", done: true },
{ label: "b", done: true },
],
},
story2: {
status: "todo",
tasks: [
{ label: "c", done: false },
{ label: "d", done: false },
{ label: "e", done: false },
],
},
},
},
},
});
const state = buildState(root);
expect(state.epics).toEqual({
epic1: {
status: "in-progress",
title: "epic1",
stories: {
story1: {
status: "done",
title: "story1",
ac_total: 2,
ac_completed: 2,
depends_on: [],
blocks: [],
},
story2: {
status: "todo",
title: "story2",
ac_total: 3,
ac_completed: 0,
depends_on: [],
blocks: [],
},
},
},
});
});
it("skips _templates and prds folders", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip-"));
fs.mkdirSync(path.join(root, "_templates"), { recursive: true });
fs.writeFileSync(
path.join(root, "_templates", "_epic.md"),
"---\nid: x\n---",
);
fs.mkdirSync(path.join(root, "prds"), { recursive: true });
fs.writeFileSync(path.join(root, "prds", "_epic.md"), "---\nid: y\n---");
expect(buildState(root).epics).toEqual({});
});
it("skips directories without _epic.md", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip2-"));
fs.mkdirSync(path.join(root, "incomplete"), { recursive: true });
expect(buildState(root).epics).toEqual({});
});
it("parses depends-on and blocks frontmatter arrays", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-dep-"));
const epicDir = path.join(root, "epic1");
fs.mkdirSync(epicDir, { recursive: true });
fs.writeFileSync(
path.join(epicDir, "_epic.md"),
`---
id: epic1
title: epic1
status: todo
---
`,
);
const story1Dir = path.join(epicDir, "story1");
fs.mkdirSync(story1Dir, { recursive: true });
fs.writeFileSync(
path.join(story1Dir, "_story.md"),
`---
id: story1
title: story1
status: todo
depends-on: []
blocks: [story2, other-epic/x]
---
`,
);
const story2Dir = path.join(epicDir, "story2");
fs.mkdirSync(story2Dir, { recursive: true });
fs.writeFileSync(
path.join(story2Dir, "_story.md"),
`---
id: story2
title: story2
status: todo
depends-on: [story1]
blocks: []
---
`,
);
const state = buildState(root);
expect(state.epics.epic1.stories.story1.depends_on).toEqual([]);
expect(state.epics.epic1.stories.story1.blocks).toEqual([
"story2",
"other-epic/x",
]);
expect(state.epics.epic1.stories.story2.depends_on).toEqual(["story1"]);
expect(state.epics.epic1.stories.story2.blocks).toEqual([]);
});
});
describe("countTaskCheckboxes", () => {
it("counts both unchecked and checked", () => {
const content = `## Tasks
- [x] done one
- [ ] open one
- [X] capital X also counts
`;
expect(countTaskCheckboxes(content)).toEqual({ total: 3, completed: 2 });
});
it("only counts inside the ## Tasks section", () => {
const content = `## Tasks
- [x] in tasks
## Notes
- [x] outside, should not count
`;
expect(countTaskCheckboxes(content)).toEqual({ total: 1, completed: 1 });
});
it("returns zeros when no Tasks section exists", () => {
expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({
total: 0,
completed: 0,
});
});
});
describe("parseFrontmatter", () => {
it("extracts simple string keys", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-"));
const fp = path.join(dir, "f.md");
fs.writeFileSync(
fp,
`---
id: x
title: A title
status: done
---
Body`,
);
expect(parseFrontmatter(fp)).toEqual({
id: "x",
title: "A title",
status: "done",
});
});
it("returns {} when no frontmatter present", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm2-"));
const fp = path.join(dir, "f.md");
fs.writeFileSync(fp, "# Just a heading\n");
expect(parseFrontmatter(fp)).toEqual({});
});
it("parses array frontmatter values", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-arr-"));
const fp = path.join(dir, "f.md");
fs.writeFileSync(
fp,
`---
id: x
items: [a, b, "c"]
empty: []
---
`,
);
expect(parseFrontmatter(fp)).toEqual({
id: "x",
items: ["a", "b", "c"],
empty: [],
});
});
});
describe("computeReadyBlocked", () => {
it("returns ready for stories whose depends_on are all done", () => {
const state = {
updated_at: "x",
epics: {
e: {
status: "in-progress",
title: "e",
stories: {
a: {
status: "done",
title: "a",
ac_total: 0,
ac_completed: 0,
depends_on: [],
blocks: [],
},
b: {
status: "todo",
title: "b",
ac_total: 0,
ac_completed: 0,
depends_on: ["a"],
blocks: [],
},
},
},
},
};
const { ready, blocked } = computeReadyBlocked(state);
expect(ready).toEqual([{ epic: "e", story: "b", title: "b" }]);
expect(blocked).toEqual([]);
});
it("returns blocked for stories whose depends_on are not done", () => {
const state = {
updated_at: "x",
epics: {
e: {
status: "in-progress",
title: "e",
stories: {
a: {
status: "todo",
title: "a",
ac_total: 0,
ac_completed: 0,
depends_on: [],
blocks: [],
},
b: {
status: "todo",
title: "b",
ac_total: 0,
ac_completed: 0,
depends_on: ["a"],
blocks: [],
},
},
},
},
};
const { ready, blocked } = computeReadyBlocked(state);
expect(ready).toEqual([{ epic: "e", story: "a", title: "a" }]);
expect(blocked).toEqual([
{ epic: "e", story: "b", title: "b", waiting_on: ["e/a"] },
]);
});
it("resolves cross-epic refs", () => {
const state = {
updated_at: "x",
epics: {
e1: {
status: "done",
title: "e1",
stories: {
a: {
status: "done",
title: "a",
ac_total: 0,
ac_completed: 0,
depends_on: [],
blocks: [],
},
},
},
e2: {
status: "in-progress",
title: "e2",
stories: {
b: {
status: "todo",
title: "b",
ac_total: 0,
ac_completed: 0,
depends_on: ["e1/a"],
blocks: [],
},
},
},
},
};
const { ready, blocked } = computeReadyBlocked(state);
expect(ready).toEqual([{ epic: "e2", story: "b", title: "b" }]);
expect(blocked).toEqual([]);
});
it("skips done stories", () => {
const state = {
updated_at: "x",
epics: {
e: {
status: "done",
title: "e",
stories: {
a: {
status: "done",
title: "a",
ac_total: 1,
ac_completed: 1,
depends_on: [],
blocks: [],
},
},
},
},
};
const { ready, blocked } = computeReadyBlocked(state);
expect(ready).toEqual([]);
expect(blocked).toEqual([]);
});
});

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Pre-commit guard: refuses the commit if docs/work/_system/_state.json is staged
* but is not byte-identical to what `pnpm work rebuild-state` would emit
* given the current markdown content of docs/work/.
*
* Run from .husky/pre-commit AFTER lint-staged and AFTER the conditional
* rebuild-state + re-stage step. By that point the staged _state.json
* should already match the rebuild output. This script is the safety net
* for the case where someone hand-edits _state.json without going through
* rebuild-state.
*
* Exit codes:
* 0 — _state.json is in sync (or not staged at all)
* 1 — _state.json is staged but out of sync
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
import { buildState } from "./state-builder.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const WORK_ROOT = path.join(REPO_ROOT, "docs", "work");
const STATE_FILE = path.join(WORK_ROOT, "_system", "_state.json");
function stagedFiles() {
const out = execSync("git diff --cached --name-only", {
cwd: REPO_ROOT,
encoding: "utf8",
});
return out.split("\n").filter(Boolean);
}
function main() {
const staged = stagedFiles();
const stateRel = path.relative(REPO_ROOT, STATE_FILE);
if (
!staged.includes(stateRel) &&
!staged.some((f) => f.startsWith("docs/work/") && f.endsWith(".md"))
) {
process.exit(0);
}
if (!fs.existsSync(STATE_FILE)) {
console.error(
"✗ state-sync-guard: docs/work/_system/_state.json missing. Run `pnpm work rebuild-state`.",
);
process.exit(1);
}
const onDisk = fs.readFileSync(STATE_FILE, "utf8");
const fresh = JSON.stringify(buildState(WORK_ROOT), null, 2) + "\n";
// The `updated_at` timestamp will always differ. Strip it from both sides
// before comparing.
const stripUpdatedAt = (s) =>
s.replace(/"updated_at":\s*"[^"]+",?\s*\n?/, "");
if (stripUpdatedAt(onDisk) === stripUpdatedAt(fresh)) {
process.exit(0);
}
console.error(
"✗ state-sync-guard: docs/work/_system/_state.json is out of sync with markdown.",
);
console.error(" Run: pnpm work rebuild-state");
console.error(" Then: git add docs/work/_system/_state.json");
process.exit(1);
}
main();

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { execSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const GUARD = path.join(__dirname, "state-sync-guard.mjs");
describe("state-sync-guard smoke", () => {
it("exits 0 when run against the repo's current state (since main is in sync)", () => {
// Run the guard; if main's _state.json drifts, this test will fail and tell us.
execSync(`node "${GUARD}"`, { encoding: "utf8" });
// No assertion on output; the exit code is 0 if we got here without throwing.
expect(true).toBe(true);
});
});