Skip to content

Instantly share code, notes, and snippets.

@offlinehacker
Created January 3, 2026 06:02
Show Gist options
  • Select an option

  • Save offlinehacker/821ec8062fcc933d3d8b1984e78a6066 to your computer and use it in GitHub Desktop.

Select an option

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)
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