Created
January 3, 2026 06:02
-
-
Save offlinehacker/821ec8062fcc933d3d8b1984e78a6066 to your computer and use it in GitHub Desktop.
opencode example plugin for lint feedback (not required anymore, we have LSP and formatters now)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { Plugin } from "@opencode-ai/plugin"; | |
| /** | |
| * LintFeedbackPlugin - Automatically runs linters after file edits and provides feedback to the LLM. | |
| * | |
| * Supported file types: | |
| * - TypeScript/JavaScript (.ts, .tsx, .js, .jsx) - runs ESLint and TypeScript type checking | |
| * - Go (.go) - runs golangci-lint | |
| * | |
| * The plugin automatically finds the appropriate config files by traversing up the directory tree. | |
| */ | |
| export const LintFeedbackPlugin: Plugin = async ({ $, directory, client }) => { | |
| // Helper to log messages to OpenCode's server logs | |
| const log = (level: "debug" | "info" | "warn" | "error", message: string) => { | |
| client.app.log({ | |
| body: { | |
| service: "lint-feedback", | |
| level, | |
| message, | |
| }, | |
| }); | |
| }; | |
| /** | |
| * Find a config file by traversing up the directory tree from the given file path. | |
| * Returns the directory containing the config file, or null if not found. | |
| */ | |
| async function findConfigDir( | |
| filePath: string, | |
| configNames: string[] | |
| ): Promise<string | null> { | |
| // Start from the file's directory | |
| let currentDir = filePath.substring(0, filePath.lastIndexOf("/")); | |
| while (currentDir.startsWith(directory)) { | |
| for (const configName of configNames) { | |
| try { | |
| await $`test -f ${currentDir}/${configName}`.quiet(); | |
| return currentDir; | |
| } catch { | |
| // Config not found in this directory, continue | |
| } | |
| } | |
| // Move up one directory | |
| const parentDir = currentDir.substring(0, currentDir.lastIndexOf("/")); | |
| if (parentDir === currentDir || !parentDir) { | |
| break; | |
| } | |
| currentDir = parentDir; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Find the appropriate tsconfig for a file. | |
| * For web/src files, use tsconfig.app.json | |
| * For vite.config.ts and .storybook files, use tsconfig.node.json | |
| * Otherwise, use tsconfig.json | |
| */ | |
| function getTsConfigForFile(filePath: string, configDir: string): string { | |
| const relativePath = filePath.replace(configDir + "/", ""); | |
| // Check if file is in src/ directory | |
| if (relativePath.startsWith("src/")) { | |
| return "tsconfig.app.json"; | |
| } | |
| // Check if file is vite.config.ts or in .storybook/ | |
| if (relativePath === "vite.config.ts" || relativePath.startsWith(".storybook/")) { | |
| return "tsconfig.node.json"; | |
| } | |
| // Default to tsconfig.json | |
| return "tsconfig.json"; | |
| } | |
| /** | |
| * Run TypeScript type checking on a file | |
| */ | |
| async function runTypeScript(filePath: string): Promise<string | null> { | |
| const configDir = await findConfigDir(filePath, [ | |
| "tsconfig.json", | |
| "tsconfig.app.json", | |
| ]); | |
| if (!configDir) { | |
| return null; | |
| } | |
| const tsConfig = getTsConfigForFile(filePath, configDir); | |
| try { | |
| // Run tsc with --noEmit to only check types, filter output to relevant file | |
| const result = | |
| await $`cd ${configDir} && npx tsc --noEmit --pretty false -p ${tsConfig} 2>&1 | grep -E "^${filePath.replace(configDir + "/", "")}\\(" || true`.quiet(); | |
| const output = result.stdout.toString().trim(); | |
| return output || null; | |
| } catch (error: unknown) { | |
| const execError = error as { stdout?: Buffer; stderr?: Buffer }; | |
| const stdout = execError.stdout?.toString().trim() || ""; | |
| const stderr = execError.stderr?.toString().trim() || ""; | |
| // Filter output to only show errors for the edited file | |
| const output = (stdout || stderr) | |
| .split("\n") | |
| .filter((line) => line.includes(filePath.replace(configDir + "/", ""))) | |
| .join("\n") | |
| .trim(); | |
| return output || null; | |
| } | |
| } | |
| /** | |
| * Run ESLint on a TypeScript/JavaScript file | |
| */ | |
| async function runESLint(filePath: string): Promise<string | null> { | |
| const configDir = await findConfigDir(filePath, [ | |
| "eslint.config.js", | |
| "eslint.config.mjs", | |
| "eslint.config.cjs", | |
| ".eslintrc.js", | |
| ".eslintrc.json", | |
| ".eslintrc", | |
| ]); | |
| if (!configDir) { | |
| return null; | |
| } | |
| try { | |
| const result = | |
| await $`cd ${configDir} && npx eslint ${filePath} --format stylish 2>&1`.quiet(); | |
| return result.stdout.toString().trim() || null; | |
| } catch (error: unknown) { | |
| // ESLint exits with non-zero code when there are errors | |
| const execError = error as { stdout?: Buffer; stderr?: Buffer }; | |
| const stdout = execError.stdout?.toString().trim() || ""; | |
| const stderr = execError.stderr?.toString().trim() || ""; | |
| return stdout || stderr || "ESLint failed with unknown error"; | |
| } | |
| } | |
| /** | |
| * Run golangci-lint on a Go file | |
| */ | |
| async function runGolangciLint(filePath: string): Promise<string | null> { | |
| const configDir = await findConfigDir(filePath, [".golangci.yml", ".golangci.yaml"]); | |
| // Use the config directory if found, otherwise use the project root | |
| const workDir = configDir || directory; | |
| // Get the package directory (directory containing the file) | |
| const packageDir = filePath.substring(0, filePath.lastIndexOf("/")); | |
| // Get the relative path from workDir to packageDir | |
| const relativePackageDir = packageDir.replace(workDir + "/", "./"); | |
| // Get the filename for filtering output | |
| const fileName = filePath.substring(filePath.lastIndexOf("/") + 1); | |
| /** | |
| * Filter function to remove noise from golangci-lint output: | |
| * - level=warning/error messages about config | |
| * Only keep lines that reference the edited file | |
| */ | |
| const filterOutput = (line: string): boolean => { | |
| return ( | |
| line.includes(fileName) && | |
| !line.includes("level=warning") && | |
| !line.includes("level=error") | |
| ); | |
| }; | |
| try { | |
| // Run on the package directory for proper type checking context | |
| const result = | |
| await $`cd ${workDir} && golangci-lint run ${relativePackageDir} --output.text.colors=false 2>&1`.quiet(); | |
| const output = result.stdout.toString().trim(); | |
| // Filter to only show relevant issues from the edited file | |
| if (output) { | |
| const filteredLines = output.split("\n").filter(filterOutput); | |
| return filteredLines.length > 0 ? filteredLines.join("\n") : null; | |
| } | |
| return null; | |
| } catch (error: unknown) { | |
| // golangci-lint exits with non-zero code when there are errors | |
| const execError = error as { stdout?: Buffer; stderr?: Buffer }; | |
| const stdout = execError.stdout?.toString().trim() || ""; | |
| const stderr = execError.stderr?.toString().trim() || ""; | |
| const output = (stdout || stderr).split("\n").filter(filterOutput).join("\n").trim(); | |
| return output || null; | |
| } | |
| } | |
| /** | |
| * Determine the file type and run the appropriate linters | |
| */ | |
| async function lintFile( | |
| filePath: string | |
| ): Promise<Array<{ linter: string; output: string }>> { | |
| const tsExtensions = [".ts", ".tsx", ".js", ".jsx"]; | |
| const goExtensions = [".go"]; | |
| const results: Array<{ linter: string; output: string }> = []; | |
| // Check if it's a TypeScript/JavaScript file | |
| if (tsExtensions.some((ext) => filePath.endsWith(ext))) { | |
| // Run TypeScript and ESLint in parallel | |
| const [tsOutput, eslintOutput] = await Promise.all([ | |
| runTypeScript(filePath), | |
| runESLint(filePath), | |
| ]); | |
| if (tsOutput) { | |
| results.push({ linter: "TypeScript", output: tsOutput }); | |
| } | |
| if (eslintOutput) { | |
| results.push({ linter: "ESLint", output: eslintOutput }); | |
| } | |
| } | |
| // Check if it's a Go file | |
| if (goExtensions.some((ext) => filePath.endsWith(ext))) { | |
| const output = await runGolangciLint(filePath); | |
| if (output) { | |
| results.push({ linter: "golangci-lint", output }); | |
| } | |
| } | |
| return results; | |
| } | |
| return { | |
| "tool.execute.after": async (input, output) => { | |
| // Only handle edit tool | |
| if (input.tool !== "edit") { | |
| return; | |
| } | |
| // Get file path from metadata.filediff.file | |
| const metadata = output.metadata as { filediff?: { file?: string } } | undefined; | |
| const filePath = metadata?.filediff?.file; | |
| if (!filePath) { | |
| log("debug", "No file path in metadata, skipping lint check"); | |
| return; | |
| } | |
| log("debug", `Running lint check on ${filePath}`); | |
| const results = await lintFile(filePath); | |
| if (results.length > 0) { | |
| log("info", `Found ${results.length} lint issue(s) in ${filePath}`); | |
| // Build feedback message with all linter results | |
| const feedbackSections = results | |
| .map( | |
| (result) => `### ${result.linter} | |
| \`\`\` | |
| ${result.output} | |
| \`\`\`` | |
| ) | |
| .join("\n\n"); | |
| // Append lint feedback to the tool output so LLM sees it | |
| output.output += `\n\n## Lint Feedback\n\nThe following issues were found after your edit:\n\n${feedbackSections}\n\nPlease fix these issues.`; | |
| } else { | |
| log("debug", `No lint issues found in ${filePath}`); | |
| } | |
| }, | |
| }; | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment