Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "bun-runner",
|
||||||
|
"description": "Smart test runner and linter MCP server for Bun and Biome",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Nathan Vale",
|
||||||
|
"email": "hi@nathanvale.com"
|
||||||
|
},
|
||||||
|
"hooks": [
|
||||||
|
"./hooks"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# bun-runner
|
||||||
|
|
||||||
|
Smart test runner and linter MCP server for Bun and Biome
|
||||||
119
hooks/biome-check.ts
Normal file
119
hooks/biome-check.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostToolUse hook that runs Biome check --write on edited files.
|
||||||
|
* Automatically fixes formatting and lint issues after Write/Edit/MultiEdit.
|
||||||
|
*
|
||||||
|
* Git-aware: Only processes files that are tracked by git or staged.
|
||||||
|
* Uses the shared parseBiomeOutput function for structured, token-efficient output.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* - 0: Success (file fixed or already clean, or unsupported file type)
|
||||||
|
* - 2: Blocking error (unfixable lint errors remain, shown to Claude)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "bun";
|
||||||
|
import { parseBiomeOutput } from "../mcp-servers/bun-runner/index";
|
||||||
|
import { BIOME_SUPPORTED_EXTENSIONS } from "./shared/constants";
|
||||||
|
import { isFileInRepo } from "./shared/git-utils";
|
||||||
|
import { extractFilePaths, parseHookInput } from "./shared/types";
|
||||||
|
|
||||||
|
function formatDiagnostics(
|
||||||
|
summary: ReturnType<typeof parseBiomeOutput>,
|
||||||
|
): string {
|
||||||
|
if (summary.error_count === 0 && summary.warning_count === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(
|
||||||
|
`${summary.error_count} error(s), ${summary.warning_count} warning(s):`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const d of summary.diagnostics) {
|
||||||
|
lines.push(` ${d.file}:${d.line} [${d.code}] ${d.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const input = await Bun.stdin.text();
|
||||||
|
const hookInput = parseHookInput(input);
|
||||||
|
|
||||||
|
if (!hookInput) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePaths = extractFilePaths(hookInput);
|
||||||
|
|
||||||
|
if (filePaths.length === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each file
|
||||||
|
const allErrors: string[] = [];
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
// Skip unsupported files
|
||||||
|
if (!BIOME_SUPPORTED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git-aware: Skip files outside the git repository
|
||||||
|
const inRepo = await isFileInRepo(filePath);
|
||||||
|
if (!inRepo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, run biome check --write to fix what can be fixed
|
||||||
|
const fixProc = spawn({
|
||||||
|
cmd: [
|
||||||
|
"bunx",
|
||||||
|
"@biomejs/biome",
|
||||||
|
"check",
|
||||||
|
"--write",
|
||||||
|
"--no-errors-on-unmatched",
|
||||||
|
filePath,
|
||||||
|
],
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
await fixProc.exited;
|
||||||
|
|
||||||
|
// Then check if there are remaining issues using JSON reporter
|
||||||
|
const checkProc = spawn({
|
||||||
|
cmd: [
|
||||||
|
"bunx",
|
||||||
|
"@biomejs/biome",
|
||||||
|
"check",
|
||||||
|
"--reporter=json",
|
||||||
|
"--no-errors-on-unmatched",
|
||||||
|
"--colors=off", // Explicitly disable colors for clean JSON
|
||||||
|
filePath,
|
||||||
|
],
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await checkProc.exited;
|
||||||
|
const stdout = await new Response(checkProc.stdout).text();
|
||||||
|
|
||||||
|
if (exitCode !== 0 && stdout.trim()) {
|
||||||
|
const summary = parseBiomeOutput(stdout);
|
||||||
|
if (summary.error_count > 0) {
|
||||||
|
allErrors.push(formatDiagnostics(summary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
console.error(`Biome found unfixable issues:\n${allErrors.join("\n\n")}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
88
hooks/biome-ci.ts
Normal file
88
hooks/biome-ci.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop hook that runs Biome CI on staged/changed files at end of turn.
|
||||||
|
* Provides a final quality gate before Claude completes its response.
|
||||||
|
*
|
||||||
|
* Git-aware: Only checks files that have been modified or staged.
|
||||||
|
* Uses `biome ci` (read-only, strict) for project-wide validation.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* - 0: Success (all files pass or no relevant changes)
|
||||||
|
* - 2: Blocking error (lint/format errors found, shown to Claude for follow-up)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "bun";
|
||||||
|
import { parseBiomeOutput } from "../mcp-servers/bun-runner/index";
|
||||||
|
import { BIOME_SUPPORTED_EXTENSIONS } from "./shared/constants";
|
||||||
|
import { getChangedFiles } from "./shared/git-utils";
|
||||||
|
|
||||||
|
function formatDiagnostics(
|
||||||
|
summary: ReturnType<typeof parseBiomeOutput>,
|
||||||
|
): string {
|
||||||
|
if (summary.error_count === 0 && summary.warning_count === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(
|
||||||
|
`${summary.error_count} error(s), ${summary.warning_count} warning(s):`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const d of summary.diagnostics) {
|
||||||
|
lines.push(` ${d.file}:${d.line} [${d.code}] ${d.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Get changed files filtered by Biome-supported extensions
|
||||||
|
const filesToCheck = await getChangedFiles(BIOME_SUPPORTED_EXTENSIONS);
|
||||||
|
|
||||||
|
if (filesToCheck.length === 0) {
|
||||||
|
// No relevant files changed, nothing to check
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run biome ci (strict, read-only) on changed files
|
||||||
|
const proc = spawn({
|
||||||
|
cmd: [
|
||||||
|
"bunx",
|
||||||
|
"@biomejs/biome",
|
||||||
|
"ci",
|
||||||
|
"--reporter=json",
|
||||||
|
"--no-errors-on-unmatched",
|
||||||
|
"--colors=off", // Explicitly disable colors for clean JSON
|
||||||
|
...filesToCheck,
|
||||||
|
],
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, // Also set fallback env vars
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = await new Response(proc.stdout).text();
|
||||||
|
|
||||||
|
if (exitCode === 0) {
|
||||||
|
// All checks passed
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and report errors
|
||||||
|
if (stdout.trim()) {
|
||||||
|
const summary = parseBiomeOutput(stdout);
|
||||||
|
if (summary.error_count > 0 || summary.warning_count > 0) {
|
||||||
|
const diagnostics = formatDiagnostics(summary);
|
||||||
|
console.error(
|
||||||
|
`Biome CI found issues in ${filesToCheck.length} changed file(s):\n${diagnostics}`,
|
||||||
|
);
|
||||||
|
console.error('\nRun "biome check --write" to auto-fix safe issues.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
39
hooks/hooks.json
Normal file
39
hooks/hooks.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "Auto-format, lint, and type-check files using Biome and TypeScript (git-aware)",
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/biome-check.ts",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/biome-ci.ts",
|
||||||
|
"timeout": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/tsc-ci.ts",
|
||||||
|
"timeout": 120
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
hooks/tsc-check.test.ts
Normal file
86
hooks/tsc-check.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { parseTscOutput } from "./tsc-check";
|
||||||
|
|
||||||
|
describe("parseTscOutput", () => {
|
||||||
|
test("parses single error correctly", () => {
|
||||||
|
const output = `src/index.ts(10,5): error TS2322: Type 'string' is not assignable to type 'number'.`;
|
||||||
|
|
||||||
|
const result = parseTscOutput(output);
|
||||||
|
|
||||||
|
expect(result.errorCount).toBe(1);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]).toEqual({
|
||||||
|
file: "src/index.ts",
|
||||||
|
line: 10,
|
||||||
|
col: 5,
|
||||||
|
message: "Type 'string' is not assignable to type 'number'.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses multiple errors correctly", () => {
|
||||||
|
const output = `src/utils.ts(5,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
|
||||||
|
src/utils.ts(12,3): error TS2304: Cannot find name 'foo'.
|
||||||
|
src/index.ts(1,1): error TS2307: Cannot find module './missing'.`;
|
||||||
|
|
||||||
|
const result = parseTscOutput(output);
|
||||||
|
|
||||||
|
expect(result.errorCount).toBe(3);
|
||||||
|
expect(result.errors).toHaveLength(3);
|
||||||
|
expect(result.errors[0]?.file).toBe("src/utils.ts");
|
||||||
|
expect(result.errors[0]?.line).toBe(5);
|
||||||
|
expect(result.errors[1]?.file).toBe("src/utils.ts");
|
||||||
|
expect(result.errors[1]?.line).toBe(12);
|
||||||
|
expect(result.errors[2]?.file).toBe("src/index.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty output", () => {
|
||||||
|
const result = parseTscOutput("");
|
||||||
|
|
||||||
|
expect(result.errorCount).toBe(0);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles output with no errors", () => {
|
||||||
|
const output = `Some random output
|
||||||
|
that is not an error
|
||||||
|
just informational text`;
|
||||||
|
|
||||||
|
const result = parseTscOutput(output);
|
||||||
|
|
||||||
|
expect(result.errorCount).toBe(0);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles Windows-style paths", () => {
|
||||||
|
const output = `C:\\Users\\dev\\project\\src\\index.ts(5,10): error TS2322: Type error.`;
|
||||||
|
|
||||||
|
const result = parseTscOutput(output);
|
||||||
|
|
||||||
|
expect(result.errorCount).toBe(1);
|
||||||
|
expect(result.errors[0]?.file).toBe(
|
||||||
|
"C:\\Users\\dev\\project\\src\\index.ts",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses errors with complex messages", () => {
|
||||||
|
const output = `src/types.ts(15,3): error TS2739: Type '{ name: string; }' is missing the following properties from type 'User': id, email, createdAt`;
|
||||||
|
|
||||||
|
const result = parseTscOutput(output);
|
||||||
|
|
||||||
|
expect(result.errorCount).toBe(1);
|
||||||
|
expect(result.errors[0]?.message).toBe(
|
||||||
|
"Type '{ name: string; }' is missing the following properties from type 'User': id, email, createdAt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores warning lines", () => {
|
||||||
|
// TSC doesn't output warnings in this format, but ensure we only match errors
|
||||||
|
const output = `src/index.ts(10,5): error TS2322: This is an error.
|
||||||
|
src/index.ts(20,5): warning TS6789: This would be a warning if TSC had them.`;
|
||||||
|
|
||||||
|
const result = parseTscOutput(output);
|
||||||
|
|
||||||
|
expect(result.errorCount).toBe(1);
|
||||||
|
expect(result.errors[0]?.line).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
139
hooks/tsc-check.ts
Normal file
139
hooks/tsc-check.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostToolUse hook that runs TypeScript type checking on edited files.
|
||||||
|
* Performs single-file type checking for fast feedback during implementation.
|
||||||
|
*
|
||||||
|
* Git-aware: Only processes files that are tracked by git or staged.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* - 0: Success (no type errors, or unsupported file type)
|
||||||
|
* - 2: Blocking error (type errors found, shown to Claude)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "bun";
|
||||||
|
import { TSC_SUPPORTED_EXTENSIONS } from "./shared/constants";
|
||||||
|
import { isFileInRepo } from "./shared/git-utils";
|
||||||
|
import {
|
||||||
|
extractFilePaths,
|
||||||
|
parseHookInput,
|
||||||
|
type TscError,
|
||||||
|
type TscParseResult,
|
||||||
|
} from "./shared/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse TypeScript compiler output into structured format.
|
||||||
|
*
|
||||||
|
* @param output - Raw stdout/stderr from tsc command
|
||||||
|
* @returns Structured error data with count and detailed error array
|
||||||
|
*/
|
||||||
|
export function parseTscOutput(output: string): TscParseResult {
|
||||||
|
const errors: TscError[] = [];
|
||||||
|
|
||||||
|
// TSC output format: file(line,col): error TS1234: message
|
||||||
|
const errorPattern = /^(.+?)\((\d+),(\d+)\):\s*error\s+TS\d+:\s*(.+)$/gm;
|
||||||
|
const matches = output.matchAll(errorPattern);
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const [, file, line, col, message] = match;
|
||||||
|
if (file && line && col && message) {
|
||||||
|
errors.push({
|
||||||
|
file,
|
||||||
|
line: Number.parseInt(line, 10),
|
||||||
|
col: Number.parseInt(col, 10),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errorCount: errors.length, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format errors for Claude-friendly output.
|
||||||
|
*
|
||||||
|
* @param parsed - Parsed TSC output
|
||||||
|
* @param filePath - Path to filter errors by
|
||||||
|
* @returns Formatted error string
|
||||||
|
*/
|
||||||
|
function formatErrors(parsed: TscParseResult, filePath: string): string {
|
||||||
|
// Filter to only errors in the edited file
|
||||||
|
const fileErrors = parsed.errors.filter(
|
||||||
|
(e) => e.file === filePath || e.file.endsWith(filePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fileErrors.length === 0) return "";
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`${fileErrors.length} type error(s) in ${filePath}:`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const e of fileErrors) {
|
||||||
|
lines.push(` ${e.file}:${e.line}:${e.col} - ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const input = await Bun.stdin.text();
|
||||||
|
const hookInput = parseHookInput(input);
|
||||||
|
|
||||||
|
if (!hookInput) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePaths = extractFilePaths(hookInput);
|
||||||
|
|
||||||
|
if (filePaths.length === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each file
|
||||||
|
const allErrors: string[] = [];
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
// Skip non-TypeScript files
|
||||||
|
if (!TSC_SUPPORTED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git-aware: Skip files outside the git repository
|
||||||
|
const inRepo = await isFileInRepo(filePath);
|
||||||
|
if (!inRepo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tsc --noEmit on the single file
|
||||||
|
const proc = spawn({
|
||||||
|
cmd: ["bunx", "tsc", "--noEmit", "--pretty", "false", filePath],
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = await new Response(proc.stdout).text();
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
const output = `${stdout}\n${stderr}`;
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const parsed = parseTscOutput(output);
|
||||||
|
const formatted = formatErrors(parsed, filePath);
|
||||||
|
if (formatted) {
|
||||||
|
allErrors.push(formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
console.error(`TypeScript type errors:\n${allErrors.join("\n\n")}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run main() when executed directly, not when imported by tests
|
||||||
|
if (import.meta.main) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
80
hooks/tsc-ci.ts
Normal file
80
hooks/tsc-ci.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop hook that runs project-wide TypeScript type checking at end of turn.
|
||||||
|
* Catches cross-file type errors that single-file checks might miss.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Unlike Biome, TypeScript errors can cascade across files.
|
||||||
|
* A change in file A can cause type errors in file B. Therefore, this hook
|
||||||
|
* blocks on ANY type error in the project, not just in changed files.
|
||||||
|
*
|
||||||
|
* Git-aware: Only runs if TypeScript files have been modified or staged.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* - 0: Success (no type errors, or no TS files changed)
|
||||||
|
* - 2: Blocking error (any type errors found, shown to Claude for follow-up)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "bun";
|
||||||
|
import { TSC_SUPPORTED_EXTENSIONS } from "./shared/constants";
|
||||||
|
import { hasChangedFiles } from "./shared/git-utils";
|
||||||
|
import type { TscParseResult } from "./shared/types";
|
||||||
|
import { parseTscOutput } from "./tsc-check";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format errors for Claude-friendly output.
|
||||||
|
*
|
||||||
|
* @param parsed - Parsed TSC output
|
||||||
|
* @returns Formatted error string
|
||||||
|
*/
|
||||||
|
function formatErrors(parsed: TscParseResult): string {
|
||||||
|
if (parsed.errorCount === 0) return "";
|
||||||
|
|
||||||
|
const lines: string[] = [`${parsed.errorCount} type error(s) found:`];
|
||||||
|
|
||||||
|
for (const e of parsed.errors) {
|
||||||
|
lines.push(` ${e.file}:${e.line}:${e.col} - ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Only run if TypeScript files have changed
|
||||||
|
const hasChanges = await hasChangedFiles(TSC_SUPPORTED_EXTENSIONS);
|
||||||
|
|
||||||
|
if (!hasChanges) {
|
||||||
|
// No TypeScript files changed, nothing to check
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run project-wide tsc --noEmit
|
||||||
|
const proc = spawn({
|
||||||
|
cmd: ["bunx", "tsc", "--noEmit", "--pretty", "false"],
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
const stdout = await new Response(proc.stdout).text();
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
const output = `${stdout}\n${stderr}`;
|
||||||
|
|
||||||
|
if (exitCode === 0) {
|
||||||
|
// All type checks passed
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and report ALL errors - TypeScript errors cascade across files
|
||||||
|
const parsed = parseTscOutput(output);
|
||||||
|
const formatted = formatErrors(parsed);
|
||||||
|
|
||||||
|
if (formatted) {
|
||||||
|
console.error(`TypeScript project check:\n${formatted}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
81
plugin.lock.json
Normal file
81
plugin.lock.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:nathanvale/side-quest-marketplace:plugins/bun-runner",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "0d7f9f8d95e23845c92eb78214be90540c70a836",
|
||||||
|
"treeHash": "9444d51f3403e2e022b3d338b28090a1d57642a0fa051655a82c4b51edf4df01",
|
||||||
|
"generatedAt": "2025-11-28T10:27:14.790135Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "bun-runner",
|
||||||
|
"description": "Smart test runner and linter MCP server for Bun and Biome",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "ea9e8ddd7db556cf944fbb1a58b2a2c1f3a876e3548afa9d058304df1eacec23"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/tsc-check.ts",
|
||||||
|
"sha256": "f689cca8b61d06255e1205b1509b527273ff4193ef2ad5875d271e97687a3044"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/tsc-ci.ts",
|
||||||
|
"sha256": "ea8d9df4a73b24638254c6347688c0157f455067f4a40701f2d1f6fb0bd7fc65"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/tsc-check.test.ts",
|
||||||
|
"sha256": "0cfa592b3785e32ee3cd179d426a891d7ec3eab89ed5ff67818a944437ee6dfe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/biome-check.ts",
|
||||||
|
"sha256": "509a094d1ea47535bc236a6202d2d1eb8fb3df819b7133e2fa2268493b96a36e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/hooks.json",
|
||||||
|
"sha256": "4eeac9c49e380861b63067ba6ebba1b7c3b4f76f6ab2c49a766738f912bbe515"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/biome-ci.ts",
|
||||||
|
"sha256": "1b2c6125da3cacb0a851c9265d9e42fbaff2af96c3cfdeebbb336ef14826609b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/shared/types.test.ts",
|
||||||
|
"sha256": "cff7aada22e9873116ba58e5db7c7671a461efc95ba888137f6ea9c016e5bc61"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/shared/git-utils.ts",
|
||||||
|
"sha256": "1777aaa0bb9829825087949ab07c18ff26972b3f5aad2a8f2dacb408316bb7bd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/shared/types.ts",
|
||||||
|
"sha256": "61d64740ab7d86e1b0585313f43fdaa088904f68ff0357bf3dc3c726ce767717"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/shared/constants.ts",
|
||||||
|
"sha256": "b441ef38acc3666b8be408d63cf4072750d0d9928de1975c8e22e4ff6c5bea5c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "1203113577375d2b79d714578b52f88a2d8010ada306174eda46749e46a9b7e3"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "9444d51f3403e2e022b3d338b28090a1d57642a0fa051655a82c4b51edf4df01"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user