Formats and lints every file Claude Code edits, reported as one collapsible line.

A Claude Code plugin that formats and lints every file Claude edits, and reports what it found in one line instead of a wall of text.
After each Edit and Write the plugin runs the tools of the edited file's language, synchronously, so the model learns about a problem before it makes the next change:
| Language | Formatter | Linter |
|---|---|---|
| Go | gofmt -w | golangci-lint run --new-from-rev=HEAD over the file's package, or go vet outside a module |
| Python | ruff format | ruff check |
| Java | google-java-format -i | — |
Every language listed in CODE_EXTENSIONS additionally gets a check on the comments the session added: comments that restate the code, section-divider banners, and blocks of five or more consecutive comment lines. A comment that gives a reason — because, so that, to avoid, or anything after a colon — is left alone.
A tool that is not on PATH is skipped, and its language with it.
Every finding reads <path>:<line>:<column>: <what>, the path relative to the session's working directory, whichever tool found it.
When nothing is wrong, nothing is said — not to the person, not to the model. Reformatting a file is silent too. When there are findings, the model receives the full text as tool-result context. A folded run of calls gets one line under it with the count:
2 lint issues in internal/api/service.go (ctrl+o to expand)
In the ctrl+o transcript each call's result row shows that count and the findings themselves, in place of what the tool would otherwise report.
Claude Code with function hooks enabled:
// ~/.claude/settings.json
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
The API is early access and may change between releases.
claude plugin marketplace add Conte777/claude-lint
claude plugin install lint@claude-lint
claude --plugin-dir plugins/lint # load from disk, reloaded on save
claude plugin validate plugins/lint # what the module hooks and calls
The plugin is typed against the declarations of the Claude Code build it runs on, which Claude Code writes into .claude-plugin/types/ each time it loads the plugin with --plugin-dir. Regenerate them after an update:
cd plugins/lint && claude -p "/cost" --plugin-dir . && tsc -p tsconfig.jsonhooks/index.tsx 360 lines1import type { EngineInterface, Register } from "claude-code"
2
3type Issue = { line: number; column: number; text: string }
4
5type Report = { count: number; name: string; detail: string }
6
7type RunResult = { exitCode: number; stdout: string; stderr: string }
8
9type RunInit = { cwd?: string; timeoutMs?: number }
10
11type AddedLine = { line: number; text: string }
12
13const CODE_EXTENSIONS = new Set([
14 ".go", ".py", ".java", ".kt", ".scala", ".rs", ".rb", ".php", ".swift",
15 ".sh", ".bash", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx",
16 ".c", ".h", ".cc", ".cpp", ".hpp", ".cxx",
17])
18
19const REPORT_LIMIT = 64
20
21const TEXT_LIMIT = 10000
22
23const POSITION = /^(.+?):(\d+):(\d+):\s(.*)$/
24
25const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
26
27const KEEP = /^\s*(#!|\/\/\s*(go:|nolint|lint:|@ts-|eslint|prettier|biome)|#\s*(noqa|type:|pylint|mypy|ruff:|fmt:)|#region|(\/\/|#|--|\*)\s*(TODO|FIXME|HACK|XXX|SAFETY|NOTE:))/
28
29const NARRATION = /^\s*(\/\/+|#+|\*|--)\s*((Step\s+\d)|((Now|Then|First|Next|Finally)[\s,]+(we|create|check|set|get|call|do)\b)|(This\s+(function|method|class|file|module|struct|type|interface|variable|constant|field|block)\b)|((Loop|Loops|Iterate|Iterates)\s+(through|over)\b)|((Sets?|Gets?|Returns?|Creates?|Initializes?|Checks?|Handles?|Updates?|Adds?|Removes?|Calls?|Defines?|Declares?|Parses?|Converts?|Stores?)\s+(the|a|an)\s))/i
30
31const REASON = /\b(because|since|so\s+that|otherwise|instead|to\s+(avoid|keep|make|stop)|workaround|upstream|deliberately|on\s+purpose|must|cannot|would|panics?|races?|deadlocks?|leaks?)\b|:\s+\S/i
32
33const BANNER = /^\s*(\/\/+|#+|\*|--)\s*[-=*_#~]{4,}/
34
35const COMMENT_LINE = /^\s*(\/\/|#|\*|--)/
36
37const dirName = (path: string): string => {
38 const cut = path.lastIndexOf("/")
39 return cut <= 0 ? "/" : path.slice(0, cut)
40}
41
42const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
43
44const extensionOf = (path: string): string => {
45 const name = baseName(path)
46 const dot = name.lastIndexOf(".")
47 return dot < 0 ? "" : name.slice(dot)
48}
49
50const under = (path: string, root: string): string | null =>
51 path === root ? "" : path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null
52
53const run = async (
54 $: EngineInterface,
55 argv: readonly string[],
56 init?: RunInit,
57): Promise<RunResult | null> => {
58 try {
59 return await $.process.run(argv, init)
60 } catch {
61 return null
62 }
63}
64
65const findUp = async ($: EngineInterface, from: string, marker: string): Promise<string | null> => {
66 let dir = from
67 while (dir !== "/" && dir !== "") {
68 if (await $.fs.exists(`${dir}/${marker}`)) return dir
69 dir = dirName(dir)
70 }
71 return null
72}
73
74const parsePosition = (line: string): Issue | null => {
75 const match = POSITION.exec(line)
76 if (match === null) return null
77 return { line: Number(match[2]), column: Number(match[3]), text: match[4] ?? "" }
78}
79
80const positionsIn = (text: string): Issue[] =>
81 text
82 .split("\n")
83 .map((line) => parsePosition(line.trim()))
84 .filter((issue): issue is Issue => issue !== null)
85
86const goIssues = async ($: EngineInterface, filePath: string): Promise<Issue[]> => {
87 const listed = await run($, ["gofmt", "-l", filePath], { timeoutMs: 15000 })
88 if (listed && listed.exitCode === 0 && listed.stdout.trim() !== "") {
89 await run($, ["gofmt", "-w", filePath], { timeoutMs: 15000 })
90 }
91
92 const dir = dirName(filePath)
93 const moduleRoot = await findUp($, dir, "go.mod")
94
95 if (moduleRoot === null) {
96 const vet = await run($, ["go", "vet", filePath], { timeoutMs: 40000 })
97 if (vet === null || vet.exitCode === 0) return []
98 return positionsIn(`${vet.stderr}\n${vet.stdout}`)
99 }
100
101 const relative = under(dir, moduleRoot) ?? ""
102 const target = relative === "" ? "./" : `./${relative}/`
103 const linted = await run(
104 $,
105 [
106 "golangci-lint", "run",
107 "--new-from-rev=HEAD",
108 "--timeout=25s",
109 "--output.json.path", "stdout",
110 target,
111 ],
112 { cwd: moduleRoot, timeoutMs: 60000 },
113 )
114 if (linted === null) return []
115
116 const head = linted.stdout.split("\n", 1)[0] ?? ""
117 let issues: unknown = []
118 try {
119 issues = (JSON.parse(head) as { Issues?: unknown }).Issues
120 } catch {
121 return []
122 }
123 if (!Array.isArray(issues)) return []
124
125 return issues.map((raw) => {
126 const issue = raw as { Text?: string; FromLinter?: string; Pos?: { Line?: number; Column?: number } }
127 const parts = (issue.Text ?? "").split("\n").map((part) => part.trim()).filter((part) => part !== "")
128 const stated = (parts[parts.length - 1] ?? "").replace(/^:\s*/, "")
129 const inner = parsePosition(stated)
130 return {
131 line: inner?.line ?? issue.Pos?.Line ?? 0,
132 column: inner?.column ?? issue.Pos?.Column ?? 0,
133 text: `${inner?.text ?? stated} (${issue.FromLinter ?? "golangci-lint"})`,
134 }
135 })
136}
137
138let ruffArgv: readonly string[] | null | undefined
139
140const resolveRuff = async ($: EngineInterface): Promise<readonly string[] | null> => {
141 if (ruffArgv !== undefined) return ruffArgv
142 for (const candidate of [["ruff"], ["uvx", "ruff"]]) {
143 const probe = await run($, [...candidate, "--version"], { timeoutMs: 30000 })
144 if (probe !== null && probe.exitCode === 0) {
145 ruffArgv = candidate
146 return ruffArgv
147 }
148 }
149 ruffArgv = null
150 return null
151}
152
153const pythonIssues = async ($: EngineInterface, filePath: string): Promise<Issue[]> => {
154 const argv = await resolveRuff($)
155 if (argv === null) return []
156
157 await run($, [...argv, "format", filePath], { timeoutMs: 40000 })
158 const checked = await run($, [...argv, "check", "--output-format", "concise", filePath], { timeoutMs: 40000 })
159 if (checked === null) return []
160
161 return positionsIn(checked.stdout)
162}
163
164const formatJava = async ($: EngineInterface, filePath: string): Promise<void> => {
165 await run($, ["google-java-format", "-i", filePath], { timeoutMs: 40000 })
166}
167
168const addedLines = async ($: EngineInterface, filePath: string): Promise<AddedLine[]> => {
169 const dir = dirName(filePath)
170 const root = await run($, ["git", "rev-parse", "--show-toplevel"], { cwd: dir, timeoutMs: 10000 })
171
172 if (root !== null && root.exitCode === 0) {
173 const repo = root.stdout.trim()
174 const tracked = await run($, ["git", "-C", repo, "ls-files", "--error-unmatch", filePath], { cwd: dir, timeoutMs: 10000 })
175 if (tracked !== null && tracked.exitCode === 0) {
176 const diff = await run($, ["git", "-C", repo, "diff", "-U0", "HEAD", "--", filePath], { cwd: dir, timeoutMs: 20000 })
177 if (diff === null) return []
178
179 const added: AddedLine[] = []
180 let line = 0
181 for (const row of diff.stdout.split("\n")) {
182 const hunk = HUNK.exec(row)
183 if (hunk !== null) {
184 line = Number(hunk[1])
185 continue
186 }
187 if (row.startsWith("+++")) continue
188 if (row.startsWith("+")) {
189 added.push({ line, text: row.slice(1) })
190 line += 1
191 }
192 }
193 return added
194 }
195 }
196
197 const text = await $.fs.read(filePath).catch(() => "")
198 return text.split("\n").map((row, index) => ({ line: index + 1, text: row }))
199}
200
201const commentIssues = async ($: EngineInterface, filePath: string): Promise<Issue[]> => {
202 const candidates = (await addedLines($, filePath)).filter((one) => !KEEP.test(one.text))
203 if (candidates.length === 0) return []
204
205 const issues: Issue[] = []
206
207 const narrated = candidates.filter((one) => NARRATION.test(one.text) && !REASON.test(one.text))
208 for (const one of narrated.slice(0, 8)) {
209 issues.push({
210 line: one.line,
211 column: 1,
212 text: `comment restates the code — delete it or replace it with the reason: ${one.text.trim()}`,
213 })
214 }
215
216 for (const one of candidates.filter((one) => BANNER.test(one.text)).slice(0, 4)) {
217 issues.push({ line: one.line, column: 1, text: `section-divider banner — remove it: ${one.text.trim()}` })
218 }
219
220 let streak = 0
221 let opened = 0
222 let previous = 0
223 for (const one of candidates) {
224 if (COMMENT_LINE.test(one.text) && (streak === 0 || one.line === previous + 1)) {
225 if (streak === 0) opened = one.line
226 streak += 1
227 previous = one.line
228 if (streak >= 5) {
229 issues.push({
230 line: opened,
231 column: 1,
232 text: "a comment block of 5+ added lines — keep it to one line unless the file already uses long blocks",
233 })
234 break
235 }
236 continue
237 }
238 streak = 0
239 }
240
241 return issues.sort((one, other) => one.line - other.line || one.column - other.column)
242}
243
244const runChecks = async (
245 $: EngineInterface,
246 filePath: string,
247 label: string,
248 name: string,
249): Promise<Report | null> => {
250 const extension = extensionOf(filePath)
251 const issues: Issue[] = []
252
253 if (extension === ".go") issues.push(...(await goIssues($, filePath)))
254 else if (extension === ".py") issues.push(...(await pythonIssues($, filePath)))
255 else if (extension === ".java") await formatJava($, filePath)
256
257 const comments = CODE_EXTENSIONS.has(extension) ? await commentIssues($, filePath) : []
258 issues.push(...comments)
259
260 if (issues.length === 0) return null
261
262 const stated = issues
263 .sort((one, other) => one.line - other.line || one.column - other.column)
264 .map((issue) => (issue.line === 0 ? `${label}: ${issue.text}` : `${label}:${issue.line}:${issue.column}: ${issue.text}`))
265 .join("\n")
266 const detail = comments.length === 0 ? stated : `${stated}\n\nComments must state why, not what.`
267
268 return { count: issues.length, name, detail }
269}
270
271const reports = new Map<string, Report>()
272
273const summary = (count: number, where: string): string =>
274 `${count} lint issue${count === 1 ? "" : "s"} in ${where}`
275
276const remember = (toolUseId: string, report: Report): void => {
277 reports.set(toolUseId, report)
278 while (reports.size > REPORT_LIMIT) {
279 const oldest = reports.keys().next().value
280 if (oldest === undefined) break
281 reports.delete(oldest)
282 }
283}
284
285const onFileTool = async (
286 $: EngineInterface,
287 toolUseId: string,
288 filePath: string,
289): Promise<string | null> => {
290 const cwd = await $.session.cwd().catch(() => "")
291 const relative = cwd === "" ? null : under(filePath, cwd)
292 const label = relative ?? filePath
293 const name = relative ?? baseName(filePath)
294
295 const report = await runChecks($, filePath, label, name)
296 if (report === null) return null
297
298 remember(toolUseId, report)
299 return report.detail
300}
301
302export const register: Register = (on) => {
303 on("tool.call", { tool: "Edit" }, async ($, e, next) => {
304 const result = await next(e)
305 if ("deny" in result || result.isError === true) return result
306
307 const detail = await onFileTool($, e.tool_use_id, e.file_path)
308 if (detail === null) return result
309
310 return { ...result, context: [...(result.context ?? []), detail] }
311 })
312
313 on("tool.call", { tool: "Write" }, async ($, e, next) => {
314 const result = await next(e)
315 if ("deny" in result || result.isError === true) return result
316
317 const detail = await onFileTool($, e.tool_use_id, e.file_path)
318 if (detail === null) return result
319
320 return { ...result, context: [...(result.context ?? []), detail] }
321 })
322
323 on("ui.render", { component: "ToolResult" }, async ($, e, next) => {
324 const report = reports.get(e.props.tool_use_id)
325 if (report === undefined) return next(e)
326
327 const { Box, Text } = $.ui.resolve(e)
328 return (
329 <Box flexDirection="column" marginLeft={2}>
330 <Text dimColor>{`⎿ ${summary(report.count, report.name)}`}</Text>
331 <Box marginLeft={3}>
332 <Text dimColor>{report.detail.slice(0, TEXT_LIMIT)}</Text>
333 </Box>
334 </Box>
335 )
336 })
337
338 on("ui.render", { component: "ToolGroup" }, async ($, e, next) => {
339 const found = e.props.calls
340 .map((call) => (call.tool_use_id === undefined ? undefined : reports.get(call.tool_use_id)))
341 .filter((report): report is Report => report !== undefined)
342 if (found.length === 0) return next(e)
343
344 const count = found.reduce((total, report) => total + report.count, 0)
345 const names = new Set(found.map((report) => report.name))
346 const where = names.size === 1 ? (found[0]?.name ?? "") : `${names.size} files`
347
348 const { Box, Text } = $.ui.resolve(e)
349 const drawn = await next(e)
350 return (
351 <Box flexDirection="column">
352 {drawn}
353 <Box marginLeft={2}>
354 <Text dimColor>{`⎿ ${summary(count, where)} (ctrl+o to expand)`}</Text>
355 </Box>
356 </Box>
357 )
358 })
359}
360