Initial commit
This commit is contained in:
28
hooks/shared/constants.ts
Normal file
28
hooks/shared/constants.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Shared constants for bun-runner hooks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* File extensions supported by Biome for linting and formatting.
|
||||
* Includes JavaScript, TypeScript, JSON, CSS, and GraphQL.
|
||||
*/
|
||||
export const BIOME_SUPPORTED_EXTENSIONS = [
|
||||
".js",
|
||||
".jsx",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".mts",
|
||||
".cts",
|
||||
".json",
|
||||
".jsonc",
|
||||
".css",
|
||||
".graphql",
|
||||
".gql",
|
||||
];
|
||||
|
||||
/**
|
||||
* File extensions supported by TypeScript compiler.
|
||||
*/
|
||||
export const TSC_SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
|
||||
117
hooks/shared/git-utils.ts
Normal file
117
hooks/shared/git-utils.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Shared git utilities for bun-runner hooks.
|
||||
* Provides git-aware file tracking and change detection.
|
||||
*/
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import { spawn } from "bun";
|
||||
|
||||
/**
|
||||
* Get the root directory of the current git repository.
|
||||
*
|
||||
* @returns The absolute path to the git root, or null if not in a git repo
|
||||
*/
|
||||
export async function getGitRoot(): Promise<string | null> {
|
||||
const proc = spawn({
|
||||
cmd: ["git", "rev-parse", "--show-toplevel"],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) return null;
|
||||
|
||||
const output = await new Response(proc.stdout).text();
|
||||
return output.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is inside the current git repository.
|
||||
* Returns true for any file inside the repo directory, including untracked files.
|
||||
*
|
||||
* @param filePath - Path to the file to check
|
||||
* @returns true if file is inside the git repo, false otherwise
|
||||
*/
|
||||
export async function isFileInRepo(filePath: string): Promise<boolean> {
|
||||
const gitRoot = await getGitRoot();
|
||||
if (!gitRoot) return false;
|
||||
|
||||
const absolutePath = resolve(filePath);
|
||||
return absolutePath.startsWith(gitRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of files that have been modified, staged, or are untracked in git.
|
||||
* Used by Stop hooks for end-of-turn validation.
|
||||
*
|
||||
* Includes:
|
||||
* - Staged files (git diff --cached)
|
||||
* - Unstaged modified files (git diff)
|
||||
* - Untracked files (git ls-files --others --exclude-standard)
|
||||
*
|
||||
* @param extensions - Optional array of file extensions to filter by
|
||||
* @returns Array of changed file paths
|
||||
*/
|
||||
export async function getChangedFiles(
|
||||
extensions?: string[],
|
||||
): Promise<string[]> {
|
||||
const files = new Set<string>();
|
||||
|
||||
// Get staged files
|
||||
const stagedProc = spawn({
|
||||
cmd: ["git", "diff", "--cached", "--name-only"],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
await stagedProc.exited;
|
||||
const stagedOutput = await new Response(stagedProc.stdout).text();
|
||||
for (const file of stagedOutput.trim().split("\n")) {
|
||||
if (file) files.add(file);
|
||||
}
|
||||
|
||||
// Get unstaged modified files
|
||||
const modifiedProc = spawn({
|
||||
cmd: ["git", "diff", "--name-only"],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
await modifiedProc.exited;
|
||||
const modifiedOutput = await new Response(modifiedProc.stdout).text();
|
||||
for (const file of modifiedOutput.trim().split("\n")) {
|
||||
if (file) files.add(file);
|
||||
}
|
||||
|
||||
// Get untracked files (newly created files not yet added to git)
|
||||
const untrackedProc = spawn({
|
||||
cmd: ["git", "ls-files", "--others", "--exclude-standard"],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
await untrackedProc.exited;
|
||||
const untrackedOutput = await new Response(untrackedProc.stdout).text();
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
if (file) files.add(file);
|
||||
}
|
||||
|
||||
const allFiles = Array.from(files);
|
||||
|
||||
// Filter by extensions if provided
|
||||
if (extensions && extensions.length > 0) {
|
||||
return allFiles.filter((file) =>
|
||||
extensions.some((ext) => file.endsWith(ext)),
|
||||
);
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any files with given extensions have been modified or staged.
|
||||
* Used by Stop hooks to decide whether to run project-wide checks.
|
||||
*
|
||||
* @param extensions - Array of file extensions to check for
|
||||
* @returns true if any matching files have changed
|
||||
*/
|
||||
export async function hasChangedFiles(extensions: string[]): Promise<boolean> {
|
||||
const changedFiles = await getChangedFiles(extensions);
|
||||
return changedFiles.length > 0;
|
||||
}
|
||||
92
hooks/shared/types.test.ts
Normal file
92
hooks/shared/types.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractFilePaths, type HookInput, parseHookInput } from "./types";
|
||||
|
||||
describe("extractFilePaths", () => {
|
||||
test("extracts single file_path", () => {
|
||||
const input: HookInput = {
|
||||
tool_name: "Write",
|
||||
tool_input: { file_path: "/path/to/file.ts" },
|
||||
};
|
||||
|
||||
const result = extractFilePaths(input);
|
||||
|
||||
expect(result).toEqual(["/path/to/file.ts"]);
|
||||
});
|
||||
|
||||
test("extracts multiple file_paths from edits", () => {
|
||||
const input: HookInput = {
|
||||
tool_name: "Edit",
|
||||
tool_input: {
|
||||
edits: [{ file_path: "/path/a.ts" }, { file_path: "/path/b.ts" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = extractFilePaths(input);
|
||||
|
||||
expect(result).toEqual(["/path/a.ts", "/path/b.ts"]);
|
||||
});
|
||||
|
||||
test("deduplicates file_paths", () => {
|
||||
const input: HookInput = {
|
||||
tool_name: "Edit",
|
||||
tool_input: {
|
||||
file_path: "/path/a.ts",
|
||||
edits: [{ file_path: "/path/a.ts" }, { file_path: "/path/b.ts" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = extractFilePaths(input);
|
||||
|
||||
expect(result).toEqual(["/path/a.ts", "/path/b.ts"]);
|
||||
});
|
||||
|
||||
test("returns empty array when tool_input is undefined", () => {
|
||||
const input: HookInput = {
|
||||
tool_name: "SomeOtherTool",
|
||||
tool_input: undefined,
|
||||
};
|
||||
|
||||
const result = extractFilePaths(input);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty array when tool_input has no file fields", () => {
|
||||
const input: HookInput = {
|
||||
tool_name: "Read",
|
||||
tool_input: {},
|
||||
};
|
||||
|
||||
const result = extractFilePaths(input);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHookInput", () => {
|
||||
test("parses valid JSON", () => {
|
||||
const json = JSON.stringify({
|
||||
tool_name: "Write",
|
||||
tool_input: { file_path: "/test.ts" },
|
||||
});
|
||||
|
||||
const result = parseHookInput(json);
|
||||
|
||||
expect(result).toEqual({
|
||||
tool_name: "Write",
|
||||
tool_input: { file_path: "/test.ts" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null for invalid JSON", () => {
|
||||
const result = parseHookInput("not valid json");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
const result = parseHookInput("");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
80
hooks/shared/types.ts
Normal file
80
hooks/shared/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Shared types for bun-runner hooks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hook input contract from Claude Code PostToolUse events.
|
||||
* This is a partial type modeling only the file-related fields we use.
|
||||
*
|
||||
* @see https://docs.anthropic.com/en/docs/claude-code/hooks#posttooluse-input
|
||||
*/
|
||||
export interface HookInput {
|
||||
tool_name: string;
|
||||
tool_input?: {
|
||||
file_path?: string;
|
||||
edits?: Array<{ file_path: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed TypeScript compiler error.
|
||||
*/
|
||||
export interface TscError {
|
||||
file: string;
|
||||
line: number;
|
||||
col: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing TypeScript compiler output.
|
||||
*/
|
||||
export interface TscParseResult {
|
||||
errorCount: number;
|
||||
errors: TscError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file paths from hook input.
|
||||
* Handles both single file (Write) and multiple files (Edit/MultiEdit).
|
||||
*
|
||||
* @param hookInput - The parsed hook input from stdin
|
||||
* @returns Array of file paths from the tool input
|
||||
*/
|
||||
export function extractFilePaths(hookInput: HookInput): string[] {
|
||||
const filePaths: string[] = [];
|
||||
|
||||
// Guard against missing tool_input (some hook events may not have it)
|
||||
if (!hookInput.tool_input) {
|
||||
return filePaths;
|
||||
}
|
||||
|
||||
if (hookInput.tool_input.file_path) {
|
||||
filePaths.push(hookInput.tool_input.file_path);
|
||||
}
|
||||
|
||||
if (hookInput.tool_input.edits) {
|
||||
for (const edit of hookInput.tool_input.edits) {
|
||||
if (edit.file_path && !filePaths.includes(edit.file_path)) {
|
||||
filePaths.push(edit.file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hook input from stdin JSON.
|
||||
* Returns null if parsing fails (graceful degradation).
|
||||
*
|
||||
* @param input - Raw stdin text
|
||||
* @returns Parsed HookInput or null if invalid
|
||||
*/
|
||||
export function parseHookInput(input: string): HookInput | null {
|
||||
try {
|
||||
return JSON.parse(input) as HookInput;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user