From 1bee0544de46e0fb68b6036823dd858cf80bbfd9 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Thu, 14 May 2026 17:32:48 +0000 Subject: [PATCH] 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 --- scripts/library-decisions/check.mjs | 242 +++++++++++++++++++++++ scripts/library-decisions/check.test.mjs | 162 ++++++++++++++- 2 files changed, 403 insertions(+), 1 deletion(-) diff --git a/scripts/library-decisions/check.mjs b/scripts/library-decisions/check.mjs index 7054598..f479d50 100644 --- a/scripts/library-decisions/check.mjs +++ b/scripts/library-decisions/check.mjs @@ -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 *-.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) { diff --git a/scripts/library-decisions/check.test.mjs b/scripts/library-decisions/check.test.mjs index 046a83b..a14d8ce 100644 --- a/scripts/library-decisions/check.test.mjs +++ b/scripts/library-decisions/check.test.mjs @@ -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, []); + }); +});