Catches slop as Claude writes it: refuses a file edit that replaces code with a '... rest of the code' placeholder, and tracks TODO stubs, not-implemented…

Catches slop as Claude writes it.
⚠ slop 7 3 findings · latest: todo-stub in src/audit.ts:1 r: Review h: Hide
| Hook | What it does |
|---|---|
tool.call on Write and Edit | Scans the text about to land in the file. A truncation marker (// ... rest of the code, # ... existing code ...) is refused: letting it through would replace real code with a comment, and the deny text tells Claude to write the whole file. Softer leftovers are recorded and toasted. |
turn.complete | Adds a line under the answer: slop-detector: 2 findings this turn · /slop to review. |
ui.render on AbovePrompt | The session's slop score, the latest finding, and buttons to review or hide. |
ui.render on Pane | /slop opens a pane listing every finding, file by file, with Clear and Close. |
| Kind | Weight | Example |
|---|---|---|
truncation (refused) | 10 | // ... rest of the code unchanged, # ... existing code ..., /* previous implementation stays */ |
not-implemented | 4 | throw new Error('not implemented'), NotImplementedError, todo!() |
todo-stub | 3 | // TODO: implement this later |
placeholder | 3 | // your code here, pass # TODO |
lorem | 2 | lorem ipsum |
debug-print | 2 | console.log('here'), debugger, breakpoint() |
ts-ignore | 2 | @ts-ignore, eslint-disable, # type: ignore |
todo | 1 | any other TODO or FIXME comment |
any-cast | 1 | as any |
duplicate-lines, long-line | 1 | the same non-trivial line four times; a 400-column line |
| Option | Default | What it does |
|---|---|---|
strict | false | Also refuse edits that add a stub of weight 3 or more, not only truncation markers. |
ignore | .test.,.spec.,.md,/fixtures/,/snapshots/ | Comma-separated path fragments that are never scanned. |
Set options in ~/.claude/settings.json under pluginConfigs → slop-detector@slopshopper, or in /config.
claude plugin marketplace add ryx2/slopshopper
claude plugin install slop-detector@slopshopper
Tested with Claude Code 2.1.289. claude plugin validate and claude plugin test pass.
hooks/register.tsx 199 lines1// slop-detector: catches slop as Claude writes it.
2//
3// tool.call (Write, Edit): scans the new text. A truncation marker such as
4// "// ... rest of the code" is refused outright: letting it through replaces
5// real code with a comment. Softer leftovers (TODO stubs, not-implemented
6// throws, debug prints, @ts-ignore, lorem ipsum) are recorded and toasted.
7// turn.complete: a line under the answer counts the turn's findings.
8// ui.render (AbovePrompt): the session's slop score and the latest finding.
9// ui.render (Pane): /slop lists every finding, file by file.
10
11import { atom, read, update } from 'claude-code'
12import type { Register } from 'claude-code'
13
14import type { Finding } from '../types'
15
16const PANE = 'slop-detector'
17const MAX_FINDINGS = 200
18
19const findings = atom({ plugin: 'slop-detector', key: 'findings' } as const, [])
20const turnCount = atom({ plugin: 'slop-detector', key: 'turnCount' } as const, 0)
21const turnFindings = atom({ plugin: 'slop-detector', key: 'turnFindings' } as const, 0)
22const isHidden = atom({ plugin: 'slop-detector', key: 'isHidden' } as const, false)
23
24type Pattern = { kind: string; weight: number; hard: boolean; test: RegExp }
25
26// Order matters: the first pattern a line matches names it.
27const PATTERNS: Pattern[] = [
28 { kind: 'truncation', weight: 10, hard: true, test: /(\/\/|#|<!--|\/\*|--|;)\s*(\.\.\.|…)\s*(the\s+)?(rest|remaining|remainder|existing|other|previous|unchanged)\b/i },
29 { kind: 'truncation', weight: 10, hard: true, test: /(\.\.\.|…)\s*(rest|remaining|remainder)\s+(of\s+)?(the\s+)?(code|file|function|implementation|class|component|module)/i },
30 { kind: 'truncation', weight: 10, hard: true, test: /(\/\/|#|<!--|\/\*)\s*(existing|previous|original|unchanged)\s+(code|implementation|content|logic)\s*(stays|remains|unchanged|here|\.\.\.|…|-->|\*\/)?\s*$/i },
31 { kind: 'truncation', weight: 10, hard: true, test: /(\/\/|#|\/\*)\s*(\.\.\.|…)\s*(same as before|unchanged|as before|omitted for brevity|etc\.?)\s*(\*\/)?\s*$/i },
32 { kind: 'not-implemented', weight: 4, hard: false, test: /throw new Error\((['"`])not implemented|NotImplementedError|unimplemented!\(\)|todo!\(\)|raise NotImplementedError/i },
33 { kind: 'todo-stub', weight: 3, hard: false, test: /(\/\/|#|\/\*|<!--)\s*(TODO|FIXME|HACK|XXX)\b.*\b(implement|fill in|finish|complete|placeholder|stub|later|real)\b/i },
34 { kind: 'placeholder', weight: 3, hard: false, test: /\b(your (code|logic|implementation) (here|goes here)|insert (code|logic) here|implementation (goes|lives) here|add (your )?(code|logic) here|placeholder (code|value|implementation))\b/i },
35 { kind: 'placeholder', weight: 2, hard: false, test: /^\s*(pass|return (null|None|undefined|\{\}|\[\]|0|""|''))\s*(#|\/\/)\s*(TODO|FIXME|stub|placeholder|implement)/i },
36 { kind: 'lorem', weight: 2, hard: false, test: /\blorem ipsum\b|\bdolor sit amet\b/i },
37 { kind: 'debug-print', weight: 2, hard: false, test: /^\s*(console\.log\((['"`])(here|debug|test|xxx|asdf|got here|hit|works?)\b|print\((['"])(here|debug|test|xxx|asdf)\b|debugger;?\s*$|binding\.pry|import pdb|breakpoint\(\))/i },
38 { kind: 'ts-ignore', weight: 2, hard: false, test: /@ts-ignore|@ts-nocheck|eslint-disable(?!-next-line)\b|# type: ignore\s*$|#\s*noqa\s*$/i },
39 { kind: 'todo', weight: 1, hard: false, test: /(\/\/|#|\/\*|<!--)\s*(TODO|FIXME)\b/i },
40 { kind: 'any-cast', weight: 1, hard: false, test: /\bas any\b|:\s*any\b(?!\w)/ },
41]
42
43export const register: Register = (on, options) => {
44 const strict = options.strict === true
45 const ignore = String(options.ignore ?? '')
46 .split(',')
47 .map(s => s.trim())
48 .filter(Boolean)
49
50 on('session.start', async ($, e, next) => {
51 try {
52 await $.command.register({ name: 'slop', description: 'Review the slop that slop-detector found this session' })
53 } catch {
54 // the name is taken by another plugin; the band still works
55 }
56 return next(e)
57 })
58
59 on('command.run', { command: 'slop' }, async $ => {
60 const list = await read($, findings)
61 await $.ui.open({ id: PANE, title: 'Slop', focus: true, closeOnEscape: true })
62 return { text: list.length === 0 ? 'No slop found this session.' : `${list.length} finding${list.length === 1 ? '' : 's'} this session, score ${scoreOf(list)}.` }
63 })
64
65 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
66 const file = String(e.file_path ?? '')
67 if (ignore.some(frag => file.includes(frag))) return next(e)
68 const text = e.tool === 'Write' ? String(e.content ?? '') : String(e.new_string ?? '')
69 const turn = await read($, turnCount)
70 const found = scan(file, text, turn)
71 const blocking = found.filter(f => f.hard || (strict && f.weight >= 3))
72 if (blocking.length > 0) {
73 const first = blocking[0]!
74 return {
75 deny: `slop-detector refused this ${e.tool} to ${shortPath(file)}: line ${first.line} is a ${first.kind} ("${first.snippet.slice(0, 80)}"). ${
76 first.hard ? 'A comment that stands in for code would delete the code it stands for. Write the complete content of the file, including every unchanged line.' : 'Write the real implementation instead of a placeholder.'
77 }`,
78 }
79 }
80 const ran = await next(e)
81 if (ran.deny !== undefined || ran.isError === true || found.length === 0) return ran
82 await update($, findings, list => [...list, ...found].slice(-MAX_FINDINGS))
83 await update($, turnFindings, n => n + found.length)
84 const worst = found.reduce((a, b) => (b.weight > a.weight ? b : a))
85 $.ui.toast(`${found.length} slop finding${found.length === 1 ? '' : 's'} in ${shortPath(file)}: ${worst.kind} on line ${worst.line}`)
86 return ran
87 })
88
89 on('turn.start', async ($, e, next) => {
90 await update($, turnCount, n => n + 1)
91 await update($, turnFindings, () => 0)
92 return next(e)
93 })
94
95 on('turn.complete', async ($, e, next) => {
96 const result = await next(e)
97 if (e.agentId !== undefined) return result
98 const n = await read($, turnFindings)
99 if (n === 0) return result
100 return { ...result, text: `slop-detector: ${n} finding${n === 1 ? '' : 's'} this turn · /slop to review` }
101 })
102
103 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104 const list = await read($, findings)
105 if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) return next(e)
106 const { Box, Text, Button } = $.ui.resolve(e)
107 const last = list[list.length - 1]!
108 const score = scoreOf(list)
109 return (
110 <Box flexDirection="row" columnGap={2} paddingX={1}>
111 <Text color={score >= 15 ? 'red' : score >= 6 ? 'yellow' : 'green'} bold>
112 ⚠ slop {score}
113 </Text>
114 <Text dimColor>
115 {list.length} finding{list.length === 1 ? '' : 's'} · latest: {last.kind} in {shortPath(last.file)}:{last.line}
116 </Text>
117 <Button key="review" label="Review" hotkey="r" plain onPress={() => $.ui.open({ id: PANE, title: 'Slop', focus: true, closeOnEscape: true })} />
118 <Button key="hide" label="Hide" hotkey="h" plain dimColor onPress={() => update($, isHidden, () => true)} />
119 </Box>
120 )
121 })
122
123 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
124 const { Box, Text, Button } = $.ui.resolve(e)
125 const list = await read($, findings)
126 const width = e.props.bodyColumns
127 const byFile = new Map<string, Finding[]>()
128 for (const f of list) byFile.set(f.file, [...(byFile.get(f.file) ?? []), f])
129 const rows = [...byFile.entries()].slice(-12)
130 return (
131 <Box flexDirection="column">
132 <Box flexDirection="row" columnGap={2}>
133 <Text bold color="yellow">
134 ⚠ slop-detector
135 </Text>
136 <Text dimColor>
137 {list.length} finding{list.length === 1 ? '' : 's'} · score {scoreOf(list)}
138 </Text>
139 </Box>
140 {list.length === 0 && <Text dimColor>Nothing yet. Findings appear as Claude writes files.</Text>}
141 {rows.map(([file, items]) => (
142 <Box flexDirection="column" marginTop={1}>
143 <Text bold>{shortPath(file)}</Text>
144 {items.slice(-6).map(f => (
145 <Box flexDirection="row" columnGap={1}>
146 <Text color={f.hard ? 'red' : f.weight >= 3 ? 'yellow' : undefined}>{String(f.line).padStart(4)}</Text>
147 <Text color="cyan">{f.kind}</Text>
148 <Text dimColor wrap="truncate-end">
149 {f.snippet.slice(0, Math.max(10, width - 20))}
150 </Text>
151 </Box>
152 ))}
153 </Box>
154 ))}
155 <Box flexDirection="row" columnGap={2} marginTop={1}>
156 <Button key="clear" label="Clear" hotkey="c" onPress={() => update($, findings, () => [])} />
157 <Button key="close" label="Close" hotkey="q" onPress={() => $.ui.close({ id: PANE })} />
158 </Box>
159 </Box>
160 )
161 })
162}
163
164/** Every slop finding in `text`, which is about to land in `file`. */
165export function scan(file: string, text: string, turn: number): Finding[] {
166 const out: Finding[] = []
167 const lines = text.split('\n')
168 const seen = new Map<string, number>()
169 for (let i = 0; i < lines.length && out.length < 20; i++) {
170 const raw = lines[i]!
171 const line = raw.trim()
172 if (line.length === 0) continue
173 const p = PATTERNS.find(x => x.test.test(raw))
174 if (p) {
175 out.push({ file, line: i + 1, kind: p.kind, snippet: line, weight: p.weight, hard: p.hard, turn })
176 continue
177 }
178 if (raw.length > 400 && !/^\s*(import|export|["'`]|\/\/|#|\*|data:)/.test(raw) && !/[,;]\s*$/.test(raw.slice(0, 120)) && !file.endsWith('.json')) {
179 out.push({ file, line: i + 1, kind: 'long-line', snippet: line.slice(0, 60) + '…', weight: 1, hard: false, turn })
180 continue
181 }
182 if (line.length > 24 && !/^[}\])]+[;,]?$/.test(line) && !/^(import|export|return|break|continue|else|try|catch|finally|\*|\/\/|#)\b/.test(line)) {
183 const n = (seen.get(line) ?? 0) + 1
184 seen.set(line, n)
185 if (n === 4) out.push({ file, line: i + 1, kind: 'duplicate-lines', snippet: line, weight: 1, hard: false, turn })
186 }
187 }
188 return out
189}
190
191export function scoreOf(list: readonly Finding[]): number {
192 return list.reduce((n, f) => n + f.weight, 0)
193}
194
195export function shortPath(file: string): string {
196 const parts = file.split('/').filter(Boolean)
197 return parts.length <= 2 ? parts.join('/') : parts.slice(-2).join('/')
198}
199tests/register.test.ts 87 lines1import { describe, expect, test } from 'claude-code/testing'
2
3const BAND = {
4 plugin: 'slop-detector',
5 component: 'AbovePrompt',
6 requestId: 'band',
7 viewport: { columns: 120, rows: 40 },
8 props: { hasSurvey: false, isWorking: false, maxRows: 8, bodyColumns: 100, scroll: { offset: 0, bodyRows: 8 }, view: {} },
9} as const
10
11const PANE = {
12 plugin: 'slop-detector',
13 component: 'Pane',
14 requestId: 'slop-detector',
15 viewport: { columns: 120, rows: 40 },
16 props: { title: 'Slop', isFocused: true, bodyColumns: 60, placement: 'dock', scroll: { offset: 0, bodyRows: 20 }, view: {} },
17} as const
18
19describe('register', () => {
20 test('refuses a Write that stands in for code with a truncation comment', async ($, on) => {
21 on('tool.call', () => ({ result: 'ok' }))
22 const out = await $.tool.call({ tool: 'Write', file_path: '/work/app/src/a.ts', content: 'export const a = 1\n// ... rest of the code unchanged\n' })
23 expect(out.deny).toContain('truncation')
24 expect(out.deny).toContain('src/a.ts')
25 })
26
27 test('refuses an Edit whose new text is "... existing code ..."', async ($, on) => {
28 on('tool.call', () => ({ result: 'ok' }))
29 const out = await $.tool.call({ tool: 'Edit', file_path: '/work/app/src/b.py', old_string: 'x = 1', new_string: '# ... existing code ...\nx = 2' })
30 expect(out.deny).toBeDefined()
31 })
32
33 test('lets clean code through and ignores test files', async ($, on) => {
34 on('tool.call', () => ({ result: 'ok' }))
35 const clean = await $.tool.call({ tool: 'Write', file_path: '/work/app/src/c.ts', content: 'export const c = () => 3\n' })
36 expect(clean.deny).toBeUndefined()
37 const test = await $.tool.call({ tool: 'Write', file_path: '/work/app/src/c.test.ts', content: '// TODO: implement\n// ... rest of the code\n' })
38 expect(test.deny).toBeUndefined()
39 })
40
41 test('records a TODO stub, toasts, draws the band, and counts it under the answer', async ($, on) => {
42 on('tool.call', () => ({ result: 'ok' }))
43 const toasts: string[] = []
44 on('ui.toast', ($, e) => {
45 toasts.push(e.text)
46 return { value: undefined }
47 })
48 on('turn.start', ($, e) => ({ turnId: e.turnId }))
49 on('turn.complete', () => ({ text: '' }))
50 on('ui.render', () => ({ type: 'Text', props: {}, children: ['engine'] }))
51
52 await $.turn.start({ turnId: 't1' })
53 await $.tool.call({ tool: 'Write', file_path: '/work/app/src/audit.ts', content: "// TODO: implement persistence later\nexport const audit = () => {}\nconsole.log('here')\n" })
54 expect(toasts.length).toBe(1)
55 expect(toasts[0]).toContain('src/audit.ts')
56
57 for (const surface of ['terminal', 'desktop'] as const) {
58 const ui = await $.ui.mount({ ...BAND, surface })
59 expect(await ui.find({ type: 'Text', text: /2 findings/ })).toBeDefined()
60 await ui.unmount()
61 }
62
63 const done = await $.turn.complete({ turnId: 't1', answer: 'done', durationMs: 1000, isAborted: false, usage: null })
64 expect(done.text).toContain('2 findings this turn')
65 })
66
67 test('/slop opens the pane and the pane lists findings with a Clear button', async ($, on) => {
68 on('tool.call', () => ({ result: 'ok' }))
69 on('ui.toast', () => ({ value: undefined }))
70 const opened: string[] = []
71 on('ui.open', ($, e) => {
72 opened.push(e.id)
73 return { value: { isPlaced: true } }
74 })
75 await $.tool.call({ tool: 'Write', file_path: '/work/app/src/d.ts', content: 'throw new Error("not implemented")\n' })
76 const answer = await $.command.run({ command: 'slop', args: '' })
77 expect(answer.text).toContain('1 finding')
78 expect(opened).toEqual(['slop-detector'])
79
80 const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
81 expect(await ui.find({ type: 'Text', text: 'not-implemented' })).toBeDefined()
82 await ui.press({ key: 'clear' })
83 expect(await ui.find({ type: 'Text', text: /Nothing yet/ })).toBeDefined()
84 await ui.unmount()
85 })
86})
87types/index.d.ts 28 lines1export type Finding = {
2 /** The file the slop landed in. */
3 file: string
4 /** 1-based line within the written text. */
5 line: number
6 /** Short name of the pattern: truncation, todo-stub, not-implemented, debug-print, ts-ignore, lorem, placeholder, duplicate-lines, long-line. */
7 kind: string
8 /** The offending line, trimmed. */
9 snippet: string
10 /** How much it counts toward the session's slop score. */
11 weight: number
12 /** True for a finding that destroys code if the write goes through. */
13 hard: boolean
14 /** The turn it was found in. */
15 turn: number
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'slop-detector': {
21 findings: Finding[]
22 turnCount: number
23 turnFindings: number
24 isHidden: boolean
25 }
26 }
27}
28README.md 48 lines1# slop-detector
2
3Catches slop as Claude writes it.
4
5```text
6 ⚠ slop 7 3 findings · latest: todo-stub in src/audit.ts:1 r: Review h: Hide
7```
8
9| Hook | What it does |
10| --- | --- |
11| `tool.call` on `Write` and `Edit` | Scans the text about to land in the file. A truncation marker (`// ... rest of the code`, `# ... existing code ...`) is **refused**: letting it through would replace real code with a comment, and the deny text tells Claude to write the whole file. Softer leftovers are recorded and toasted. |
12| `turn.complete` | Adds a line under the answer: `slop-detector: 2 findings this turn · /slop to review`. |
13| `ui.render` on `AbovePrompt` | The session's slop score, the latest finding, and buttons to review or hide. |
14| `ui.render` on `Pane` | `/slop` opens a pane listing every finding, file by file, with Clear and Close. |
15
16## What counts as slop
17
18| Kind | Weight | Example |
19| --- | --- | --- |
20| `truncation` (refused) | 10 | `// ... rest of the code unchanged`, `# ... existing code ...`, `/* previous implementation stays */` |
21| `not-implemented` | 4 | `throw new Error('not implemented')`, `NotImplementedError`, `todo!()` |
22| `todo-stub` | 3 | `// TODO: implement this later` |
23| `placeholder` | 3 | `// your code here`, `pass # TODO` |
24| `lorem` | 2 | `lorem ipsum` |
25| `debug-print` | 2 | `console.log('here')`, `debugger`, `breakpoint()` |
26| `ts-ignore` | 2 | `@ts-ignore`, `eslint-disable`, `# type: ignore` |
27| `todo` | 1 | any other `TODO` or `FIXME` comment |
28| `any-cast` | 1 | `as any` |
29| `duplicate-lines`, `long-line` | 1 | the same non-trivial line four times; a 400-column line |
30
31## Options
32
33| Option | Default | What it does |
34| --- | --- | --- |
35| `strict` | `false` | Also refuse edits that add a stub of weight 3 or more, not only truncation markers. |
36| `ignore` | `.test.,.spec.,.md,/fixtures/,/snapshots/` | Comma-separated path fragments that are never scanned. |
37
38Set options in `~/.claude/settings.json` under `pluginConfigs` → `slop-detector@slopshopper`, or in `/config`.
39
40## Install
41
42```bash
43claude plugin marketplace add ryx2/slopshopper
44claude plugin install slop-detector@slopshopper
45```
46
47Tested with Claude Code 2.1.289. `claude plugin validate` and `claude plugin test` pass.
48