Files
agentic-dev/scripts/library-decisions/check.mjs
Danijel Martinek 26bcbb7a91 feat(scripts): add --staged-against flag to library-decisions check
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>
2026-05-14 05:57:10 +00:00

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);
}