feat(omo-claude): vendor comment-checker component with CC patches
Vendored from omo-codex via sync-components: model/turn_id optional in the PostToolUse validator (permission_mode kept), CLAUDE_PLUGIN_ROOT hook path. Builds clean; runs without crash on a CC-shaped PostToolUse payload (no turn_id). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "^(apply_patch|write|Write|edit|Edit|multi_edit|multiedit|MultiEdit)$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use",
|
||||||
|
"timeout": 30,
|
||||||
|
"statusMessage": "checking comments"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "@code-yeongyu/codex-comment-checker",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Codex plugin that runs comment-checker after edit-like PostToolUse hooks.",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "npm@11.12.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/code-yeongyu/codex-comment-checker",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/code-yeongyu/codex-comment-checker.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/code-yeongyu/codex-comment-checker/issues"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"codex",
|
||||||
|
"codex-plugin",
|
||||||
|
"comment-checker",
|
||||||
|
"hooks",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"codex-comment-checker": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"hooks",
|
||||||
|
"skills",
|
||||||
|
".codex-plugin",
|
||||||
|
"LICENSE",
|
||||||
|
"NOTICE",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"test": "vitest --run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check --write .",
|
||||||
|
"check": "tsc --noEmit && biome check . && npm run build"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@code-yeongyu/comment-checker": "^0.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.4.15",
|
||||||
|
"@types/node": "^25.7.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: comment-checker
|
||||||
|
description: Use when Codex needs to understand or respond to automatic comment-checker feedback emitted after an edit-like PostToolUse hook.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Codex Comment Checker
|
||||||
|
|
||||||
|
The plugin registers a `PostToolUse` hook for successful `apply_patch`, `write`, `edit`, `multi_edit`, and `multiedit` calls.
|
||||||
|
|
||||||
|
When comment-checker reports a warning after a patch, Codex receives blocking feedback and should fix or explain the flagged comment before moving on.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- No MCP tool is exposed.
|
||||||
|
- Non-edit tools are ignored by this plugin.
|
||||||
|
- Missing checker binaries emit no hook output so normal Codex work can continue.
|
||||||
@@ -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: codex-comment-checker hook post-tool-use\n");
|
||||||
|
process.exitCode = 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = result.message.trim();
|
||||||
|
if (message.length > 0) {
|
||||||
|
warnings.push({ filePath: request.filePath, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length === 0) return "";
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
decision: "block",
|
||||||
|
reason: formatWarnings(warnings),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
value["hook_event_name"] === "PostToolUse" &&
|
||||||
|
typeof value["session_id"] === "string" &&
|
||||||
|
(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&
|
||||||
|
(typeof value["transcript_path"] === "string" || value["transcript_path"] === null) &&
|
||||||
|
typeof value["cwd"] === "string" &&
|
||||||
|
(typeof value["model"] === "string" || value["model"] === undefined) &&
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["test/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "test/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user