#!/usr/bin/env node /** * Pre-commit guard: refuses the commit if a new runtime dependency in a * feature- or core-tier package lacks a staged approved library trace. * * Exit codes: * 0 — all staged deps have approved traces (or are app-tier / devDeps / peerDeps) * 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"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_REPO_ROOT = path.resolve(__dirname, "..", ".."); /** Derive package tier from its repo-relative path. */ function deriveTier(relPath) { if (relPath.startsWith("apps/")) return "app"; if (relPath.startsWith("packages/core-")) return "core"; if (relPath.startsWith("packages/")) return "feature"; return "skip"; // root package.json or unknown path } function stagedFilesList(repoRoot, baseRef) { const cmd = baseRef ? `git diff ${baseRef}...HEAD --name-only` : "git diff --cached --name-only"; return execSync(cmd, { cwd: repoRoot, encoding: "utf8" }) .split("\n") .filter(Boolean); } /** * Return the names of runtime deps that are new in the staged version of * relPath compared to HEAD. Returns [] when the file can't be read. * * When baseRef is set, compares HEAD against baseRef instead of the index. */ function getNewRuntimeDeps(relPath, repoRoot, baseRef) { const currentRef = baseRef ? `HEAD:${relPath}` : `:${relPath}`; const ancestorRef = baseRef ? `${baseRef}:${relPath}` : `HEAD:${relPath}`; let staged; try { staged = JSON.parse( execSync(`git show "${currentRef}"`, { cwd: repoRoot, encoding: "utf8" }), ); } catch { return []; } let base = {}; try { base = JSON.parse( execSync(`git show "${ancestorRef}"`, { cwd: repoRoot, encoding: "utf8", }), ); } catch { // New file or initial commit — treat all deps as new } const baseDeps = new Set(Object.keys(base.dependencies ?? {})); const stagedDeps = staged.dependencies ?? {}; return Object.keys(stagedDeps).filter( (d) => !baseDeps.has(d) && // Workspace-protocol entries are internal monorepo packages — not // third-party libraries, so they don't require a library trace. !String(stagedDeps[d]).startsWith("workspace:"), ); } /** * Search the staged-files list for a trace file whose name ends with * `-.md` inside docs/library-decisions/. */ function findStagedTrace(depName, staged) { const safe = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`^docs/library-decisions/[^/]+-${safe}\\.md$`); 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. * * Returns an array of error objects, each with: * { pkgJson, dep, reason: "no-trace" | "not-approved" | "parse-error", ... } * * An empty array means the commit is clean. */ export function checkLibraryDecisions( repoRoot = DEFAULT_REPO_ROOT, { stagedAgainst } = {}, ) { const staged = stagedFilesList(repoRoot, stagedAgainst); const pkgJsons = staged.filter( (f) => f === "package.json" || f.endsWith("/package.json"), ); const errors = []; for (const relPath of pkgJsons) { const tier = deriveTier(relPath); if (tier === "app" || tier === "skip") continue; for (const dep of getNewRuntimeDeps(relPath, repoRoot, stagedAgainst)) { const traceFile = findStagedTrace(dep, staged); if (!traceFile) { errors.push({ pkgJson: relPath, dep, reason: "no-trace" }); continue; } try { const traceRef = stagedAgainst ? `HEAD:${traceFile}` : `:${traceFile}`; const content = execSync(`git show "${traceRef}"`, { cwd: repoRoot, encoding: "utf8", }); const fm = parseFrontmatter(content); if (fm.decision !== "approved") { errors.push({ pkgJson: relPath, dep, reason: "not-approved", decision: fm.decision, }); } } catch (e) { errors.push({ pkgJson: relPath, dep, reason: "parse-error", detail: String(e.message), }); } } } return errors; } // 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) { stagedAgainst = args[flagIdx + 1]; if (!stagedAgainst || stagedAgainst.startsWith("--")) { console.error("Error: --staged-against requires a base ref argument"); process.exit(1); } } const errors = checkLibraryDecisions(DEFAULT_REPO_ROOT, { stagedAgainst }); if (!errors.length) process.exit(0); console.error( "✗ library-decisions/check: new runtime deps require an approved trace.\n", ); const groups = {}; for (const e of errors) { (groups[e.pkgJson] ??= []).push(e); } for (const [pkg, errs] of Object.entries(groups)) { console.error(` ${pkg}:`); for (const e of errs) { const msg = e.reason === "no-trace" ? "no staged trace in docs/library-decisions/" : e.reason === "not-approved" ? `trace decision is "${e.decision}" (expected "approved")` : `trace parse error — ${e.detail}`; console.error(` ✗ ${e.dep}: ${msg}`); } } console.error( "\n Evaluate the library first: .claude/skills/evaluate-library/SKILL.md", ); process.exit(1); }