Adds `--staged-against <base>` CLI flag to `check.mjs` so the reviewer agent can compare `git diff <base>...HEAD` instead of the git index. This gives the sandcastle reviewer a CI-compatible code path that works in its clean sandbox where `git diff --cached` may be empty. Appends a "Library-trace check" section to `.sandcastle/reviewer.prompt.md` instructing the reviewer to run the command before issuing a verdict. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
5.4 KiB
JavaScript
176 lines
5.4 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 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 ?? {}));
|
|
return Object.keys(staged.dependencies ?? {}).filter((d) => !baseDeps.has(d));
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
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);
|
|
}
|