#!/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 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) { return execSync("git diff --cached --name-only", { 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. */ function getNewRuntimeDeps(relPath, repoRoot) { let staged; try { staged = JSON.parse( execSync(`git show ":${relPath}"`, { cwd: repoRoot, encoding: "utf8" }), ); } catch { return []; } let base = {}; try { base = JSON.parse( execSync(`git show "HEAD:${relPath}"`, { cwd: repoRoot, encoding: "utf8", }), ); } catch { // New file or initial commit — treat all staged deps as new } const baseDeps = new Set(Object.keys(base.dependencies ?? {})); return Object.keys(staged.dependencies ?? {}).filter((d) => !baseDeps.has(d)); } /** * 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; } /** * 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) { const staged = stagedFilesList(repoRoot); 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)) { const traceFile = findStagedTrace(dep, staged); if (!traceFile) { errors.push({ pkgJson: relPath, dep, reason: "no-trace" }); continue; } try { const content = execSync(`git show ":${traceFile}"`, { 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 errors = checkLibraryDecisions(); 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); }