Walks every approved/pre-shipped trace, re-runs its verification-commands, classifies soft/hard divergence, and manages GitHub issues via the gh CLI: - hard drift (non-zero exit or CVE/abandoned keywords) → per-dep library-policy/re-evaluation issue; duplicate-issue guard prevents spam - soft drift (dormant/warning/deprecated keywords at exit 0) → rolling library-policy/dashboard issue (create or update) - clean + lastRevalidated set → close any stale re-evaluation issue - rejected traces skipped entirely ghRunner and commandRunner are injectable for hermetic integration tests; 12 fixture-based tests cover all six story scenarios plus edge cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
337 lines
9.4 KiB
JavaScript
337 lines
9.4 KiB
JavaScript
#!/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}`);
|
|
}
|
|
}
|
|
}
|