Files
agentic-dev/scripts/library-decisions/check.mjs
Danijel Martinek bce9ded915 feat(core-ui): scaffold @repo/core-ui via generator
Runs pnpm turbo gen core-package ui to produce the package shell:
atomic-design components (Button, Input, Label, FormField), vitest
config excluding story files from coverage, and transpilePackages
wiring in web-next. Adds @vitest/coverage-v8 devDep and
label.stories.tsx to satisfy lint/coverage gates.

Also fixes scripts/library-decisions/check.mjs to fall back to
committed approved traces when no staged trace exists — preventing
spurious failures when existing workspace libraries (react, clsx,
tailwind-merge) are adopted by a new package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:59:48 +00:00

453 lines
14 KiB
JavaScript

#!/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
* `-<depName>.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 *-<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.
*
* 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 stagedTrace = findStagedTrace(dep, staged);
if (!stagedTrace) {
// Fall back to an already-committed trace — if one exists and is
// approved, the dep was previously evaluated and doesn't need
// re-staging just because a new package adopts it.
const committedTrace = findExistingTrace(dep, repoRoot);
if (committedTrace) {
try {
const content = fs.readFileSync(committedTrace, "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),
});
}
continue;
}
errors.push({ pkgJson: relPath, dep, reason: "no-trace" });
continue;
}
try {
const traceRef = stagedAgainst
? `HEAD:${stagedTrace}`
: `:${stagedTrace}`;
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);
}