mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-01 21:30:21 +00:00
The validate-file.ts hook previously only blocked `set -u` when
`set -eo pipefail` was absent from the file. This allowed scripts
with both `set -eo pipefail` and `set -u` to pass validation,
contradicting the shell rules that unconditionally ban nounset.
Fix the regex to always reject `set -u` variants on actual set
invocation lines (not comments or strings), and update the error
message to recommend `${VAR:-}` instead.
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
125 lines
3.1 KiB
TypeScript
125 lines
3.1 KiB
TypeScript
/**
|
|
* PostToolUse hook for Write|Edit — validates shell and TypeScript files after modification.
|
|
*
|
|
* Reads CLAUDE_FILE env var. Performs:
|
|
* - .sh files: bash -n syntax check, relative source detection, echo -e, set -u
|
|
* - .ts files: banner comment detection, biome lint + format
|
|
*
|
|
* Blocks (exit 2) on any failure.
|
|
*/
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
|
|
const file = process.env.CLAUDE_FILE;
|
|
if (!file) {
|
|
process.exit(0);
|
|
}
|
|
|
|
function fail(msg: string): never {
|
|
console.error(msg);
|
|
process.exit(2);
|
|
}
|
|
|
|
function run(
|
|
cmd: string,
|
|
args: string[],
|
|
opts?: {
|
|
cwd?: string;
|
|
},
|
|
): string {
|
|
return execFileSync(cmd, args, {
|
|
encoding: "utf-8",
|
|
cwd: opts?.cwd,
|
|
timeout: 60_000,
|
|
});
|
|
}
|
|
|
|
// --- Shell file checks ---
|
|
if (file.endsWith(".sh")) {
|
|
// bash -n syntax check
|
|
try {
|
|
run("bash", [
|
|
"-n",
|
|
file,
|
|
]);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
fail(`SYNTAX ERROR in ${file}\n${msg}`);
|
|
}
|
|
|
|
let content: string;
|
|
try {
|
|
content = readFileSync(file, "utf-8");
|
|
} catch {
|
|
process.exit(0);
|
|
}
|
|
|
|
// Check for relative source patterns
|
|
if (/source\s+\.\.?\//.test(content)) {
|
|
fail(`RELATIVE SOURCE detected in ${file} — breaks curl|bash execution`);
|
|
}
|
|
|
|
// Check for echo -e (macOS bash 3.x compat)
|
|
if (/echo\s+-e\s/.test(content)) {
|
|
fail(`echo -e detected in ${file} — use printf instead (macOS bash 3.x compat)`);
|
|
}
|
|
|
|
// Check for set -u (nounset) — always banned, even alongside set -eo pipefail.
|
|
// Only match lines that actually invoke set (not comments or string literals).
|
|
const setUPattern = /^\s*set\s+-[a-z]*u/m;
|
|
if (setUPattern.test(content)) {
|
|
fail(`set -u (nounset) detected in ${file} — use \${VAR:-} for optional vars instead`);
|
|
}
|
|
}
|
|
|
|
// --- TypeScript file checks ---
|
|
if (file.endsWith(".ts")) {
|
|
let content: string;
|
|
try {
|
|
content = readFileSync(file, "utf-8");
|
|
} catch {
|
|
process.exit(0);
|
|
}
|
|
|
|
// Check for banner comments (lines of 10+ dashes, equals, asterisks, or hashes)
|
|
const lines = content.split("\n");
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (/^\s*\/\/\s*[-=*#]{10,}\s*$/.test(lines[i])) {
|
|
fail(`BANNER COMMENT at ${file}:${i + 1} — use // #region Name / // #endregion instead`);
|
|
}
|
|
}
|
|
|
|
// Find biome config by walking up from the file's directory to the repo root
|
|
let biomeDir: string | null = null;
|
|
let searchDir = dirname(file);
|
|
const root = resolve("/");
|
|
while (searchDir !== root) {
|
|
if (existsSync(resolve(searchDir, "biome.json")) || existsSync(resolve(searchDir, "biome.jsonc"))) {
|
|
biomeDir = searchDir;
|
|
break;
|
|
}
|
|
searchDir = resolve(searchDir, "..");
|
|
}
|
|
|
|
if (biomeDir) {
|
|
// Run biome check (lint + format) in a single pass
|
|
try {
|
|
run(
|
|
"bunx",
|
|
[
|
|
"@biomejs/biome",
|
|
"check",
|
|
file,
|
|
],
|
|
{
|
|
cwd: biomeDir,
|
|
},
|
|
);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
fail(`BIOME CHECK FAILED for ${file}\n${msg}`);
|
|
}
|
|
}
|
|
}
|