commit 9bae6114093d411291ec6b3690c9a011c2b6076a Author: Zhongwei Li Date: Sun Nov 30 08:41:56 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..a592c0b --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ae2ad3 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# bun-runner + +Smart test runner and linter MCP server for Bun and Biome diff --git a/hooks/biome-check.ts b/hooks/biome-check.ts new file mode 100644 index 0000000..a23831a --- /dev/null +++ b/hooks/biome-check.ts @@ -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, +): 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(); diff --git a/hooks/biome-ci.ts b/hooks/biome-ci.ts new file mode 100644 index 0000000..06b578b --- /dev/null +++ b/hooks/biome-ci.ts @@ -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, +): 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(); diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..a2b4f2f --- /dev/null +++ b/hooks/hooks.json @@ -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 + } + ] + } + ] + } +} diff --git a/hooks/shared/constants.ts b/hooks/shared/constants.ts new file mode 100644 index 0000000..9cbf6a5 --- /dev/null +++ b/hooks/shared/constants.ts @@ -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"]; diff --git a/hooks/shared/git-utils.ts b/hooks/shared/git-utils.ts new file mode 100644 index 0000000..4cd5827 --- /dev/null +++ b/hooks/shared/git-utils.ts @@ -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 { + 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 { + 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 { + const files = new Set(); + + // 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 { + const changedFiles = await getChangedFiles(extensions); + return changedFiles.length > 0; +} diff --git a/hooks/shared/types.test.ts b/hooks/shared/types.test.ts new file mode 100644 index 0000000..2ef35f5 --- /dev/null +++ b/hooks/shared/types.test.ts @@ -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(); + }); +}); diff --git a/hooks/shared/types.ts b/hooks/shared/types.ts new file mode 100644 index 0000000..c3dc22f --- /dev/null +++ b/hooks/shared/types.ts @@ -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; + } +} diff --git a/hooks/tsc-check.test.ts b/hooks/tsc-check.test.ts new file mode 100644 index 0000000..438093c --- /dev/null +++ b/hooks/tsc-check.test.ts @@ -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); + }); +}); diff --git a/hooks/tsc-check.ts b/hooks/tsc-check.ts new file mode 100644 index 0000000..4ed064a --- /dev/null +++ b/hooks/tsc-check.ts @@ -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(); +} diff --git a/hooks/tsc-ci.ts b/hooks/tsc-ci.ts new file mode 100644 index 0000000..c87f112 --- /dev/null +++ b/hooks/tsc-ci.ts @@ -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(); diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..7113abf --- /dev/null +++ b/plugin.lock.json @@ -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": [] + } +} \ No newline at end of file