feat(omo-codex): batch 35 (4 files)
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runCodexHookCli } from "./codex-hook.js";
|
||||
|
||||
const [command, subcommand] = process.argv.slice(2);
|
||||
|
||||
if (command === "hook" && subcommand === "post-tool-use") {
|
||||
await runCodexHookCli();
|
||||
} else {
|
||||
process.stderr.write("Usage: omo-comment-checker hook post-tool-use\n");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { stdin as processStdin, stdout as processStdout } from "node:process";
|
||||
|
||||
import {
|
||||
type CommentCheckRequest,
|
||||
extractCommentCheckRequests,
|
||||
isRecord,
|
||||
type ToolResultContent,
|
||||
type ToolResultLike,
|
||||
toHookInput,
|
||||
} from "./core.js";
|
||||
import { type CommentCheckerRunner, runCommentChecker } from "./runner.js";
|
||||
|
||||
export type CodexPostToolUseInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "PostToolUse";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
tool_response: unknown;
|
||||
tool_use_id: string;
|
||||
};
|
||||
|
||||
export type CodexHookOptions = {
|
||||
run?: CommentCheckerRunner;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000;
|
||||
const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200;
|
||||
const CONTEXT_PRESSURE_MARKERS = [
|
||||
"context compacted",
|
||||
"context_length_exceeded",
|
||||
"skill descriptions were shortened",
|
||||
"context_too_large",
|
||||
"codex ran out of room in the model's context window",
|
||||
"your input exceeds the context window",
|
||||
"long threads and multiple compactions",
|
||||
] as const;
|
||||
|
||||
export function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[] {
|
||||
return extractCommentCheckRequests(toToolResultLike(input));
|
||||
}
|
||||
|
||||
export async function runCommentCheckerPostToolUse(
|
||||
input: CodexPostToolUseInput,
|
||||
options: CodexHookOptions = {},
|
||||
): Promise<string> {
|
||||
const requests = extractCodexCommentCheckRequests(input);
|
||||
if (requests.length === 0) return "";
|
||||
|
||||
const runner = options.run ?? runCommentChecker;
|
||||
const warnings: Array<{ filePath: string; message: string }> = [];
|
||||
|
||||
for (const request of requests) {
|
||||
const context = {
|
||||
sessionId: input.session_id,
|
||||
cwd: input.cwd,
|
||||
...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }),
|
||||
};
|
||||
const result = await runner(toHookInput(request, context));
|
||||
if (result.status === "missing" || result.status === "pass") continue;
|
||||
if (result.status === "error") continue;
|
||||
const message = normalizeHookText(result.message);
|
||||
if (message.length > 0) {
|
||||
warnings.push({ filePath: request.filePath, message });
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length === 0) return "";
|
||||
|
||||
return JSON.stringify({
|
||||
decision: "block",
|
||||
reason: limitHookText(formatWarnings(warnings), hookFeedbackLimit(input.transcript_path)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCodexHookCli(): Promise<void> {
|
||||
const input = await readStdin();
|
||||
if (input.trim().length === 0) return;
|
||||
const parsed = parseCodexPostToolUseInput(input);
|
||||
if (!parsed) return;
|
||||
const output = await runCommentCheckerPostToolUse(parsed);
|
||||
if (output.length > 0) {
|
||||
processStdout.write(output);
|
||||
processStdout.write("\n");
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return isCodexPostToolUseInput(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function toToolResultLike(input: CodexPostToolUseInput): ToolResultLike {
|
||||
return {
|
||||
toolName: input.tool_name,
|
||||
input: normalizeToolInput(input.tool_name, input.tool_input),
|
||||
content: normalizeToolResponse(input.tool_response),
|
||||
isError: isErrorResponse(input.tool_response),
|
||||
details: isRecord(input.tool_response) ? input.tool_response : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolInput(toolName: string, toolInput: Record<string, unknown>): Record<string, unknown> {
|
||||
if (toolName === "apply_patch" && typeof toolInput["command"] === "string") {
|
||||
return {
|
||||
...toolInput,
|
||||
input: toolInput["command"],
|
||||
patch: toolInput["command"],
|
||||
};
|
||||
}
|
||||
return toolInput;
|
||||
}
|
||||
|
||||
function normalizeToolResponse(toolResponse: unknown): ToolResultContent[] {
|
||||
if (typeof toolResponse === "string") {
|
||||
return [{ type: "text", text: toolResponse }];
|
||||
}
|
||||
if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") {
|
||||
return [{ type: "text", text: toolResponse["text"] }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isErrorResponse(toolResponse: unknown): boolean {
|
||||
return isRecord(toolResponse) && toolResponse["is_error"] === true;
|
||||
}
|
||||
|
||||
function formatWarnings(warnings: Array<{ filePath: string; message: string }>): string {
|
||||
return warnings
|
||||
.map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function normalizeHookText(value: string): string {
|
||||
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
||||
}
|
||||
|
||||
function hookFeedbackLimit(transcriptPath: string | null): number {
|
||||
return isContextPressureTranscript(transcriptPath)
|
||||
? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS
|
||||
: DEFAULT_MAX_HOOK_FEEDBACK_CHARS;
|
||||
}
|
||||
|
||||
function isContextPressureTranscript(transcriptPath: string | null): boolean {
|
||||
if (transcriptPath === null) return false;
|
||||
try {
|
||||
return hasContextPressureMarker(readFileSync(transcriptPath, "utf8"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hasContextPressureMarker(text: string): boolean {
|
||||
const normalizedText = text.toLowerCase();
|
||||
return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker));
|
||||
}
|
||||
|
||||
function limitHookText(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
const marker = `\n\n[Truncated hook output to ${maxChars} chars to avoid Codex context overflow.]`;
|
||||
if (marker.length >= maxChars) return marker.slice(0, maxChars);
|
||||
const head = text.slice(0, maxChars - marker.length).replace(/[ \t\r\n]+$/, "");
|
||||
return `${head}${marker}`;
|
||||
}
|
||||
|
||||
function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "PostToolUse" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
(typeof value["transcript_path"] === "string" || value["transcript_path"] === null) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["tool_name"] === "string" &&
|
||||
isRecord(value["tool_input"]) &&
|
||||
typeof value["tool_use_id"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
processStdin.setEncoding("utf-8");
|
||||
processStdin.on("data", (chunk: string) => {
|
||||
data += chunk;
|
||||
});
|
||||
processStdin.once("error", reject);
|
||||
processStdin.once("end", () => {
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
export type TextContent = {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ImageContent = {
|
||||
type: "image";
|
||||
data: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type CheckerToolName = "Write" | "Edit" | "MultiEdit";
|
||||
|
||||
export type CheckerEdit = {
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
};
|
||||
|
||||
export type CheckerToolInput = {
|
||||
file_path: string;
|
||||
content?: string;
|
||||
old_string?: string;
|
||||
new_string?: string;
|
||||
edits?: CheckerEdit[];
|
||||
};
|
||||
|
||||
export type CommentCheckRequest = {
|
||||
sourceToolName: string;
|
||||
toolName: CheckerToolName;
|
||||
filePath: string;
|
||||
toolInput: CheckerToolInput;
|
||||
};
|
||||
|
||||
export type CommentCheckerHookInput = {
|
||||
session_id: string;
|
||||
tool_name: CheckerToolName;
|
||||
transcript_path: string;
|
||||
cwd: string;
|
||||
hook_event_name: "PostToolUse";
|
||||
tool_input: CheckerToolInput;
|
||||
};
|
||||
|
||||
export type ToolResultContent = TextContent | ImageContent;
|
||||
|
||||
export type ToolResultLike = {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
content?: ToolResultContent[];
|
||||
isError?: boolean;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
type ApplyPatchAccumulator = {
|
||||
operation: "add" | "delete" | "update";
|
||||
filePath: string;
|
||||
movePath?: string;
|
||||
oldLines: string[];
|
||||
newLines: string[];
|
||||
};
|
||||
|
||||
type ApplyPatchFileMetadata = {
|
||||
filePath: string;
|
||||
movePath?: string;
|
||||
before: string;
|
||||
after: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[] {
|
||||
if (event.isError) return [];
|
||||
if (isToolFailureOutput(getContentText(event.content))) return [];
|
||||
|
||||
const toolName = event.toolName.toLowerCase();
|
||||
if (toolName === "write") return extractWriteRequest(event);
|
||||
if (toolName === "edit") return extractEditRequest(event);
|
||||
if (toolName === "multiedit" || toolName === "multi_edit") return extractMultiEditRequest(event);
|
||||
if (toolName === "apply_patch") return extractApplyPatchRequests(event);
|
||||
return [];
|
||||
}
|
||||
|
||||
export function toHookInput(
|
||||
request: CommentCheckRequest,
|
||||
context: {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
transcriptPath?: string;
|
||||
},
|
||||
): CommentCheckerHookInput {
|
||||
return {
|
||||
session_id: context.sessionId,
|
||||
tool_name: request.toolName,
|
||||
transcript_path: context.transcriptPath ?? "",
|
||||
cwd: context.cwd,
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_input: request.toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function isToolFailureOutput(text: string): boolean {
|
||||
const lower = text.trim().toLowerCase();
|
||||
return (
|
||||
lower.startsWith("error") ||
|
||||
lower.includes("error:") ||
|
||||
lower.includes("failed to") ||
|
||||
lower.includes("could not")
|
||||
);
|
||||
}
|
||||
|
||||
function extractWriteRequest(event: ToolResultLike): CommentCheckRequest[] {
|
||||
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
|
||||
const content = getString(event.input, ["content"]);
|
||||
if (!filePath || content === undefined) return [];
|
||||
return [
|
||||
{
|
||||
sourceToolName: event.toolName,
|
||||
toolName: "Write",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
content,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function extractEditRequest(event: ToolResultLike): CommentCheckRequest[] {
|
||||
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
|
||||
const oldString = getString(event.input, ["oldString", "old_string"]);
|
||||
const newString = getString(event.input, ["newString", "new_string"]);
|
||||
if (!filePath || oldString === undefined || newString === undefined) return [];
|
||||
const toolInput: CheckerToolInput = { file_path: filePath };
|
||||
toolInput.old_string = oldString;
|
||||
toolInput.new_string = newString;
|
||||
return [
|
||||
{
|
||||
sourceToolName: event.toolName,
|
||||
toolName: "Edit",
|
||||
filePath,
|
||||
toolInput,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function extractMultiEditRequest(event: ToolResultLike): CommentCheckRequest[] {
|
||||
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
|
||||
const edits = getEdits(event.input["edits"]);
|
||||
if (!filePath || edits.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
sourceToolName: event.toolName,
|
||||
toolName: "MultiEdit",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
edits,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function extractApplyPatchRequests(event: ToolResultLike): CommentCheckRequest[] {
|
||||
const metadataRequests = extractApplyPatchMetadataRequests(event.details, event.toolName);
|
||||
if (metadataRequests.length > 0) return metadataRequests;
|
||||
|
||||
const patch = getString(event.input, ["input", "patch", "command"]);
|
||||
if (!patch) return [];
|
||||
return parseApplyPatchRequests(patch, event.toolName);
|
||||
}
|
||||
|
||||
function extractApplyPatchMetadataRequests(details: unknown, sourceToolName: string): CommentCheckRequest[] {
|
||||
const metadataFiles = getApplyPatchMetadataFiles(details);
|
||||
if (metadataFiles.length === 0) return [];
|
||||
|
||||
const requests: CommentCheckRequest[] = [];
|
||||
for (const file of metadataFiles) {
|
||||
if (file.type === "delete") continue;
|
||||
const filePath = file.movePath ?? file.filePath;
|
||||
if (file.before.length === 0) {
|
||||
requests.push({
|
||||
sourceToolName,
|
||||
toolName: "Write",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
content: file.after,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
requests.push({
|
||||
sourceToolName,
|
||||
toolName: "Edit",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
old_string: file.before,
|
||||
new_string: file.after,
|
||||
},
|
||||
});
|
||||
}
|
||||
return requests;
|
||||
}
|
||||
|
||||
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"];
|
||||
const metadata = isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : [];
|
||||
return metadata;
|
||||
}
|
||||
|
||||
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 || before === undefined || after === undefined) continue;
|
||||
files.push({
|
||||
filePath,
|
||||
before,
|
||||
after,
|
||||
...(movePath === undefined ? {} : { movePath }),
|
||||
...(type === undefined ? {} : { type }),
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function parseApplyPatchRequests(patch: string, sourceToolName = "apply_patch"): CommentCheckRequest[] {
|
||||
const requests: CommentCheckRequest[] = [];
|
||||
let current: ApplyPatchAccumulator | undefined;
|
||||
|
||||
const flush = (): void => {
|
||||
if (!current) return;
|
||||
if (current.operation === "add") {
|
||||
const content = joinPatchLines(current.newLines);
|
||||
if (content.length > 0) {
|
||||
requests.push({
|
||||
sourceToolName,
|
||||
toolName: "Write",
|
||||
filePath: current.filePath,
|
||||
toolInput: {
|
||||
file_path: current.filePath,
|
||||
content,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (current.operation === "update") {
|
||||
const newString = joinPatchLines(current.newLines);
|
||||
if (newString.length > 0) {
|
||||
const filePath = current.movePath ?? current.filePath;
|
||||
requests.push({
|
||||
sourceToolName,
|
||||
toolName: "Edit",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
old_string: joinPatchLines(current.oldLines),
|
||||
new_string: newString,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
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) continue;
|
||||
if (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.newLines.push(line.slice(1));
|
||||
if (line.startsWith("-")) current.oldLines.push(line.slice(1));
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return requests;
|
||||
}
|
||||
|
||||
function makeAccumulator(operation: ApplyPatchAccumulator["operation"], filePath: string): ApplyPatchAccumulator {
|
||||
return {
|
||||
operation,
|
||||
filePath,
|
||||
oldLines: [],
|
||||
newLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getEdits(value: unknown): CheckerEdit[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const edits: CheckerEdit[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
const oldString = getString(item, ["oldString", "old_string"]);
|
||||
const newString = getString(item, ["newString", "new_string"]);
|
||||
if (oldString === undefined || newString === undefined) continue;
|
||||
edits.push({
|
||||
old_string: oldString,
|
||||
new_string: newString,
|
||||
});
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
function getContentText(content: ToolResultContent[] | undefined): string {
|
||||
if (!content) return "";
|
||||
return content
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function getString(input: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = input[key];
|
||||
if (typeof value === "string") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function joinPatchLines(lines: 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,195 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { CommentCheckerHookInput } from "./core.js";
|
||||
|
||||
export type ProcessResult = {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise<ProcessResult>;
|
||||
|
||||
export type RunCommentCheckerOptions = {
|
||||
binaryPath?: string;
|
||||
customPrompt?: string;
|
||||
resolveBinary?: () => string | undefined;
|
||||
executor?: ProcessExecutor;
|
||||
};
|
||||
|
||||
export type CommentCheckerRunResult = {
|
||||
status: "pass" | "warning" | "error" | "missing";
|
||||
message: string;
|
||||
binaryPath?: string;
|
||||
exitCode?: number | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
|
||||
export type CommentCheckerRunner = (input: CommentCheckerHookInput) => Promise<CommentCheckerRunResult>;
|
||||
|
||||
export async function runCommentChecker(
|
||||
input: CommentCheckerHookInput,
|
||||
options: RunCommentCheckerOptions = {},
|
||||
): Promise<CommentCheckerRunResult> {
|
||||
const binaryPath =
|
||||
options.binaryPath ?? (options.resolveBinary ? options.resolveBinary() : resolveCommentCheckerBinary());
|
||||
if (!binaryPath) {
|
||||
return {
|
||||
status: "missing",
|
||||
message: "comment-checker binary not found. Run npm install for the codex-comment-checker plugin.",
|
||||
};
|
||||
}
|
||||
|
||||
const args = ["check"];
|
||||
if (options.customPrompt) {
|
||||
args.push("--prompt", options.customPrompt);
|
||||
}
|
||||
|
||||
const executor = options.executor ?? spawnProcess;
|
||||
const result = await executor(binaryPath, args, JSON.stringify(input));
|
||||
const message = result.stderr || result.stdout;
|
||||
if (result.exitCode === 0) {
|
||||
return {
|
||||
status: "pass",
|
||||
message: "",
|
||||
binaryPath,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
if (result.exitCode === 2) {
|
||||
return {
|
||||
status: "warning",
|
||||
message,
|
||||
binaryPath,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error",
|
||||
message,
|
||||
binaryPath,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCommentCheckerBinary(): string | undefined {
|
||||
const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker";
|
||||
const fromPackageApi = resolvePackageApiBinary();
|
||||
if (fromPackageApi) return fromPackageApi;
|
||||
const fromPackage = resolvePackageBinary(binaryName);
|
||||
if (fromPackage) return fromPackage;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePackageApiBinary(): string | undefined {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageExports: unknown = require("@code-yeongyu/comment-checker");
|
||||
if (!isCommentCheckerPackage(packageExports)) return undefined;
|
||||
const binaryPath = packageExports.getBinaryPath();
|
||||
return existsSync(binaryPath) ? binaryPath : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePackageBinary(binaryName: string): string | undefined {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packagePath = require.resolve("@code-yeongyu/comment-checker/package.json");
|
||||
const binaryPath = join(dirname(packagePath), "bin", binaryName);
|
||||
return existsSync(binaryPath) ? binaryPath : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isCommentCheckerPackage(value: unknown): value is { getBinaryPath: () => string } {
|
||||
return isRecord(value) && typeof value["getBinaryPath"] === "function";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
interface OutputAccumulator {
|
||||
text: string;
|
||||
bytes: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function appendOutput(output: OutputAccumulator, chunk: string, maxOutputBytes: number): void {
|
||||
if (output.truncated) return;
|
||||
|
||||
const remainingBytes = maxOutputBytes - output.bytes;
|
||||
const chunkBytes = Buffer.byteLength(chunk, "utf8");
|
||||
if (chunkBytes <= remainingBytes) {
|
||||
output.text += chunk;
|
||||
output.bytes += chunkBytes;
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingBytes > 0) {
|
||||
output.text += Buffer.from(chunk, "utf8").subarray(0, remainingBytes).toString("utf8");
|
||||
output.bytes += remainingBytes;
|
||||
}
|
||||
output.truncated = true;
|
||||
}
|
||||
|
||||
function formatOutput(output: OutputAccumulator, streamName: "stdout" | "stderr", maxOutputBytes: number): string {
|
||||
if (!output.truncated) return output.text;
|
||||
return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`;
|
||||
}
|
||||
|
||||
export function spawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
stdin: string,
|
||||
maxOutputBytes: number = MAX_PROCESS_OUTPUT_BYTES,
|
||||
): Promise<ProcessResult> {
|
||||
return new Promise((resolve) => {
|
||||
const outputByteLimit = Number.isFinite(maxOutputBytes) && maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0;
|
||||
const proc = spawn(command, args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const stdout: OutputAccumulator = { text: "", bytes: 0, truncated: false };
|
||||
const stderr: OutputAccumulator = { text: "", bytes: 0, truncated: false };
|
||||
|
||||
proc.stdout.setEncoding("utf-8");
|
||||
proc.stderr.setEncoding("utf-8");
|
||||
proc.stdout.on("data", (chunk: string) => {
|
||||
appendOutput(stdout, chunk, outputByteLimit);
|
||||
});
|
||||
proc.stderr.on("data", (chunk: string) => {
|
||||
appendOutput(stderr, chunk, outputByteLimit);
|
||||
});
|
||||
proc.once("error", (error) => {
|
||||
appendOutput(stderr, error.message, outputByteLimit);
|
||||
resolve({
|
||||
exitCode: null,
|
||||
stdout: formatOutput(stdout, "stdout", outputByteLimit),
|
||||
stderr: formatOutput(stderr, "stderr", outputByteLimit),
|
||||
});
|
||||
});
|
||||
proc.once("close", (exitCode) => {
|
||||
resolve({
|
||||
exitCode,
|
||||
stdout: formatOutput(stdout, "stdout", outputByteLimit),
|
||||
stderr: formatOutput(stderr, "stderr", outputByteLimit),
|
||||
});
|
||||
});
|
||||
proc.stdin.end(stdin);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user