test(omo-codex): batch 50 (4 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:08 +09:00
parent ffa06fe065
commit 140c656022
4 changed files with 836 additions and 0 deletions
@@ -0,0 +1,44 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path";
import { argv, execPath, stderr } from "node:process";
import { fileURLToPath } from "node:url";
import { runPostToolUseHookCli } from "./codex-hook.js";
const PACKAGE_LSP_MCP_CLI = "../../../../../lsp-tools-mcp/dist/cli.js";
async function main(): Promise<void> {
const [command = "mcp", subcommand = ""] = argv.slice(2);
if (command === "hook" && subcommand === "post-tool-use") {
await runPostToolUseHookCli();
return;
}
if (command === "mcp") {
await runPackageLspMcpCli();
return;
}
stderr.write("Usage: omo-lsp [mcp | hook post-tool-use]\n");
process.exitCode = 2;
}
main().catch((error: unknown) => {
stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
process.exitCode = 1;
});
async function runPackageLspMcpCli(): Promise<void> {
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), PACKAGE_LSP_MCP_CLI);
const child = spawn(execPath, [cliPath, "mcp"], { stdio: "inherit" });
await new Promise<void>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code !== null && code !== 0) process.exitCode = code;
if (code === null && signal !== null) process.exitCode = 1;
resolve();
});
});
}
@@ -0,0 +1,285 @@
import { readFileSync } from "node:fs";
import { stdin as processStdin } from "node:process";
import { disposeDefaultLspManager } from "@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js";
import { executeLspDiagnostics } from "@code-yeongyu/lsp-tools-mcp/dist/tools.js";
export type DiagnosticsRunner = (filePath: string) => Promise<string>;
export interface CodexPostToolUseInput {
tool_name?: unknown;
tool_input?: unknown;
tool_response?: unknown;
transcript_path?: unknown;
}
interface DiagnosticBlock {
filePath: string;
diagnostics: string;
}
interface PostToolUseHookOutput {
decision: "block";
reason: string;
hookSpecificOutput: {
hookEventName: "PostToolUse";
additionalContext: string;
};
}
const MUTATION_TOOL_NAMES = new Set(["apply_patch", "write", "edit", "multiedit", "multi_edit"]);
const CLEAN_DIAGNOSTICS_TEXT = "No diagnostics found";
const UNSUPPORTED_EXTENSION_TEXT = "No LSP server configured for extension:";
const DIAGNOSTIC_START_PATTERN = /(?:error|warning|information|hint)\[[^\]\r\n]+\] \(\d+\) at \d+:\d+:/g;
const DIAGNOSTIC_CHUNK_PATTERN = /^(?:error|warning|information|hint)\[[^\]\r\n]+\] \(\d+\) at \d+:\d+:/;
const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000;
const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200;
const MAX_CONCURRENT_DIAGNOSTICS = 4;
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 async function runLspDiagnosticsText(filePath: string): Promise<string> {
const result = await executeLspDiagnostics({ filePath, severity: "error" });
return result.content.map((block) => block.text).join("\n");
}
export async function runLspPostToolUseHook(
input: CodexPostToolUseInput,
runDiagnostics: DiagnosticsRunner = runLspDiagnosticsText,
): Promise<string> {
const filePaths = extractMutatedFilePaths(input);
if (filePaths.length === 0) return "";
const blocks: DiagnosticBlock[] = [];
for (const { filePath, diagnostics } of await collectDiagnostics(filePaths, runDiagnostics)) {
if (isCleanDiagnostics(diagnostics)) continue;
blocks.push({ filePath, diagnostics });
}
if (blocks.length === 0) return "";
const rawReason = blocks.map(formatDiagnosticBlock).join("\n\n");
const reason = limitHookText(rawReason, hookFeedbackLimit(input.transcript_path));
const output: PostToolUseHookOutput = {
decision: "block",
reason,
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: reason,
},
};
return `${JSON.stringify(output)}\n`;
}
async function collectDiagnostics(
filePaths: readonly string[],
runDiagnostics: DiagnosticsRunner,
): Promise<DiagnosticBlock[]> {
const results: DiagnosticBlock[] = [];
let nextIndex = 0;
const workerCount = Math.min(MAX_CONCURRENT_DIAGNOSTICS, filePaths.length);
async function worker(): Promise<void> {
for (;;) {
const index = nextIndex;
nextIndex += 1;
const filePath = filePaths[index];
if (filePath === undefined) return;
results[index] = { filePath, diagnostics: (await runDiagnostics(filePath)).trim() };
}
}
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}
function formatDiagnosticBlock({ filePath, diagnostics }: DiagnosticBlock): string {
return `LSP diagnostics after editing ${filePath}:\n\n${formatDiagnosticsForDisplay(diagnostics)}`;
}
function formatDiagnosticsForDisplay(diagnostics: string): string {
const chunks = splitDiagnosticChunks(diagnostics);
if (!chunks.some(isDiagnosticChunk)) return chunks.join("\n").trim();
return chunks.map(formatDiagnosticChunk).join("\n");
}
function splitDiagnosticChunks(diagnostics: string): string[] {
const normalized = diagnostics.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
if (normalized.length === 0) return [];
const matches = Array.from(normalized.matchAll(DIAGNOSTIC_START_PATTERN));
const firstMatch = matches[0];
if (firstMatch?.index === undefined) return [normalized];
const chunks: string[] = [];
const leadingText = normalized.slice(0, firstMatch.index).trim();
if (leadingText.length > 0) chunks.push(leadingText);
for (const [index, match] of matches.entries()) {
if (match.index === undefined) continue;
const nextMatch = matches[index + 1];
const end = nextMatch?.index ?? normalized.length;
const chunk = normalized.slice(match.index, end).trim();
if (chunk.length > 0) chunks.push(chunk);
}
return chunks;
}
function formatDiagnosticChunk(chunk: string): string {
const lines = chunk.split("\n");
const firstLine = lines[0];
if (firstLine === undefined) return "";
if (!isDiagnosticChunk(firstLine)) return chunk;
const followingLines = lines.slice(1).map((line) => ` ${line}`);
return [`- ${firstLine}`, ...followingLines].join("\n");
}
function isDiagnosticChunk(chunk: string): boolean {
return DIAGNOSTIC_CHUNK_PATTERN.test(chunk);
}
function hookFeedbackLimit(transcriptPath: unknown): number {
return isContextPressureTranscript(transcriptPath)
? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS
: DEFAULT_MAX_HOOK_FEEDBACK_CHARS;
}
function isContextPressureTranscript(transcriptPath: unknown): boolean {
if (typeof transcriptPath !== "string") 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}`;
}
export function extractMutatedFilePaths(input: CodexPostToolUseInput): string[] {
if (!isMutationTool(input.tool_name)) return [];
if (isFailedToolResponse(input.tool_response)) return [];
const toolInput = isRecord(input.tool_input) ? input.tool_input : {};
const paths = new Set<string>();
addStringValue(paths, toolInput["path"]);
addStringValue(paths, toolInput["filePath"]);
addStringValue(paths, toolInput["file_path"]);
addStringArray(paths, toolInput["paths"]);
addStringArray(paths, toolInput["filePaths"]);
addStringArray(paths, toolInput["file_paths"]);
addPatchPayloads(paths, toolInput);
addPatchFiles(paths, toolInput["files"]);
addPatchFiles(paths, toolInput["changes"]);
return [...paths];
}
export async function runPostToolUseHookCli(stdin: NodeJS.ReadStream = processStdin): Promise<void> {
try {
const raw = await readStdin(stdin);
if (!raw.trim()) return;
const parsed: unknown = JSON.parse(raw);
const input = isRecord(parsed) ? parsed : {};
const output = await runLspPostToolUseHook(input);
if (output) process.stdout.write(output);
} finally {
await disposeDefaultLspManager();
}
}
function isMutationTool(value: unknown): boolean {
if (typeof value !== "string") return false;
return MUTATION_TOOL_NAMES.has(value.toLowerCase());
}
function isCleanDiagnostics(diagnostics: string): boolean {
return (
diagnostics.length === 0 ||
diagnostics === CLEAN_DIAGNOSTICS_TEXT ||
diagnostics.startsWith(UNSUPPORTED_EXTENSION_TEXT)
);
}
function isFailedToolResponse(value: unknown): boolean {
if (!isRecord(value)) return false;
return (
value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"
);
}
function addStringValue(paths: Set<string>, value: unknown): void {
if (typeof value === "string" && value.length > 0) {
paths.add(value);
}
}
function addStringArray(paths: Set<string>, value: unknown): void {
if (!Array.isArray(value)) return;
for (const item of value) {
addStringValue(paths, item);
}
}
function addPatchPayloads(paths: Set<string>, input: Record<string, unknown>): void {
addPatchInput(paths, input["input"]);
addPatchInput(paths, input["patch"]);
addPatchInput(paths, input["command"]);
}
function addPatchInput(paths: Set<string>, value: unknown): void {
if (typeof value !== "string") return;
for (const line of value.split("\n")) {
const path = extractPatchHeaderPath(line);
if (path !== undefined) paths.add(path);
}
}
function extractPatchHeaderPath(line: string): string | undefined {
const prefixes = ["*** Add File: ", "*** Update File: ", "*** Move to: "] as const;
for (const prefix of prefixes) {
if (line.startsWith(prefix)) return line.slice(prefix.length).trim();
}
return undefined;
}
function addPatchFiles(paths: Set<string>, value: unknown): void {
if (!Array.isArray(value)) return;
for (const item of value) {
if (!isRecord(item)) continue;
addStringValue(paths, item["path"]);
addStringValue(paths, item["filePath"]);
addStringValue(paths, item["file_path"]);
addStringValue(paths, item["movePath"]);
addStringValue(paths, item["move_path"]);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readStdin(stdin: NodeJS.ReadStream): Promise<string> {
stdin.setEncoding("utf8");
let raw = "";
for await (const chunk of stdin) {
raw += chunk;
}
return raw;
}
@@ -0,0 +1,358 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { extractMutatedFilePaths, runLspPostToolUseHook } from "../src/codex-hook.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
describe("codex PostToolUse hook", () => {
it("extracts files from Codex apply_patch command payloads", () => {
const paths = extractMutatedFilePaths({
tool_name: "apply_patch",
tool_input: {
command: [
"*** Begin Patch",
"*** Add File: src/new.ts",
"+export const value = 1;",
"*** Update File: src/existing.ts",
"@@",
"-export const old = true;",
"+export const old = false;",
"*** End Patch",
].join("\n"),
},
tool_response: "Success. Updated files.",
});
expect(paths).toEqual(["src/new.ts", "src/existing.ts"]);
});
it("extracts files from edit-style tool input aliases", () => {
const paths = extractMutatedFilePaths({
tool_name: "Edit",
tool_input: { file_path: "src/edit.ts" },
tool_response: { ok: true },
});
expect(paths).toEqual(["src/edit.ts"]);
});
it("#given post-edit diagnostics contain one error #when the hook blocks #then it keeps the blocked output shape", async () => {
// given
const output = await runLspPostToolUseHook(
{
tool_name: "apply_patch",
tool_input: {
command: "*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+missing();\n*** End Patch\n",
},
tool_response: "Success. Updated files.",
},
async (filePath) => {
expect(filePath).toBe("src/broken.ts");
return "error[typescript] (2304) at 1:1: Cannot find name 'missing'.";
},
);
// when
const parsed: unknown = JSON.parse(output);
// then
expect(JSON.parse(output)).toEqual({
decision: "block",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext:
"LSP diagnostics after editing src/broken.ts:\n\n" +
"- error[typescript] (2304) at 1:1: Cannot find name 'missing'.",
},
reason:
"LSP diagnostics after editing src/broken.ts:\n\n" +
"- error[typescript] (2304) at 1:1: Cannot find name 'missing'.",
});
expect(parsed).toHaveProperty("decision", "block");
});
it("#given adjacent TypeScript diagnostics #when the hook blocks #then it renders each diagnostic on its own bullet line", async () => {
// given
const output = await runLspPostToolUseHook(
{
tool_name: "apply_patch",
tool_input: {
command: "*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+missing();\n*** End Patch\n",
},
tool_response: "Success. Updated files.",
},
async () =>
"error[typescript] (2307) at 5:7: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.error[typescript] (2307) at 6:49: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.error[typescript] (2307) at 10:7: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.",
);
// when
const parsed: unknown = JSON.parse(output);
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
// then
expect(parsed.reason).toBe(
[
"LSP diagnostics after editing src/broken.ts:",
"",
"- error[typescript] (2307) at 5:7: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.",
"- error[typescript] (2307) at 6:49: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.",
"- error[typescript] (2307) at 10:7: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.",
].join("\n"),
);
expect(parsed.hookSpecificOutput.additionalContext).toBe(parsed.reason);
});
it("#given plain non-diagnostic feedback #when the hook blocks #then it preserves the text after the readable header", async () => {
// given
const output = await runLspPostToolUseHook(
{
tool_name: "write",
tool_input: { path: "src/broken.ts" },
tool_response: { ok: true },
},
async () => "language server failed before diagnostics could be collected",
);
// when
const parsed: unknown = JSON.parse(output);
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
// then
expect(parsed.reason).toBe(
"LSP diagnostics after editing src/broken.ts:\n\nlanguage server failed before diagnostics could be collected",
);
expect(parsed.hookSpecificOutput.additionalContext).toBe(parsed.reason);
});
it("#given plain non-diagnostic feedback with CRLF and bare CR #when the hook blocks #then it normalizes line endings", async () => {
// given
const output = await runLspPostToolUseHook(
{
tool_name: "write",
tool_input: { path: "src/broken.ts" },
tool_response: { ok: true },
},
async () => "\r\nlanguage server failed\r\n retry detail\rbefore diagnostics could be collected\r\n",
);
// when
const parsed: unknown = JSON.parse(output);
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
// then
expect(parsed.reason).toBe(
"LSP diagnostics after editing src/broken.ts:\n\nlanguage server failed\n retry detail\nbefore diagnostics could be collected",
);
expect(parsed.reason).not.toContain("\r");
expect(parsed.hookSpecificOutput.additionalContext).toBe(parsed.reason);
});
it("#given multiple edited files #when only one file has diagnostics #then it injects only files with diagnostics", async () => {
// given
const checkedFilePaths: string[] = [];
const output = await runLspPostToolUseHook(
{
tool_name: "MultiEdit",
tool_input: {
file_paths: ["src/clean.ts", "README.md", "src/broken.ts", "src/broken.ts"],
},
tool_response: { ok: true },
},
async (filePath) => {
checkedFilePaths.push(filePath);
if (filePath === "src/broken.ts") {
return "error[typescript] (2322) at 1:7: Type 'number' is not assignable to type 'string'.";
}
if (filePath === "README.md") {
return "No LSP server configured for extension: .md";
}
return "No diagnostics found";
},
);
// when
const expectedDiagnostics =
"LSP diagnostics after editing src/broken.ts:\n\n" +
"- error[typescript] (2322) at 1:7: Type 'number' is not assignable to type 'string'.";
// then
expect(checkedFilePaths).toEqual(["src/clean.ts", "README.md", "src/broken.ts"]);
expect(JSON.parse(output)).toEqual({
decision: "block",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: expectedDiagnostics,
},
reason: expectedDiagnostics,
});
});
it("#given multiple edited files #when diagnostics resolve out of order #then starts bounded concurrent diagnostics and preserves output order", async () => {
// given
const calls: string[] = [];
const resolvers = new Map<string, (value: string) => void>();
const outputPromise = runLspPostToolUseHook(
{
tool_name: "MultiEdit",
tool_input: { file_paths: ["src/a.ts", "src/b.ts"] },
tool_response: { ok: true },
},
(filePath) =>
new Promise<string>((resolve) => {
calls.push(filePath);
resolvers.set(filePath, resolve);
}),
);
// when
const startedBeforeRelease = [...calls];
const resolveB = resolvers.get("src/b.ts");
const resolveA = resolvers.get("src/a.ts");
if (resolveB === undefined || resolveA === undefined) throw new TypeError("Expected both diagnostics to start");
resolveB("error[typescript] (1000) at 1:1: src/b.ts failed.");
await Promise.resolve();
resolveA("error[typescript] (1000) at 1:1: src/a.ts failed.");
const parsed: unknown = JSON.parse(await outputPromise);
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
// then
expect(startedBeforeRelease).toEqual(["src/a.ts", "src/b.ts"]);
expect(parsed.reason.indexOf("src/a.ts")).toBeLessThan(parsed.reason.indexOf("src/b.ts"));
});
it("#given six edited files #when diagnostics run #then at most four are active concurrently", async () => {
// given
const calls: string[] = [];
const resolvers = new Map<string, (value: string) => void>();
const filePaths = ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts", "src/f.ts"];
const outputPromise = runLspPostToolUseHook(
{
tool_name: "MultiEdit",
tool_input: { file_paths: filePaths },
tool_response: { ok: true },
},
(filePath) =>
new Promise<string>((resolve) => {
calls.push(filePath);
resolvers.set(filePath, resolve);
}),
);
// when
const initialCalls = [...calls];
for (const filePath of filePaths) {
resolvers.get(filePath)?.("No diagnostics found");
await Promise.resolve();
}
await outputPromise;
// then
expect(initialCalls).toEqual(["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts"]);
});
it("does not run diagnostics for failed mutation tool responses", async () => {
const output = await runLspPostToolUseHook(
{
tool_name: "apply_patch",
tool_input: {
command: "*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+missing();\n*** End Patch\n",
},
tool_response: { isError: true },
},
async () => {
throw new Error("diagnostics should not run after failed mutations");
},
);
expect(output).toBe("");
});
it("is silent for clean diagnostics and unsupported extensions", async () => {
const output = await runLspPostToolUseHook(
{
tool_name: "apply_patch",
tool_input: {
command: "*** Begin Patch\n*** Update File: README.md\n@@\n+hello\n*** End Patch\n",
},
tool_response: "Success. Updated files.",
},
async () => "No LSP server configured for extension: .md",
);
expect(output).toBe("");
});
it("#given Codex canonical context-window transcript and large diagnostics #when the hook blocks #then it caps injected feedback", async () => {
const root = mkdtempSync(path.join(tmpdir(), "codex-lsp-context-pressure-"));
tempDirs.push(root);
const transcriptPath = path.join(root, "transcript.jsonl");
writeFileSync(
transcriptPath,
[
"context_length_exceeded",
"Codex ran out of room in the model's context window. Start a new thread before retrying.",
"",
].join("\n"),
);
const largeDiagnostics = [
"error[typescript] (2322) at 1:1: Type 'number' is not assignable to type 'string'.",
"x".repeat(10_000),
].join("\n");
const output = await runLspPostToolUseHook(
{
tool_name: "apply_patch",
tool_input: {
command:
"*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+const value: string = 1;\n*** End Patch\n",
},
tool_response: "Success. Updated files.",
transcript_path: transcriptPath,
},
async () => largeDiagnostics,
);
const parsed: unknown = JSON.parse(output);
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
expect(parsed.reason.length).toBeLessThanOrEqual(1200);
expect(parsed.reason).toContain("LSP diagnostics after editing src/broken.ts");
expect(parsed.reason).toContain("[Truncated hook output");
expect(parsed.hookSpecificOutput.additionalContext).toBe(parsed.reason);
});
});
interface PostToolUseHookOutput {
readonly decision: "block";
readonly reason: string;
readonly hookSpecificOutput: {
readonly hookEventName: "PostToolUse";
readonly additionalContext: string;
};
}
function isPostToolUseHookOutput(value: unknown): value is PostToolUseHookOutput {
if (!isRecord(value)) return false;
const hookSpecificOutput = value["hookSpecificOutput"];
return (
value["decision"] === "block" &&
typeof value["reason"] === "string" &&
isRecord(hookSpecificOutput) &&
hookSpecificOutput["hookEventName"] === "PostToolUse" &&
typeof hookSpecificOutput["additionalContext"] === "string"
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,149 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type PackageJson = {
readonly version: string;
readonly type: string;
readonly packageManager: string;
readonly bin: Record<string, string>;
readonly dependencies: Record<string, string>;
readonly scripts: Record<string, string>;
};
type HookCommand = {
readonly command: string;
};
type HookEntry = {
readonly hooks: readonly HookCommand[];
};
type HooksJson = {
readonly hooks: Record<string, readonly HookEntry[]>;
};
type McpServer = {
readonly command: string;
readonly args: readonly string[];
};
type McpJson = {
readonly mcpServers: Record<string, McpServer>;
};
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 readHooksJson(path: string): HooksJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
return parsed;
}
function readMcpJson(path: string): McpJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isMcpJson(parsed)) throw new TypeError(`Invalid MCP metadata: ${path}`);
return parsed;
}
describe("plugin package metadata", () => {
it("#given packaged component files #when validating entrypoints #then hook command stays local and MCP command references the package", () => {
// given
const packageJson = readPackageJson("package.json");
const hooksJson = readHooksJson("hooks/hooks.json");
const mcpJson = readMcpJson(".mcp.json");
const cliSource = readFileSync("src/cli.ts", "utf8");
const sourceFiles = readdirSync("src");
// when
const command = hooksJson.hooks["PostToolUse"]?.[0]?.hooks[0]?.command;
const lspServer = mcpJson.mcpServers["lsp"];
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
// then
expect(packageJson.type).toBe("module");
expect(packageJson.packageManager).toBe("npm@11.12.1");
expect(packageJson.dependencies).toEqual({
"@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp",
});
expect(packageJson.bin["omo-lsp"]).toBe("./dist/cli.js");
expect(packageJson.bin["codex-lsp"]).toBeUndefined();
expect(packageJson.scripts["build"]).toBe("node scripts/clean-dist.mjs && tsc -p tsconfig.build.json");
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(cliSource).toContain("Usage: omo-lsp [mcp | hook post-tool-use]");
expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`);
expect(lspServer?.command).toBe("node");
expect(lspServer?.args).toEqual(["../../../../lsp-tools-mcp/dist/cli.js", "mcp"]);
expect(cliSource).not.toContain("./lazy-lsp-mcp.js");
expect(cliSource).not.toContain("@code-yeongyu/lsp-tools-mcp");
expect(cliSource).toContain("../../../../../lsp-tools-mcp/dist/cli.js");
expect(sourceFiles.filter((name) => name.startsWith("lazy-mcp") || name === "lazy-lsp-mcp.ts")).toEqual([]);
});
it("#given LSP skill guidance #when validating MCP tool instructions #then tool names are not framed as shell commands", () => {
// given
const skill = readFileSync("skills/lsp/SKILL.md", "utf8");
// when
const mentionsToolInterface = skill.includes("through the tool interface");
const rejectsShellExecution = skill.includes("not shell commands");
// then
expect(mentionsToolInterface).toBe(true);
expect(rejectsShellExecution).toBe(true);
});
});
function isPackageJson(value: unknown): value is PackageJson {
return (
isRecord(value) &&
typeof value["version"] === "string" &&
value["type"] === "module" &&
value["packageManager"] === "npm@11.12.1" &&
isStringRecord(value["bin"]) &&
isStringRecord(value["dependencies"]) &&
isStringRecord(value["scripts"])
);
}
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 isMcpJson(value: unknown): value is McpJson {
if (!isRecord(value) || !isRecord(value["mcpServers"])) return false;
return Object.values(value["mcpServers"]).every(isMcpServer);
}
function isMcpServer(value: unknown): value is McpServer {
return (
isRecord(value) &&
typeof value["command"] === "string" &&
Array.isArray(value["args"]) &&
value["args"].every((item) => typeof item === "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);
}