refactor(packages): extract comment-checker-core package
This commit is contained in:
+1
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user