SLOPSHOPPER

lint

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

newrowsguardprocess
v0.1.3MITupdated 2026-10-03Conte777/claude-lint/plugins/lint
A shopper browsing a rack in a slop shop
README

claude-lint

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.

What it does

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:

LanguageFormatterLinter
Gogofmt -wgolangci-lint run --new-from-rev=HEAD over the file's package, or go vet outside a module
Pythonruff formatruff check
Javagoogle-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.

Requirements

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.

Install

claude plugin marketplace add Conte777/claude-lint
claude plugin install lint@claude-lint

Development

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.json
Source 1 files
hooks/index.tsx 360 lines
1import 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