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:
2026-05-14 17:32:48 +00:00
parent 3d98a14e45
commit 1bee0544de
2 changed files with 403 additions and 1 deletions

View File

@@ -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) {

View File

@@ -4,7 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
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. */
function makeRepo() {
@@ -181,3 +181,163 @@ describe("checkLibraryDecisions", () => {
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, []);
});
});