refactor(packages): extract comment-checker-core package

This commit is contained in:
YeonGyu-Kim
2026-05-21 01:17:04 +09:00
parent 3b303e79f4
commit 7028c1f40a
14 changed files with 562 additions and 355 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./src/index"
@@ -0,0 +1,18 @@
{
"name": "@oh-my-opencode/comment-checker-core",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Pure TypeScript comment-checker parsing and runner core for oh-my-opencode.",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./src/index.ts"
}
},
"types": "./index.d.ts",
"scripts": {
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "bun test src/*.test.ts"
}
}
@@ -0,0 +1,164 @@
import type { ApplyPatchAccumulator, ApplyPatchFileMetadata, CheckerEdit } from "./types"
export function extractApplyPatchEdits(
details: unknown,
args?: Record<string, unknown>,
): CheckerEdit[] {
const metadataEdits = getApplyPatchMetadataFiles(details)
.filter((file) => file.type?.toLowerCase() !== "delete")
.map((file) => ({
filePath: file.movePath ?? file.filePath,
before: file.before,
after: file.after,
}))
if (metadataEdits.length > 0) return metadataEdits
const patch = args === undefined ? undefined : getString(args, ["patchText", "input", "patch", "command"])
if (patch === undefined) return []
return parseApplyPatchRequests(patch)
}
export function getApplyPatchMetadataFiles(details: unknown): ApplyPatchFileMetadata[] {
if (!isRecord(details)) return []
const direct = readApplyPatchMetadataFiles(details["files"])
if (direct.length > 0) return direct
const resultDetails = details["result"]
const result = isRecord(resultDetails) ? readApplyPatchMetadataFiles(resultDetails["files"]) : []
if (result.length > 0) return result
const metadataDetails = details["metadata"]
return isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : []
}
export function readApplyPatchMetadataFiles(value: unknown): ApplyPatchFileMetadata[] {
if (!Array.isArray(value)) return []
const files: ApplyPatchFileMetadata[] = []
for (const item of value) {
if (!isRecord(item)) continue
const filePath = getString(item, ["filePath", "file_path", "path"])
const movePath = getString(item, ["movePath", "move_path"])
const before = getString(item, ["before", "old", "oldString", "old_string"])
const after = getString(item, ["after", "new", "newString", "new_string"])
const type = getString(item, ["type", "operation"])
if (filePath === undefined || before === undefined || after === undefined) continue
files.push({
filePath,
before,
after,
...(movePath === undefined ? {} : { movePath }),
...(type === undefined ? {} : { type }),
})
}
return files
}
export function parseApplyPatchRequests(patch: string): CheckerEdit[] {
const edits: CheckerEdit[] = []
let current: ApplyPatchAccumulator | undefined
const flush = (): void => {
if (current === undefined) return
if (current.operation === "add") {
const after = joinPatchLines(current.newLines)
if (after.length > 0) {
edits.push({ filePath: current.filePath, before: "", after })
}
}
if (current.operation === "update") {
const after = joinPatchLines(current.newLines)
if (after.length > 0) {
edits.push({
filePath: current.movePath ?? current.filePath,
before: joinPatchLines(current.oldLines),
after,
})
}
}
current = undefined
}
for (const line of patch.split(/\r?\n/)) {
if (line === "*** Begin Patch" || line === "*** End Patch") continue
if (line.startsWith("*** Add File: ")) {
flush()
current = makeAccumulator("add", line.slice("*** Add File: ".length).trim())
continue
}
if (line.startsWith("*** Update File: ")) {
flush()
current = makeAccumulator("update", line.slice("*** Update File: ".length).trim())
continue
}
if (line.startsWith("*** Delete File: ")) {
flush()
current = makeAccumulator("delete", line.slice("*** Delete File: ".length).trim())
continue
}
if (line.startsWith("*** Move to: ")) {
if (current?.operation === "update") {
current.movePath = line.slice("*** Move to: ".length).trim()
}
continue
}
if (current === undefined || line.startsWith("@@")) continue
if (current.operation === "add") {
if (line.startsWith("+")) current.newLines.push(line.slice(1))
continue
}
if (current.operation === "update") {
if (line.startsWith("-")) current.oldLines.push(line.slice(1))
if (line.startsWith("+")) current.newLines.push(line.slice(1))
}
}
flush()
return edits
}
export function makeAccumulator(
operation: ApplyPatchAccumulator["operation"],
filePath: string,
): ApplyPatchAccumulator {
return {
operation,
filePath,
oldLines: [],
newLines: [],
}
}
export function getString(input: Record<string, unknown>, keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = input[key]
if (typeof value === "string") return value
}
return undefined
}
export function joinPatchLines(lines: readonly string[]): string {
return lines.length === 0 ? "" : `${lines.join("\n")}\n`
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
@@ -0,0 +1,30 @@
export {
extractApplyPatchEdits,
getApplyPatchMetadataFiles,
getString,
isRecord,
joinPatchLines,
makeAccumulator,
parseApplyPatchRequests,
readApplyPatchMetadataFiles,
} from "./apply-patch-edits"
export { resolveCommentCheckerBinary, runCommentChecker } from "./runner"
export type {
ApplyPatchAccumulator,
ApplyPatchFileMetadata,
CheckResult,
CheckerEdit,
CommentFilter,
CommentInfo,
CommentType,
FileComments,
FilterResult,
HookInput,
PendingCall,
ResolveCommentCheckerBinaryInput,
RunCommentCheckerInput,
RunCommentCheckerOptions,
SpawnFn,
SpawnProcess,
SpawnSignal,
} from "./types"
+119
View File
@@ -0,0 +1,119 @@
import { createRequire } from "node:module"
import { dirname, join } from "node:path"
import type {
CheckResult,
ResolveCommentCheckerBinaryInput,
RunCommentCheckerInput,
RunCommentCheckerOptions,
SpawnProcess,
SpawnSignal,
} from "./types"
const EMPTY_RESULT: CheckResult = { hasComments: false, message: "" }
function killProcessSafely(process: SpawnProcess, signal: SpawnSignal): void {
try {
process.kill(signal)
} catch (error) {
if (!(error instanceof Error)) {
throw error
}
}
}
export function resolveCommentCheckerBinary(input: ResolveCommentCheckerBinaryInput): string | null {
const packageName = input.packageName ?? "@code-yeongyu/comment-checker"
if (input.cachedBinaryPath !== null && input.existsSync(input.cachedBinaryPath)) {
return input.cachedBinaryPath
}
if (input.importMetaUrl === undefined) {
return null
}
try {
const require = createRequire(input.importMetaUrl)
const packageJsonPath = require.resolve(`${packageName}/package.json`)
const binaryPath = join(dirname(packageJsonPath), "bin", input.binaryName)
return input.existsSync(binaryPath) ? binaryPath : null
} catch (error) {
if (error instanceof Error) {
return null
}
throw error
}
}
export async function runCommentChecker(
input: RunCommentCheckerInput,
options: RunCommentCheckerOptions,
): Promise<CheckResult> {
if (input.binaryPath === null || !options.existsSync(input.binaryPath)) {
return EMPTY_RESULT
}
const args = [input.binaryPath, "check"]
if (input.customPrompt !== undefined) {
args.push("--prompt", input.customPrompt)
}
const timeoutMs = options.timeoutMs ?? 30_000
const killGraceMs = options.killGraceMs ?? 1_000
const setTimer = options.setTimeoutFn ?? setTimeout
const clearTimer = options.clearTimeoutFn ?? clearTimeout
const process = options.spawn(args)
process.stdin.write(JSON.stringify(input.hookInput))
process.stdin.end()
let timeoutId: ReturnType<typeof setTimeout> | null = null
let graceId: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<"timeout">((resolve) => {
timeoutId = setTimer(() => {
killProcessSafely(process, "SIGTERM")
graceId = setTimer(() => {
killProcessSafely(process, "SIGKILL")
}, killGraceMs)
resolve("timeout")
}, timeoutMs)
})
try {
const stdoutPromise = new Response(process.stdout).text()
const stderrPromise = new Response(process.stderr).text()
const exitCodePromise = process.exited
const completed = Promise.all([stdoutPromise, stderrPromise, exitCodePromise] as const)
const race = await Promise.race([completed, timeoutPromise] as const)
if (race === "timeout") {
return EMPTY_RESULT
}
const [_stdout, stderr, exitCode] = race
if (exitCode === 0) {
return EMPTY_RESULT
}
if (exitCode === 2) {
return { hasComments: true, message: stderr }
}
return EMPTY_RESULT
} catch (error) {
if (error instanceof Error) {
return EMPTY_RESULT
}
throw error
} finally {
if (timeoutId !== null) {
clearTimer(timeoutId)
}
if (graceId !== null) {
clearTimer(graceId)
}
}
}
+114
View File
@@ -0,0 +1,114 @@
export type CheckerEdit = {
readonly filePath: string
readonly before: string
readonly after: string
}
export type ApplyPatchFileMetadata = {
readonly filePath: string
readonly movePath?: string
readonly before: string
readonly after: string
readonly type?: string
}
export type ApplyPatchAccumulator = {
operation: "add" | "update" | "delete"
filePath: string
movePath?: string
oldLines: string[]
newLines: string[]
}
export type CommentType = "line" | "block" | "docstring"
export interface CommentInfo {
readonly text: string
readonly lineNumber: number
readonly filePath: string
readonly commentType: CommentType
readonly isDocstring: boolean
readonly metadata?: Record<string, string>
}
export interface PendingCall {
readonly filePath: string
readonly content?: string
readonly oldString?: string
readonly newString?: string
readonly edits?: readonly { old_string: string; new_string: string }[]
readonly tool: "write" | "edit" | "multiedit"
readonly sessionID: string
readonly timestamp: number
}
export interface FileComments {
readonly filePath: string
readonly comments: readonly CommentInfo[]
}
export interface FilterResult {
readonly shouldSkip: boolean
readonly reason?: string
}
export type CommentFilter = (comment: CommentInfo) => FilterResult
export interface HookInput {
readonly session_id: string
readonly tool_name: string
readonly transcript_path: string
readonly cwd: string
readonly hook_event_name: string
readonly tool_input: {
readonly file_path?: string
readonly content?: string
readonly old_string?: string
readonly new_string?: string
readonly edits?: readonly { old_string: string; new_string: string }[]
}
readonly tool_response?: unknown
}
export interface CheckResult {
readonly hasComments: boolean
readonly message: string
}
export type SpawnSignal = "SIGTERM" | "SIGKILL"
export type SpawnProcess = {
readonly stdin: {
write(input: string): void
end(): void
}
readonly stdout: ReadableStream<Uint8Array>
readonly stderr: ReadableStream<Uint8Array>
readonly exited: Promise<number>
kill(signal: SpawnSignal): void
}
export type SpawnFn = (args: readonly string[]) => SpawnProcess
export interface ResolveCommentCheckerBinaryInput {
readonly binaryName: string
readonly cachedBinaryPath: string | null
readonly existsSync: (path: string) => boolean
readonly importMetaUrl?: string
readonly packageName?: string
}
export interface RunCommentCheckerInput {
readonly hookInput: HookInput
readonly binaryPath: string | null
readonly customPrompt?: string
}
export interface RunCommentCheckerOptions {
readonly spawn: SpawnFn
readonly existsSync: (path: string) => boolean
readonly timeoutMs?: number
readonly killGraceMs?: number
readonly setTimeoutFn?: typeof setTimeout
readonly clearTimeoutFn?: typeof clearTimeout
}
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ESNext"],
"types": ["bun-types"]
},
"include": ["src/**/*"]
}