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>
This commit is contained in:
@@ -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 <base-branch>
|
||||
```
|
||||
|
||||
where `<base-branch>` 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 `<promise>COMPLETE</promise>` as the final line of your response.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user