diff --git a/packages/omo-codex/plugin/components/lsp/src/cli.ts b/packages/omo-codex/plugin/components/lsp/src/cli.ts new file mode 100644 index 000000000..ad7bdd9d6 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/src/cli.ts @@ -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 { + 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 { + const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), PACKAGE_LSP_MCP_CLI); + const child = spawn(execPath, [cliPath, "mcp"], { stdio: "inherit" }); + await new Promise((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(); + }); + }); +} diff --git a/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts b/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts new file mode 100644 index 000000000..5587923b4 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts @@ -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; + +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 { + 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 { + 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 { + const results: DiagnosticBlock[] = []; + let nextIndex = 0; + const workerCount = Math.min(MAX_CONCURRENT_DIAGNOSTICS, filePaths.length); + async function worker(): Promise { + 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(); + 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 { + 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, value: unknown): void { + if (typeof value === "string" && value.length > 0) { + paths.add(value); + } +} + +function addStringArray(paths: Set, value: unknown): void { + if (!Array.isArray(value)) return; + for (const item of value) { + addStringValue(paths, item); + } +} + +function addPatchPayloads(paths: Set, input: Record): void { + addPatchInput(paths, input["input"]); + addPatchInput(paths, input["patch"]); + addPatchInput(paths, input["command"]); +} + +function addPatchInput(paths: Set, 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, 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readStdin(stdin: NodeJS.ReadStream): Promise { + stdin.setEncoding("utf8"); + let raw = ""; + for await (const chunk of stdin) { + raw += chunk; + } + return raw; +} diff --git a/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts new file mode 100644 index 000000000..0b34e1632 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts @@ -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 void>(); + const outputPromise = runLspPostToolUseHook( + { + tool_name: "MultiEdit", + tool_input: { file_paths: ["src/a.ts", "src/b.ts"] }, + tool_response: { ok: true }, + }, + (filePath) => + new Promise((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 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((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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts new file mode 100644 index 000000000..1b0390e5d --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts @@ -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; + readonly dependencies: Record; + readonly scripts: Record; +}; + +type HookCommand = { + readonly command: string; +}; + +type HookEntry = { + readonly hooks: readonly HookCommand[]; +}; + +type HooksJson = { + readonly hooks: Record; +}; + +type McpServer = { + readonly command: string; + readonly args: readonly string[]; +}; + +type McpJson = { + readonly mcpServers: Record; +}; + +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 { + return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}