feat(comment-checker): add native CLI support with WASM fallback
- Add cli.ts for native binary resolution and spawning - Update index.ts to use CLI when available, WASM as fallback - Add Edit/MultiEdit support to types.ts for proper CLI input
This commit is contained in:
156
src/hooks/comment-checker/cli.ts
Normal file
156
src/hooks/comment-checker/cli.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
"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<CheckResult> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PendingCall, FileComments } from "./types"
|
import type { PendingCall, FileComments } from "./types"
|
||||||
|
import { runCommentChecker, isCliAvailable, type HookInput } from "./cli"
|
||||||
import { detectComments, isSupportedFile, warmupCommonLanguages } from "./detector"
|
import { detectComments, isSupportedFile, warmupCommonLanguages } from "./detector"
|
||||||
import { applyFilters } from "./filters"
|
import { applyFilters } from "./filters"
|
||||||
import { formatHookMessage } from "./output"
|
import { formatHookMessage } from "./output"
|
||||||
@@ -18,6 +19,10 @@ function debugLog(...args: unknown[]) {
|
|||||||
const pendingCalls = new Map<string, PendingCall>()
|
const pendingCalls = new Map<string, PendingCall>()
|
||||||
const PENDING_CALL_TTL = 60_000
|
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 {
|
function cleanupOldPendingCalls(): void {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const [callID, call] of pendingCalls) {
|
for (const [callID, call] of pendingCalls) {
|
||||||
@@ -32,8 +37,10 @@ setInterval(cleanupOldPendingCalls, 10_000)
|
|||||||
export function createCommentCheckerHooks() {
|
export function createCommentCheckerHooks() {
|
||||||
debugLog("createCommentCheckerHooks called")
|
debugLog("createCommentCheckerHooks called")
|
||||||
|
|
||||||
// Background warmup - LSP style (non-blocking)
|
// Background warmup for WASM fallback - LSP style (non-blocking)
|
||||||
|
if (!USE_CLI) {
|
||||||
warmupCommonLanguages()
|
warmupCommonLanguages()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tool.execute.before": async (
|
"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 filePath = (output.args.filePath ?? output.args.file_path ?? output.args.path) as string | undefined
|
||||||
const content = output.args.content 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)
|
debugLog("extracted filePath:", filePath)
|
||||||
|
|
||||||
@@ -58,7 +68,7 @@ export function createCommentCheckerHooks() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSupportedFile(filePath)) {
|
if (!USE_CLI && !isSupportedFile(filePath)) {
|
||||||
debugLog("unsupported file:", filePath)
|
debugLog("unsupported file:", filePath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,6 +77,9 @@ export function createCommentCheckerHooks() {
|
|||||||
pendingCalls.set(input.callID, {
|
pendingCalls.set(input.callID, {
|
||||||
filePath,
|
filePath,
|
||||||
content,
|
content,
|
||||||
|
oldString: oldString as string | undefined,
|
||||||
|
newString: newString as string | undefined,
|
||||||
|
edits,
|
||||||
tool: toolLower as "write" | "edit" | "multiedit",
|
tool: toolLower as "write" | "edit" | "multiedit",
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -89,7 +102,6 @@ export function createCommentCheckerHooks() {
|
|||||||
debugLog("processing pendingCall:", pendingCall)
|
debugLog("processing pendingCall:", pendingCall)
|
||||||
|
|
||||||
// Only skip if the output indicates a tool execution failure
|
// 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 outputLower = output.output.toLowerCase()
|
||||||
const isToolFailure =
|
const isToolFailure =
|
||||||
outputLower.includes("error:") ||
|
outputLower.includes("error:") ||
|
||||||
@@ -103,6 +115,58 @@ export function createCommentCheckerHooks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (USE_CLI) {
|
||||||
|
// Native CLI mode - much faster
|
||||||
|
await processWithCli(input, pendingCall, output)
|
||||||
|
} else {
|
||||||
|
// WASM fallback mode
|
||||||
|
await processWithWasm(pendingCall, output)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debugLog("tool.execute.after failed:", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithCli(
|
||||||
|
input: { tool: string; sessionID: string; callID: string },
|
||||||
|
pendingCall: PendingCall,
|
||||||
|
output: { output: string }
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
debugLog("using WASM fallback mode")
|
||||||
|
|
||||||
let content: string
|
let content: string
|
||||||
|
|
||||||
if (pendingCall.content) {
|
if (pendingCall.content) {
|
||||||
@@ -137,11 +201,6 @@ export function createCommentCheckerHooks() {
|
|||||||
const message = formatHookMessage(fileComments)
|
const message = formatHookMessage(fileComments)
|
||||||
debugLog("appending message to output")
|
debugLog("appending message to output")
|
||||||
output.output += `\n\n${message}`
|
output.output += `\n\n${message}`
|
||||||
} catch (err) {
|
|
||||||
debugLog("tool.execute.after failed:", err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export interface LanguageConfig {
|
|||||||
export interface PendingCall {
|
export interface PendingCall {
|
||||||
filePath: string
|
filePath: string
|
||||||
content?: string
|
content?: string
|
||||||
|
oldString?: string
|
||||||
|
newString?: string
|
||||||
|
edits?: Array<{ old_string: string; new_string: string }>
|
||||||
tool: "write" | "edit" | "multiedit"
|
tool: "write" | "edit" | "multiedit"
|
||||||
sessionID: string
|
sessionID: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
|
|||||||
Reference in New Issue
Block a user