vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}

This commit is contained in:
YeonGyu-Kim
2026-05-25 22:24:37 +09:00
parent 06c86f526a
commit 2415f37bc0
260 changed files with 22715 additions and 0 deletions
@@ -0,0 +1,13 @@
# Normalize line endings: store LF in git, check out LF on every platform.
# Required so biome's --check passes on Windows (default core.autocrlf=true).
* text=auto eol=lf
# Explicit binary types
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.tgz binary
*.gz binary
@@ -0,0 +1,12 @@
* @code-yeongyu
.github/workflows/* @code-yeongyu
.github/dependabot.yml @code-yeongyu
package.json @code-yeongyu
package-lock.json @code-yeongyu
LICENSE @code-yeongyu
NOTICE @code-yeongyu
README.md @code-yeongyu
CHANGELOG.md @code-yeongyu
.codex-plugin/plugin.json @code-yeongyu
hooks/hooks.json @code-yeongyu
@@ -0,0 +1,40 @@
name: Bug Report
description: Report broken Codex hook, MCP, or comment-checking behavior
labels: [bug]
body:
- type: markdown
attributes:
value: |
Include the Codex tool payload, hook output, and plugin version needed to reproduce.
- type: textarea
id: what
attributes:
label: What happened?
description: Include exact output/errors.
validations:
required: true
- type: textarea
id: payload
attributes:
label: Tool payload
description: Paste the minimal PostToolUse or MCP payload that reproduces the issue.
render: json
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: input
id: version
attributes:
label: codex-comment-checker version
placeholder: 0.1.0
validations:
required: false
@@ -0,0 +1,27 @@
name: Feature Request
description: Propose a Codex comment-checker hook or MCP improvement
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow is blocked or awkward today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: What should codex-comment-checker do?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: What else could solve this?
validations:
required: false
@@ -0,0 +1,45 @@
{
"name": "main protection",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "test (ubuntu-latest · node 20)" },
{ "context": "test (ubuntu-latest · node 22)" },
{ "context": "test (macos-latest · node 20)" },
{ "context": "test (macos-latest · node 22)" }
]
}
}
],
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
]
}
@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
dev-dependencies:
dependency-type: development
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
@@ -0,0 +1,19 @@
## Summary
<!-- Brief description, 1-3 bullets -->
-
## Verification
- [ ] `npm run check` (typecheck + biome + build)
- [ ] `npm test` (unit tests)
- [ ] `npm pack --dry-run` (release sanity)
- [ ] Hook smoke-tested locally with `node dist/cli.js hook post-tool-use`
## Codex plugin impact
- [ ] `.codex-plugin/plugin.json` remains valid
- [ ] `hooks/hooks.json` still uses stable Codex hook JSON
- [ ] No MCP server or MCP tool is exposed
- [ ] CHANGELOG entry added for user-facing changes
@@ -0,0 +1,47 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }} · node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22"]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node ${{ matrix.node }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
@@ -0,0 +1,51 @@
name: publish
on:
release:
types: [published]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
registry-url: https://registry.npmjs.org
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
- name: Publish to npm
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "NODE_AUTH_TOKEN is not configured; skipping npm publish."
exit 0
fi
npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
@@ -0,0 +1,7 @@
node_modules/
*.log
.DS_Store
.env
.env.*
coverage/
.vitest/
@@ -0,0 +1,35 @@
# Repository Conventions
Conventions for human contributors and AI agents working on this repository.
## Style
- Terse technical prose. No emojis in commits, issues, PR comments, or code.
- TypeScript strict mode. No `any`, no `unknown` casts where avoidable, no `@ts-ignore`, no `@ts-expect-error`, no enums.
- ESM modules with `.js` suffix in runtime import paths.
- Tabs for indentation. Double quotes for strings.
- Tests use vitest with `#given .. #when .. #then` descriptions or plain `// given / // when / // then` body comments.
## Commands
- `npm install` - install dependencies.
- `npm test` - run vitest once.
- `npm run typecheck` - strict TypeScript check.
- `npm run check` - type check, biome, and build.
- `npm pack --dry-run` - release package smoke test.
- `node dist/cli.js hook post-tool-use < fixture.json` - smoke-test the Codex hook.
## Constraints
- No Bun APIs. Runtime is Node only because Codex launches plugin hooks with Node.
- Keep Codex `PostToolUse` hook behavior covered by tests.
- Keep `apply_patch` extraction covered by tests.
- `apply_patch` must support Codex `tool_input.command`, raw patch text, and OMO-compatible metadata.
- Hook output must use the stable Codex hook JSON contract.
- Do not expose an MCP server or MCP tool from this plugin.
## Don'ts
- No `git add -A` or `git add .`. Stage only the files you changed.
- No `git commit --no-verify`. No force pushes. No history rewriting on shared branches.
- Do not couple this package back to pi, omo, or senpi internal source paths.
@@ -0,0 +1,33 @@
# Changelog
## Unreleased
### Added
- Restore `write`, `edit`, `multi_edit`, and `multiedit` PostToolUse coverage alongside `apply_patch`.
- Forward Codex `transcript_path` into native comment-checker hook input when available.
- Add package smoke coverage for portable hook entrypoints.
### Changed
- Treat the native checker binary as an optional dependency for unsupported platforms.
- Cap child process stdout/stderr captured from the native checker.
- Run CI on Windows in addition to Ubuntu and macOS.
## [0.1.1] - 2026-05-15
### Changed
- Limit automatic comment checking to successful `apply_patch` hook events.
- Remove the `comment_check` MCP tool and MCP server configuration.
- Update plugin metadata, docs, and contributor guidance to describe hook-only behavior.
## [0.1.0] - 2026-05-15
### Added
- Initial `codex-comment-checker` Codex plugin.
- `PostToolUse` hook for `apply_patch`, `write`, `edit`, and `multiedit` style tool calls.
- Blocking hook feedback when `comment-checker` reports warnings.
- `comment_check` MCP tool for explicit write/edit/multiedit checks.
- Codex plugin manifest, local MCP config, bundled skill, and GitHub repository metadata.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Yeongyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,6 @@
codex-comment-checker
This package ports the pi-comment-checker hook into a Codex plugin repository.
The plugin targets Codex plugin manifests and plugin-bundled lifecycle hooks.
The checker engine is provided by @code-yeongyu/comment-checker.
@@ -0,0 +1,90 @@
# codex-comment-checker
[![ci](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Codex plugin that runs [`@code-yeongyu/comment-checker`](https://github.com/code-yeongyu/go-claude-code-comment-checker) after successful edit-like `PostToolUse` hook calls.
## Behavior
| Case | Result |
|------|--------|
| `apply_patch` succeeds | parses `tool_input.command` and checks added/updated files |
| `write`, `edit`, `multi_edit`, or `multiedit` succeeds | maps the Codex payload to the native checker hook input |
| non-edit tool succeeds | ignored |
| checker exits `2` | returns Codex `PostToolUse` blocking feedback so the model fixes or explains the warning |
| checker binary missing or unavailable on the current platform | emits no hook output |
| checker exits unexpectedly | leaves hook output unchanged |
Deletes are ignored because they cannot introduce new comments.
## Codex Plugin
The plugin ships:
- `.codex-plugin/plugin.json` for Codex plugin discovery.
- `hooks/hooks.json` for the `PostToolUse` hook.
- `skills/comment-checker/SKILL.md` with usage guidance.
The hook command is:
```bash
node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use
```
No MCP server or `comment_check` tool is exposed.
## Local Development
```bash
npm install
npm test
npm run typecheck
npm run check
npm pack --dry-run
```
Smoke-test the hook:
```bash
node dist/cli.js hook post-tool-use < test/fixtures/post-tool-use.json
```
## Local Codex Installation
From the marketplace root containing this plugin:
```bash
codex plugin marketplace add /path/to/codex-plugins
node /path/to/codex-plugins/scripts/install-local.mjs /path/to/codex-plugins
```
If your local Codex build exposes plugin install commands, you can install from the UI or CLI instead. For older local builds, the marketplace installer builds and copies the plugin into `~/.codex/plugins/cache/<marketplace>/omo/0.1.0`, installs runtime dependencies there, and enables:
```toml
[features]
plugins = true
plugin_hooks = true
[plugins."omo@code-yeongyu-codex-plugins"]
enabled = true
```
## Branch Rules and Releases
- `main` is protected by `.github/branch-ruleset.json`.
- CI runs Node 20 and 22 on Ubuntu, macOS, and Windows.
- Releases are GitHub Releases tagged as `v<semver>`.
- Publishing runs from the `publish` workflow after a GitHub Release is published.
## Privacy
This plugin runs locally. It sends hook input to the optional local `comment-checker` binary when available and does not call a network service by itself.
## License
[MIT](LICENSE).
## Related
- [pi-comment-checker](https://github.com/code-yeongyu/pi-comment-checker) - source extension this Codex plugin ports.
- [comment-checker](https://github.com/code-yeongyu/go-claude-code-comment-checker) - native checker binary.
@@ -0,0 +1,48 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noDefaultExport": "error",
"noEnum": "error",
"noNonNullAssertion": "error",
"useImportType": "error",
"useConst": "error",
"useNodejsImportProtocol": "off"
},
"complexity": {
"useLiteralKeys": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noTsIgnore": "error",
"noControlCharactersInRegex": "off",
"noEmptyInterface": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 3,
"lineWidth": 120
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
},
"overrides": [
{
"includes": ["vitest.config.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
]
}
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "^(apply_patch|write|Write|edit|Edit|multi_edit|multiedit|MultiEdit)$",
"hooks": [
{
"type": "command",
"command": "node \"${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" &&
(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);
});
}
@@ -0,0 +1,317 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
type CodexPostToolUseInput,
extractCodexCommentCheckRequests,
runCommentCheckerPostToolUse,
} from "../src/codex-hook.ts";
type CliResult = {
exitCode: number | null;
stdout: string;
stderr: string;
};
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
function runHookCli(input: string): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI_PATH, "hook", "post-tool-use"], {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", reject);
child.once("close", (exitCode) => {
resolve({ exitCode, stdout, stderr });
});
child.stdin.end(input);
});
}
function postToolUseInput(overrides: Partial<CodexPostToolUseInput> = {}): CodexPostToolUseInput {
return {
session_id: "thread-1",
turn_id: "turn-1",
transcript_path: null,
cwd: "/repo",
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "never",
tool_name: "apply_patch",
tool_input: {
command: [
"*** Begin Patch",
"*** Update File: src/example.ts",
"@@",
"-const value = 1;",
"+// explains value",
"+const value = 2;",
"*** End Patch",
].join("\n"),
},
tool_response: "Success. Updated files.",
tool_use_id: "call-1",
...overrides,
};
}
describe("extractCodexCommentCheckRequests", () => {
it("#given codex apply_patch command #when extracting #then returns edit request for changed file", () => {
const requests = extractCodexCommentCheckRequests(postToolUseInput());
expect(requests).toEqual([
{
sourceToolName: "apply_patch",
toolName: "Edit",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
old_string: "const value = 1;\n",
new_string: "// explains value\nconst value = 2;\n",
},
},
]);
});
it("#given unsupported post tool event #when extracting #then returns no requests", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "read",
tool_input: { file_path: "src/example.ts", content: "// hi\nconst value = 1;\n" },
}),
);
expect(requests).toEqual([]);
});
it("#given codex write payload #when extracting #then returns write request", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "write",
tool_input: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
}),
);
expect(requests).toEqual([
{
sourceToolName: "write",
toolName: "Write",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
},
]);
});
it("#given codex edit payload #when extracting #then returns edit request", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "edit",
tool_input: {
path: "src/example.ts",
oldString: "const value = 1;\n",
newString: "// explains value\nconst value = 2;\n",
},
}),
);
expect(requests).toEqual([
{
sourceToolName: "edit",
toolName: "Edit",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
old_string: "const value = 1;\n",
new_string: "// explains value\nconst value = 2;\n",
},
},
]);
});
it("#given one-sided codex edit payload #when extracting #then returns no requests", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "edit",
tool_input: {
path: "src/example.ts",
oldString: "const value = 1;\n",
},
}),
);
expect(requests).toEqual([]);
});
it("#given codex multi_edit payload #when extracting #then returns multiedit request", () => {
const requests = extractCodexCommentCheckRequests(
postToolUseInput({
tool_name: "multi_edit",
tool_input: {
filePath: "src/example.ts",
edits: [
{ old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" },
{ oldString: "const b = 1;\n", newString: "// explains b\nconst b = 2;\n" },
],
},
}),
);
expect(requests).toEqual([
{
sourceToolName: "multi_edit",
toolName: "MultiEdit",
filePath: "src/example.ts",
toolInput: {
file_path: "src/example.ts",
edits: [
{ old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" },
{ old_string: "const b = 1;\n", new_string: "// explains b\nconst b = 2;\n" },
],
},
},
]);
});
});
describe("runCommentCheckerPostToolUse", () => {
it("#given checker warning #when hook runs #then returns blocking feedback JSON", async () => {
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
run: async () => ({
status: "warning",
message: "comment warning: explain less",
}),
});
expect(JSON.parse(output)).toEqual({
decision: "block",
reason: "comment-checker found issues in src/example.ts:\ncomment warning: explain less",
});
});
it("#given missing checker binary #when hook runs #then emits no hook output", async () => {
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
run: async () => ({
status: "missing",
message: "not installed",
}),
});
expect(output).toBe("");
});
it("#given transcript path #when hook runs #then forwards it to checker input", async () => {
let transcriptPath = "";
await runCommentCheckerPostToolUse(
postToolUseInput({
transcript_path: "/tmp/codex-comment-checker-transcript.jsonl",
tool_name: "write",
tool_input: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
}),
{
run: async (input) => {
transcriptPath = input.transcript_path;
return {
status: "pass",
message: "",
};
},
},
);
expect(transcriptPath).toBe("/tmp/codex-comment-checker-transcript.jsonl");
});
it("#given null transcript path #when hook runs #then forwards empty string fallback", async () => {
let transcriptPath = "unset";
await runCommentCheckerPostToolUse(
postToolUseInput({
transcript_path: null,
tool_name: "write",
tool_input: {
file_path: "src/example.ts",
content: "// explains value\nconst value = 1;\n",
},
}),
{
run: async (input) => {
transcriptPath = input.transcript_path;
return {
status: "pass",
message: "",
};
},
},
);
expect(transcriptPath).toBe("");
});
});
describe("runCodexHookCli", () => {
it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = "break;\n";
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = '"break;"\n';
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given non-string transcript path #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = `${JSON.stringify({ ...postToolUseInput(), transcript_path: 42 })}\n`;
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
});
@@ -0,0 +1,15 @@
{
"session_id": "00000000-0000-0000-0000-000000000000",
"turn_id": "00000000-0000-0000-0000-000000000001",
"transcript_path": "/tmp/codex-comment-checker-transcript.jsonl",
"cwd": ".",
"hook_event_name": "PostToolUse",
"model": "gpt-5.5",
"permission_mode": "default",
"tool_name": "apply_patch",
"tool_input": {
"command": "*** Begin Patch\n*** Add File: src/example.ts\n+export const meaning = 42;\n*** End Patch\n"
},
"tool_response": "Success. Updated files.",
"tool_use_id": "toolu_000000000000000000000000"
}
@@ -0,0 +1,109 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type PackageJson = {
readonly type: string;
readonly packageManager: string;
readonly bin: Record<string, string>;
readonly dependencies?: Record<string, unknown>;
readonly optionalDependencies: Record<string, string>;
};
type PluginJson = {
readonly hooks: string;
};
type HookCommand = {
readonly command: string;
};
type HookEntry = {
readonly hooks: readonly HookCommand[];
};
type HooksJson = {
readonly hooks: Record<string, readonly HookEntry[]>;
};
function readPackageJson(path: string): PackageJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
return parsed;
}
function readPluginJson(path: string): PluginJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`);
return parsed;
}
function readHooksJson(path: string): HooksJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
return parsed;
}
describe("plugin package metadata", () => {
it("#given packaged plugin files #when validating entrypoints #then hook command uses portable plugin root interpolation", () => {
// given
const packageJson = readPackageJson("package.json");
const pluginJson = readPluginJson(".codex-plugin/plugin.json");
const hooksJson = readHooksJson("hooks/hooks.json");
const cliSource = readFileSync("src/cli.ts", "utf8");
// when
const command = hooksJson.hooks["PostToolUse"]?.[0]?.hooks[0]?.command;
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
// then
expect(packageJson.type).toBe("module");
expect(packageJson.packageManager).toBe("npm@11.12.1");
expect(packageJson.dependencies ?? {}).not.toHaveProperty("@code-yeongyu/comment-checker");
expect(packageJson.optionalDependencies).toHaveProperty("@code-yeongyu/comment-checker");
expect(packageJson.bin["codex-comment-checker"]).toBe("./dist/cli.js");
expect(pluginJson.hooks).toBe("./hooks/hooks.json");
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`);
});
});
function isPackageJson(value: unknown): value is PackageJson {
if (!isRecord(value)) return false;
const dependencies = value["dependencies"];
return (
value["type"] === "module" &&
value["packageManager"] === "npm@11.12.1" &&
isStringRecord(value["bin"]) &&
isStringRecord(value["optionalDependencies"]) &&
(dependencies === undefined || isRecord(dependencies))
);
}
function isPluginJson(value: unknown): value is PluginJson {
return isRecord(value) && typeof value["hooks"] === "string";
}
function isHooksJson(value: unknown): value is HooksJson {
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
return Object.values(value["hooks"]).every(isHookEntries);
}
function isHookEntries(value: unknown): value is readonly HookEntry[] {
return Array.isArray(value) && value.every(isHookEntry);
}
function isHookEntry(value: unknown): value is HookEntry {
return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand);
}
function isHookCommand(value: unknown): value is HookCommand {
return isRecord(value) && typeof value["command"] === "string";
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,66 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
MAX_PROCESS_OUTPUT_BYTES,
resolveCommentCheckerBinary,
runCommentChecker,
spawnProcess,
} from "../src/runner.js";
describe("spawnProcess", () => {
it("#given noisy checker process #when output exceeds cap #then stderr is bounded", async () => {
// given
const maxOutputBytes = 16;
// when
const result = await spawnProcess(
process.execPath,
["-e", "process.stderr.write('x'.repeat(40)); process.exit(2);"],
"",
maxOutputBytes,
);
// then
expect(MAX_PROCESS_OUTPUT_BYTES).toBeGreaterThan(maxOutputBytes);
expect(result.exitCode).toBe(2);
expect(result.stderr).toBe(`${"x".repeat(maxOutputBytes)}\n[stderr truncated after 16 bytes]`);
});
});
describe("resolveCommentCheckerBinary", () => {
it("#given installed checker package #when resolving binary #then returns existing checker binary", () => {
// given / when
const binaryPath = resolveCommentCheckerBinary();
// then
expect(binaryPath).toBeDefined();
expect(binaryPath ?? "").toContain("comment-checker");
expect(existsSync(binaryPath ?? "")).toBe(true);
});
});
describe("runCommentChecker", () => {
it("#given missing checker binary #when runner starts #then returns missing result", async () => {
// given / when
const result = await runCommentChecker(
{
session_id: "session-1",
tool_name: "Write",
transcript_path: "",
cwd: "/repo",
hook_event_name: "PostToolUse",
tool_input: {
file_path: "src/example.ts",
content: "const value = 1;\n",
},
},
{
resolveBinary: () => undefined,
},
);
// then
expect(result.status).toBe("missing");
});
});
@@ -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/**/*"]
}
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
pool: "threads",
},
});