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>
453 lines
14 KiB
JavaScript
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);
|
|
}
|