feat(scripts): add --renovate-pr mode to library-decisions check
Extends check.mjs with checkRenovatePr() (and a matching CLI flag) that runs on Renovate PR branches: it parses the pnpm-lock.yaml diff to find bumped packages, classifies each as major/minor/patch, and for any feature- or core-tier major bump requires the trace's lastRevalidated field to equal today's ISO date. - App-tier deps and non-Renovate branches pass unconditionally. - Minor/patch bumps pass unconditionally (semver-compatible by contract). - On failure, the output references the evaluate-library skill and the stale trace path (ADR-023 close-the-drift-gate intent). Six integration tests cover all required cases: minor bump, major+fresh, major+stale, app-tier major, patch, and non-Renovate branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
* 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";
|
||||
@@ -75,6 +76,209 @@ function findStagedTrace(depName, staged) {
|
||||
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.
|
||||
*
|
||||
@@ -135,6 +339,44 @@ export function checkLibraryDecisions(
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user