Initial commit
This commit is contained in:
62
scripts/compliance/emit-all.mjs
Normal file
62
scripts/compliance/emit-all.mjs
Normal 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");
|
||||
}
|
||||
412
scripts/compliance/emit-data-map.mjs
Normal file
412
scripts/compliance/emit-data-map.mjs
Normal 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();
|
||||
}
|
||||
354
scripts/compliance/emit-data-map.test.mjs
Normal file
354
scripts/compliance/emit-data-map.test.mjs
Normal 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"));
|
||||
});
|
||||
});
|
||||
228
scripts/compliance/emit-retention-policy.mjs
Normal file
228
scripts/compliance/emit-retention-policy.mjs
Normal 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();
|
||||
}
|
||||
292
scripts/compliance/emit-retention-policy.test.mjs
Normal file
292
scripts/compliance/emit-retention-policy.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
339
scripts/compliance/emit-sub-processors.mjs
Normal file
339
scripts/compliance/emit-sub-processors.mjs
Normal 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();
|
||||
}
|
||||
512
scripts/compliance/emit-sub-processors.test.mjs
Normal file
512
scripts/compliance/emit-sub-processors.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
102
scripts/conformance.mjs
Normal file
102
scripts/conformance.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/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();
|
||||
}
|
||||
83
scripts/conformance.test.mjs
Normal file
83
scripts/conformance.test.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
22
scripts/coverage/__fixtures__/aggregate-pkg-a.lcov
Normal file
22
scripts/coverage/__fixtures__/aggregate-pkg-a.lcov
Normal 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
|
||||
13
scripts/coverage/__fixtures__/aggregate-pkg-b.lcov
Normal file
13
scripts/coverage/__fixtures__/aggregate-pkg-b.lcov
Normal 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
|
||||
49
scripts/coverage/__fixtures__/sample-diff.patch
Normal file
49
scripts/coverage/__fixtures__/sample-diff.patch
Normal 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
|
||||
25
scripts/coverage/__fixtures__/sample.lcov
Normal file
25
scripts/coverage/__fixtures__/sample.lcov
Normal 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
|
||||
211
scripts/coverage/aggregate.mjs
Normal file
211
scripts/coverage/aggregate.mjs
Normal 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();
|
||||
}
|
||||
174
scripts/coverage/aggregate.test.mjs
Normal file
174
scripts/coverage/aggregate.test.mjs
Normal 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
340
scripts/coverage/diff.mjs
Normal 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, web-tanstack, 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();
|
||||
}
|
||||
314
scripts/coverage/diff.test.mjs
Normal file
314
scripts/coverage/diff.test.mjs
Normal file
@@ -0,0 +1,314 @@
|
||||
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 web-tanstack 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/web-tanstack/src/instrumentation-client.ts",
|
||||
new Set([1, 2, 3, 4, 5]),
|
||||
],
|
||||
["apps/web-tanstack/app.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
158
scripts/coverage/mutate.mjs
Normal 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();
|
||||
}
|
||||
71
scripts/coverage/mutate.test.mjs
Normal file
71
scripts/coverage/mutate.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
452
scripts/library-decisions/check.mjs
Normal file
452
scripts/library-decisions/check.mjs
Normal 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);
|
||||
}
|
||||
383
scripts/library-decisions/check.test.mjs
Normal file
383
scripts/library-decisions/check.test.mjs
Normal 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, []);
|
||||
});
|
||||
});
|
||||
336
scripts/library-decisions/revalidate.mjs
Normal file
336
scripts/library-decisions/revalidate.mjs
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
513
scripts/library-decisions/revalidate.test.mjs
Normal file
513
scripts/library-decisions/revalidate.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
145
scripts/library-decisions/schema.mjs
Normal file
145
scripts/library-decisions/schema.mjs
Normal 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);
|
||||
}
|
||||
220
scripts/library-decisions/schema.test.mjs
Normal file
220
scripts/library-decisions/schema.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
78
scripts/work/bump-updated-timestamps.mjs
Normal file
78
scripts/work/bump-updated-timestamps.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/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.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
180
scripts/work/cli.mjs
Normal 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
59
scripts/work/cli.test.mjs
Normal 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
265
scripts/work/decompose.mjs
Normal 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));
|
||||
}
|
||||
162
scripts/work/decompose.test.mjs
Normal file
162
scripts/work/decompose.test.mjs
Normal 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
666
scripts/work/dispatch.mjs
Normal 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));
|
||||
}
|
||||
200
scripts/work/dispatch.test.mjs
Normal file
200
scripts/work/dispatch.test.mjs
Normal 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
240
scripts/work/prd-ship.mjs
Normal 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;
|
||||
}
|
||||
190
scripts/work/prd-ship.test.mjs
Normal file
190
scripts/work/prd-ship.test.mjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
235
scripts/work/state-builder.mjs
Normal file
235
scripts/work/state-builder.mjs
Normal 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 };
|
||||
}
|
||||
387
scripts/work/state-builder.test.mjs
Normal file
387
scripts/work/state-builder.test.mjs
Normal 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([]);
|
||||
});
|
||||
});
|
||||
68
scripts/work/state-sync-guard.mjs
Normal file
68
scripts/work/state-sync-guard.mjs
Normal 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();
|
||||
16
scripts/work/state-sync-guard.test.mjs
Normal file
16
scripts/work/state-sync-guard.test.mjs
Normal 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.
|
||||
const result = execSync(`node "${GUARD}"`, { encoding: "utf8" });
|
||||
// No assertion on output; the exit code is 0 if we got here without throwing.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user