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
|
* 1 — one or more feature/core deps are missing an approved trace
|
||||||
*/
|
*/
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { parseFrontmatter } from "./schema.mjs";
|
import { parseFrontmatter } from "./schema.mjs";
|
||||||
@@ -75,6 +76,209 @@ function findStagedTrace(depName, staged) {
|
|||||||
return staged.find((f) => re.test(f)) ?? null;
|
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.
|
* 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.
|
// CLI entry point — only runs when executed directly, not when imported.
|
||||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
const args = process.argv.slice(2);
|
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;
|
let stagedAgainst;
|
||||||
const flagIdx = args.indexOf("--staged-against");
|
const flagIdx = args.indexOf("--staged-against");
|
||||||
if (flagIdx !== -1) {
|
if (flagIdx !== -1) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { checkLibraryDecisions } from "./check.mjs";
|
import { checkLibraryDecisions, checkRenovatePr } from "./check.mjs";
|
||||||
|
|
||||||
/** Create a temp git repo with one initial commit so HEAD exists. */
|
/** Create a temp git repo with one initial commit so HEAD exists. */
|
||||||
function makeRepo() {
|
function makeRepo() {
|
||||||
@@ -181,3 +181,163 @@ describe("checkLibraryDecisions", () => {
|
|||||||
assert.equal(errs[0].reason, "no-trace");
|
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, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user