diff --git a/src/hooks/comment-checker/cli.ts b/src/hooks/comment-checker/cli.ts new file mode 100644 index 0000000..a8d3e72 --- /dev/null +++ b/src/hooks/comment-checker/cli.ts @@ -0,0 +1,156 @@ +import { spawn } from "bun" +import { createRequire } from "module" +import { dirname, join } from "path" +import { existsSync } from "fs" +import * as fs from "fs" + +const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1" +const DEBUG_FILE = "/tmp/comment-checker-debug.log" + +function debugLog(...args: unknown[]) { + if (DEBUG) { + const msg = `[${new Date().toISOString()}] [comment-checker:cli] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n` + fs.appendFileSync(DEBUG_FILE, msg) + } +} + +type Platform = "darwin" | "linux" | "win32" | "unsupported" + +function getPlatformPackageName(): string | null { + const platform = process.platform as Platform + const arch = process.arch + + const platformMap: Record = { + "darwin-arm64": "@anthropic-claude/comment-checker-darwin-arm64", + "darwin-x64": "@anthropic-claude/comment-checker-darwin-x64", + "linux-arm64": "@anthropic-claude/comment-checker-linux-arm64", + "linux-x64": "@anthropic-claude/comment-checker-linux-x64", + } + + return platformMap[`${platform}-${arch}`] ?? null +} + +function findCommentCheckerPath(): string | null { + // 1. Try to find from @anthropic-claude/comment-checker package + try { + const require = createRequire(import.meta.url) + const cliPkgPath = require.resolve("@anthropic-claude/comment-checker/package.json") + const cliDir = dirname(cliPkgPath) + const binaryPath = join(cliDir, "bin", "comment-checker") + + if (existsSync(binaryPath)) { + debugLog("found binary in main package:", binaryPath) + return binaryPath + } + } catch { + debugLog("main package not installed") + } + + // 2. Try platform-specific package directly + const platformPkg = getPlatformPackageName() + if (platformPkg) { + try { + const require = createRequire(import.meta.url) + const pkgPath = require.resolve(`${platformPkg}/package.json`) + const pkgDir = dirname(pkgPath) + const binaryPath = join(pkgDir, "bin", "comment-checker") + + if (existsSync(binaryPath)) { + debugLog("found binary in platform package:", binaryPath) + return binaryPath + } + } catch { + debugLog("platform package not installed:", platformPkg) + } + } + + // 3. Try homebrew installation (macOS) + if (process.platform === "darwin") { + const homebrewPaths = [ + "/opt/homebrew/bin/comment-checker", + "/usr/local/bin/comment-checker", + ] + for (const path of homebrewPaths) { + if (existsSync(path)) { + debugLog("found binary via homebrew:", path) + return path + } + } + } + + // 4. Try system PATH + const systemPath = "comment-checker" + debugLog("falling back to system PATH:", systemPath) + return systemPath +} + +export const COMMENT_CHECKER_CLI_PATH = findCommentCheckerPath() + +export interface HookInput { + session_id: string + tool_name: string + transcript_path: string + cwd: string + hook_event_name: string + tool_input: { + file_path?: string + content?: string + old_string?: string + new_string?: string + edits?: Array<{ old_string: string; new_string: string }> + } + tool_response?: unknown +} + +export interface CheckResult { + hasComments: boolean + message: string +} + +export async function runCommentChecker(input: HookInput): Promise { + if (!COMMENT_CHECKER_CLI_PATH) { + debugLog("comment-checker binary not found") + return { hasComments: false, message: "" } + } + + const jsonInput = JSON.stringify(input) + debugLog("running comment-checker with input:", jsonInput.substring(0, 200)) + + try { + const proc = spawn([COMMENT_CHECKER_CLI_PATH], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + + // Write JSON to stdin + proc.stdin.write(jsonInput) + proc.stdin.end() + + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + debugLog("exit code:", exitCode, "stdout length:", stdout.length, "stderr length:", stderr.length) + + if (exitCode === 0) { + return { hasComments: false, message: "" } + } + + if (exitCode === 2) { + // Comments detected - message is in stderr + return { hasComments: true, message: stderr } + } + + // Error case + debugLog("unexpected exit code:", exitCode, "stderr:", stderr) + return { hasComments: false, message: "" } + } catch (err) { + debugLog("failed to run comment-checker:", err) + return { hasComments: false, message: "" } + } +} + +export function isCliAvailable(): boolean { + return COMMENT_CHECKER_CLI_PATH !== null && existsSync(COMMENT_CHECKER_CLI_PATH) +} diff --git a/src/hooks/comment-checker/index.ts b/src/hooks/comment-checker/index.ts index 488666d..4f3b635 100644 --- a/src/hooks/comment-checker/index.ts +++ b/src/hooks/comment-checker/index.ts @@ -1,4 +1,5 @@ import type { PendingCall, FileComments } from "./types" +import { runCommentChecker, isCliAvailable, type HookInput } from "./cli" import { detectComments, isSupportedFile, warmupCommonLanguages } from "./detector" import { applyFilters } from "./filters" import { formatHookMessage } from "./output" @@ -18,6 +19,10 @@ function debugLog(...args: unknown[]) { const pendingCalls = new Map() const PENDING_CALL_TTL = 60_000 +// Check if native CLI is available at startup +const USE_CLI = isCliAvailable() +debugLog("comment-checker mode:", USE_CLI ? "CLI (native)" : "WASM (fallback)") + function cleanupOldPendingCalls(): void { const now = Date.now() for (const [callID, call] of pendingCalls) { @@ -32,8 +37,10 @@ setInterval(cleanupOldPendingCalls, 10_000) export function createCommentCheckerHooks() { debugLog("createCommentCheckerHooks called") - // Background warmup - LSP style (non-blocking) - warmupCommonLanguages() + // Background warmup for WASM fallback - LSP style (non-blocking) + if (!USE_CLI) { + warmupCommonLanguages() + } return { "tool.execute.before": async ( @@ -50,6 +57,9 @@ export function createCommentCheckerHooks() { const filePath = (output.args.filePath ?? output.args.file_path ?? output.args.path) as string | undefined const content = output.args.content as string | undefined + const oldString = output.args.oldString ?? output.args.old_string as string | undefined + const newString = output.args.newString ?? output.args.new_string as string | undefined + const edits = output.args.edits as Array<{ old_string: string; new_string: string }> | undefined debugLog("extracted filePath:", filePath) @@ -58,7 +68,7 @@ export function createCommentCheckerHooks() { return } - if (!isSupportedFile(filePath)) { + if (!USE_CLI && !isSupportedFile(filePath)) { debugLog("unsupported file:", filePath) return } @@ -67,6 +77,9 @@ export function createCommentCheckerHooks() { pendingCalls.set(input.callID, { filePath, content, + oldString: oldString as string | undefined, + newString: newString as string | undefined, + edits, tool: toolLower as "write" | "edit" | "multiedit", sessionID: input.sessionID, timestamp: Date.now(), @@ -89,7 +102,6 @@ export function createCommentCheckerHooks() { debugLog("processing pendingCall:", pendingCall) // Only skip if the output indicates a tool execution failure - // (not LSP warnings/errors or other incidental "error" strings) const outputLower = output.output.toLowerCase() const isToolFailure = outputLower.includes("error:") || @@ -103,40 +115,13 @@ export function createCommentCheckerHooks() { } try { - let content: string - - if (pendingCall.content) { - content = pendingCall.content - debugLog("using content from args") + if (USE_CLI) { + // Native CLI mode - much faster + await processWithCli(input, pendingCall, output) } else { - debugLog("reading file:", pendingCall.filePath) - const file = Bun.file(pendingCall.filePath) - content = await file.text() - debugLog("file content length:", content.length) + // WASM fallback mode + await processWithWasm(pendingCall, output) } - - debugLog("calling detectComments...") - const rawComments = await detectComments(pendingCall.filePath, content) - debugLog("raw comments:", rawComments.length) - - const filteredComments = applyFilters(rawComments) - debugLog("filtered comments:", filteredComments.length) - - if (filteredComments.length === 0) { - debugLog("no comments after filtering") - return - } - - const fileComments: FileComments[] = [ - { - filePath: pendingCall.filePath, - comments: filteredComments, - }, - ] - - const message = formatHookMessage(fileComments) - debugLog("appending message to output") - output.output += `\n\n${message}` } catch (err) { debugLog("tool.execute.after failed:", err) } @@ -144,4 +129,78 @@ export function createCommentCheckerHooks() { } } +async function processWithCli( + input: { tool: string; sessionID: string; callID: string }, + pendingCall: PendingCall, + output: { output: string } +): Promise { + debugLog("using CLI mode") + + const hookInput: HookInput = { + session_id: pendingCall.sessionID, + tool_name: pendingCall.tool.charAt(0).toUpperCase() + pendingCall.tool.slice(1), // "write" -> "Write" + transcript_path: "", + cwd: process.cwd(), + hook_event_name: "PostToolUse", + tool_input: { + file_path: pendingCall.filePath, + content: pendingCall.content, + old_string: pendingCall.oldString, + new_string: pendingCall.newString, + edits: pendingCall.edits, + }, + } + + const result = await runCommentChecker(hookInput) + + if (result.hasComments && result.message) { + debugLog("CLI detected comments, appending message") + output.output += `\n\n${result.message}` + } else { + debugLog("CLI: no comments detected") + } +} + +async function processWithWasm( + pendingCall: PendingCall, + output: { output: string } +): Promise { + debugLog("using WASM fallback mode") + + let content: string + + if (pendingCall.content) { + content = pendingCall.content + debugLog("using content from args") + } else { + debugLog("reading file:", pendingCall.filePath) + const file = Bun.file(pendingCall.filePath) + content = await file.text() + debugLog("file content length:", content.length) + } + + debugLog("calling detectComments...") + const rawComments = await detectComments(pendingCall.filePath, content) + debugLog("raw comments:", rawComments.length) + + const filteredComments = applyFilters(rawComments) + debugLog("filtered comments:", filteredComments.length) + + if (filteredComments.length === 0) { + debugLog("no comments after filtering") + return + } + + const fileComments: FileComments[] = [ + { + filePath: pendingCall.filePath, + comments: filteredComments, + }, + ] + + const message = formatHookMessage(fileComments) + debugLog("appending message to output") + output.output += `\n\n${message}` +} + diff --git a/src/hooks/comment-checker/types.ts b/src/hooks/comment-checker/types.ts index bb8b0a4..3d7ea67 100644 --- a/src/hooks/comment-checker/types.ts +++ b/src/hooks/comment-checker/types.ts @@ -18,6 +18,9 @@ export interface LanguageConfig { export interface PendingCall { filePath: string content?: string + oldString?: string + newString?: string + edits?: Array<{ old_string: string; new_string: string }> tool: "write" | "edit" | "multiedit" sessionID: string timestamp: number