From 26bcbb7a9128d6f856e0aa14be80cf950180bde2 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Thu, 14 May 2026 05:57:10 +0000 Subject: [PATCH] feat(scripts): add --staged-against flag to library-decisions check Adds `--staged-against ` CLI flag to `check.mjs` so the reviewer agent can compare `git diff ...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 --- .sandcastle/reviewer.prompt.md | 10 +++++ scripts/library-decisions/check.mjs | 48 +++++++++++++++++------- scripts/library-decisions/check.test.mjs | 17 +++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/.sandcastle/reviewer.prompt.md b/.sandcastle/reviewer.prompt.md index 223aa66..b5b18e9 100644 --- a/.sandcastle/reviewer.prompt.md +++ b/.sandcastle/reviewer.prompt.md @@ -80,6 +80,16 @@ Return structured JSON: If you reject, the orchestrator passes your notes back to the implementer for a fix-up cycle (up to the task's `max-attempts`, default 3). +## Library-trace check + +Before issuing your verdict, run: + +```bash +node scripts/library-decisions/check.mjs --staged-against +``` + +where `` is the PR's base branch (typically `main`). If the command exits non-zero, **reject** the slice: a new runtime dependency in a feature- or core-tier package is missing an approved library-decision trace. The implementer must run the evaluate-library skill (`.claude/skills/evaluate-library/SKILL.md`) and add the resulting `docs/library-decisions/*.md` trace before the slice can be approved. + ## Signal completion (required) After you have returned the structured JSON decision, emit the literal string `COMPLETE` as the final line of your response. diff --git a/scripts/library-decisions/check.mjs b/scripts/library-decisions/check.mjs index 1bdfae8..7054598 100644 --- a/scripts/library-decisions/check.mjs +++ b/scripts/library-decisions/check.mjs @@ -23,11 +23,11 @@ function deriveTier(relPath) { return "skip"; // root package.json or unknown path } -function stagedFilesList(repoRoot) { - return execSync("git diff --cached --name-only", { - cwd: repoRoot, - encoding: "utf8", - }) +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); } @@ -35,12 +35,17 @@ function stagedFilesList(repoRoot) { /** * 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) { +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 ":${relPath}"`, { cwd: repoRoot, encoding: "utf8" }), + execSync(`git show "${currentRef}"`, { cwd: repoRoot, encoding: "utf8" }), ); } catch { return []; @@ -48,13 +53,13 @@ function getNewRuntimeDeps(relPath, repoRoot) { let base = {}; try { base = JSON.parse( - execSync(`git show "HEAD:${relPath}"`, { + execSync(`git show "${ancestorRef}"`, { cwd: repoRoot, encoding: "utf8", }), ); } catch { - // New file or initial commit — treat all staged deps as new + // 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)); @@ -78,8 +83,11 @@ function findStagedTrace(depName, staged) { * * An empty array means the commit is clean. */ -export function checkLibraryDecisions(repoRoot = DEFAULT_REPO_ROOT) { - const staged = stagedFilesList(repoRoot); +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"), ); @@ -89,14 +97,15 @@ export function checkLibraryDecisions(repoRoot = DEFAULT_REPO_ROOT) { const tier = deriveTier(relPath); if (tier === "app" || tier === "skip") continue; - for (const dep of getNewRuntimeDeps(relPath, repoRoot)) { + 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 content = execSync(`git show ":${traceFile}"`, { + const traceRef = stagedAgainst ? `HEAD:${traceFile}` : `:${traceFile}`; + const content = execSync(`git show "${traceRef}"`, { cwd: repoRoot, encoding: "utf8", }); @@ -125,7 +134,18 @@ export function checkLibraryDecisions(repoRoot = DEFAULT_REPO_ROOT) { // CLI entry point — only runs when executed directly, not when imported. if (process.argv[1] === fileURLToPath(import.meta.url)) { - const errors = checkLibraryDecisions(); + 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( diff --git a/scripts/library-decisions/check.test.mjs b/scripts/library-decisions/check.test.mjs index 31b0708..046a83b 100644 --- a/scripts/library-decisions/check.test.mjs +++ b/scripts/library-decisions/check.test.mjs @@ -163,4 +163,21 @@ describe("checkLibraryDecisions", () => { assert.deepEqual(checkLibraryDecisions(dir), []); }); + + test("--staged-against mode: new feature-tier dep without trace → exit 1", () => { + const { dir, g } = makeRepo(); + // Baseline commit: feature package with no deps + commitPkg(dir, g, "packages/feat-a", { dependencies: {} }); + // Second commit: adds new-lib — no trace file committed alongside it + commitPkg(dir, g, "packages/feat-a", { + dependencies: { "new-lib": "^1.0.0" }, + }); + + // HEAD has new-lib; HEAD~1 doesn't — no trace in the diff → exit 1 + const errs = checkLibraryDecisions(dir, { stagedAgainst: "HEAD~1" }); + + assert.equal(errs.length, 1); + assert.equal(errs[0].dep, "new-lib"); + assert.equal(errs[0].reason, "no-trace"); + }); });