Finds AI slop in Claude's changes (dashes, comment blocks, comments that repeat the code, tests that assert nothing, filler phrases) and tells Claude to remove…

Unslop finds AI slop in the changes Claude makes and tells Claude to remove it. It also keeps Claude's chat replies short and plain, and it shows you what was unslopped.

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install unslop@claude-code-mods
Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.
Slop is what AI writes often, people rarely write, and that is not better than leaving it out. After each Edit, Write, or NotebookEdit, Unslop reads only the lines Claude added and looks for:
// Return the result over return result)expect(true).toBe(true)), or only check that a value existsUnslop never edits a file. It tells Claude, and Claude removes the slop:
haiku by default) then looks at the same lines for slop that rules cannot judge, such as a test that only tests a mock. If Claude is still working, the findings go into the running turn. If the turn already ended, they wait for you: the status line counts them, and /unslop fix sends them to Claude.It skips node_modules, vendored and generated folders, lockfiles, minified files, and binary files.
Unslop also adds one short rule to Claude's system prompt: write chat replies in ASD-STE100 Simplified Technical English at about 80% strictness, with no em or en dashes, no filler, and no flattery. The rule is for chat only, not for code.
The status line shows the count: unslop: 4 removed, 1 open /unslop.
/unslop: open the Unslop pane with every finding, grouped by file./unslop fix: ask Claude to remove every open finding. Use it when the status line says some are waiting./unslop clear: remove the findings that are already fixed from the list./unslop off and /unslop on: pause or resume the checks and the chat rule. The choice stays for the next session./unslop opens the pane with the keyboard in it:
✂ UNSLOP 3 removed 1 open
f fix open ones c clear removed Tab moves Enter presses Esc back to the prompt
Fix open ones Clear removed
src/cart.ts
✓ removed comment block line 12 /**
● open slop test line 30 expect(true).toBe(true)
This assertion is always true. Assert real behavior, or remove it.
f to send the open findings to Claude, or c to clear the removed ones.Removed findings show in green with a check. Open findings show in yellow, with what to do under them.
Change these in /plugin:
chatStyle (on): add the plain chat rule to Claude's system prompt.modelPass (on): run the model check after each edit.detectModel (haiku): the model for the model check.commentBlockLines (4): flag a run of added comment lines this long or longer.The rule checks cost nothing. The model check is one small call per edit; turn off modelPass to stop it.
See the marketplace README for configuration, privacy, updates, and removal.
hooks/register.ts 51 lines1import type { Register } from 'claude-code'
2
3import { installEdits } from './edits'
4import { installPane } from './pane'
5import { statusLine, STYLE_SECTION_ID, STYLE_TEXT } from './slop/notes'
6import { configureUnslop, counts, ON_STORE_KEY, resetUnslop, setOn, unslopView } from './slop/state'
7
8const DEFAULT_COMMENT_BLOCK_LINES = 4
9const MIN_COMMENT_BLOCK_LINES = 2
10
11function textOption(value: unknown): string {
12 return typeof value === 'string' && value.trim() !== '' ? value.trim() : 'haiku'
13}
14
15function blockLinesOption(value: unknown): number {
16 return typeof value === 'number' && Number.isFinite(value) && value >= MIN_COMMENT_BLOCK_LINES ? Math.round(value) : DEFAULT_COMMENT_BLOCK_LINES
17}
18
19export const register: Register = (on, options) => {
20 configureUnslop({
21 chatStyle: options.chatStyle !== false,
22 modelPass: options.modelPass !== false,
23 detectModel: textOption(options.detectModel),
24 commentBlockLines: blockLinesOption(options.commentBlockLines),
25 })
26 installEdits(on)
27 installPane(on)
28
29 on('session.start', async ($, e, next) => {
30 resetUnslop({ root: e.cwd })
31 await $.command.register({
32 name: 'unslop',
33 description: 'Unslop: see the slop found in Claude\'s changes and what was removed. fix asks Claude to remove open slop; on or off pauses it.',
34 argumentHint: '[fix|clear|on|off]',
35 immediate: true,
36 })
37 setOn((await $.store.get(ON_STORE_KEY)) !== false)
38 $.ui.status(statusLine(counts()))
39 return next(e)
40 })
41
42 on('prompt.compose', async ($, e, next) => {
43 const composed = await next(e)
44 const { isOn, settings } = unslopView()
45 if (!isOn || !settings.chatStyle) {
46 return composed
47 }
48 return { sections: [...composed.sections, { id: STYLE_SECTION_ID, text: STYLE_TEXT, scope: 'session' }] }
49 })
50}
51hooks/edits.ts 158 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { DETECT_SYSTEM, detectPrompt, isStillPresent, parseDetection, withSnapshots } from './slop/detect'
4import { appendNote, contextNote, statusLine } from './slop/notes'
5import { isBinaryText, isIgnoredPath, relativePath } from './slop/paths'
6import { scanSegments } from './slop/scan'
7import { addedLines, segmentsFromContent, segmentsFromPatch, segmentsFromStrings } from './slop/segments'
8import {
9 addFindings,
10 counts,
11 filesWithOpenFindings,
12 finishTurn,
13 markNotified,
14 markRemoved,
15 openFindingsFor,
16 startTurn,
17 unslopView,
18} from './slop/state'
19import type { DetectJob, Segment } from './slop/types'
20import { noop } from './shared/noop'
21
22const DETECT_TIMEOUT_MS = 30000
23const DETECT_MAX_TOKENS = 600
24
25let detection: Promise<void> = Promise.resolve()
26
27function showCounts($: EngineInterface): void {
28 $.ui.status(statusLine(counts()))
29 $.ui.invalidate('ui.render')
30}
31
32async function recheck($: EngineInterface, options: { absolutePath: string }): Promise<void> {
33 const open = openFindingsFor(options.absolutePath)
34 if (open.length === 0) {
35 return
36 }
37 const text = await $.fs.read(options.absolutePath).catch(() => '')
38 const { commentBlockLines } = unslopView().settings
39 markRemoved(open.filter(finding => !isStillPresent({ finding, text, commentBlockLines })).map(finding => finding.id))
40}
41
42async function detect($: EngineInterface, job: DetectJob): Promise<void> {
43 const { settings } = unslopView()
44 const result = await $.model
45 .complete({
46 model: settings.detectModel,
47 system: DETECT_SYSTEM,
48 prompt: detectPrompt({ path: job.path, addedLines: job.addedLines, known: openFindingsFor(job.absolutePath).map(finding => finding.excerpt) }),
49 maxTokens: DETECT_MAX_TOKENS,
50 timeoutMs: DETECT_TIMEOUT_MS,
51 })
52 .catch(() => undefined)
53 if (!result?.isAnswered) {
54 return
55 }
56 const text = await $.fs.read(job.absolutePath).catch(() => '')
57 const current = new Set(text.split('\n').map(line => line.trim()))
58 const known = new Set(openFindingsFor(job.absolutePath).map(finding => finding.excerpt))
59 const parsed = parseDetection({ text: result.text, path: job.path, addedLines: job.addedLines }).filter(
60 candidate => current.has(candidate.excerpt) && !known.has(candidate.excerpt),
61 )
62 const candidates = withSnapshots({ candidates: parsed, text })
63 const added = addFindings({ path: job.path, absolutePath: job.absolutePath, candidates, source: 'model', isNotified: false })
64 if (added.length === 0) {
65 return
66 }
67 const view = unslopView()
68 if (view.isWorking && view.turn === job.turn) {
69 const appended = await $.session
70 .append({ message: { type: 'user', content: [{ type: 'text', text: appendNote(added) }] } })
71 .catch(() => undefined)
72 if (appended !== undefined && appended.deny === undefined) {
73 markNotified(added.map(finding => finding.id))
74 }
75 }
76 showCounts($)
77}
78
79function queueDetection($: EngineInterface, job: DetectJob): void {
80 detection = detection.then(() => detect($, job)).catch(noop)
81}
82
83async function afterEdit($: EngineInterface, options: { absolutePath: string; segments: readonly Segment[] }): Promise<string | undefined> {
84 const view = unslopView()
85 const path = relativePath({ path: options.absolutePath, root: view.root })
86 if (!view.isOn || isIgnoredPath(path)) {
87 return undefined
88 }
89 await recheck($, { absolutePath: options.absolutePath })
90 const lines = addedLines(options.segments)
91 if (lines.every(line => line.trim() === '') || isBinaryText(lines.join('\n'))) {
92 showCounts($)
93 return undefined
94 }
95 const candidates = scanSegments({ path, segments: options.segments, commentBlockLines: view.settings.commentBlockLines })
96 const added = addFindings({ path, absolutePath: options.absolutePath, candidates, source: 'rules', isNotified: true })
97 if (view.settings.modelPass) {
98 queueDetection($, { path, absolutePath: options.absolutePath, addedLines: lines, turn: view.turn })
99 }
100 showCounts($)
101 return added.length === 0 ? undefined : contextNote({ path, findings: added })
102}
103
104export function installEdits(on: On): void {
105 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
106 const ran = await next(e)
107 if (ran.deny !== undefined || ran.isError === true || ran.result.staged === true) {
108 return ran
109 }
110 const patch = ran.result.structuredPatch
111 const segments = patch.length > 0 ? segmentsFromPatch(patch) : segmentsFromStrings({ before: e.old_string, after: e.new_string })
112 const note = await afterEdit($, { absolutePath: e.file_path, segments })
113 return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
114 })
115
116 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
117 const ran = await next(e)
118 if (ran.deny !== undefined || ran.isError === true || ran.result.staged === true) {
119 return ran
120 }
121 const { structuredPatch, type, originalFile } = ran.result
122 let segments = segmentsFromPatch(structuredPatch)
123 if (type === 'create') {
124 segments = segmentsFromContent(e.content)
125 } else if (structuredPatch.length === 0) {
126 segments = segmentsFromStrings({ before: originalFile ?? '', after: e.content })
127 }
128 const note = await afterEdit($, { absolutePath: e.file_path, segments })
129 return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
130 })
131
132 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
133 const ran = await next(e)
134 if (ran.deny !== undefined || ran.isError === true || e.edit_mode === 'delete') {
135 return ran
136 }
137 const segments = segmentsFromStrings({ before: ran.result.old_source ?? '', after: e.new_source })
138 const note = await afterEdit($, { absolutePath: e.notebook_path, segments })
139 return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
140 })
141
142 on('turn.start', ($, e, next) => {
143 startTurn()
144 return next(e)
145 })
146
147 on('turn.complete', async ($, e, next) => {
148 if (e.agentId === undefined) {
149 finishTurn()
150 for (const absolutePath of filesWithOpenFindings()) {
151 await recheck($, { absolutePath })
152 }
153 showCounts($)
154 }
155 return next(e)
156 })
157}
158hooks/pane.tsx 121 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { fixPrompt, KIND_LABELS, shortExcerpt, statusLine } from './slop/notes'
4import { clearRemoved, counts, markNotified, ON_STORE_KEY, openFindings, setOn, unslopView } from './slop/state'
5import type { Finding } from './slop/types'
6
7export const PANE_ID = 'unslop'
8const ACCENT = '#5eead4'
9const OPEN = '#fbbf24'
10const REMOVED = '#4ade80'
11const PANE_ROWS = 18
12
13function showCounts($: EngineInterface): void {
14 $.ui.status(statusLine(counts()))
15 $.ui.invalidate('ui.render')
16}
17
18function fix($: EngineInterface): number {
19 const open = openFindings()
20 if (open.length === 0) {
21 return 0
22 }
23 markNotified(open.map(finding => finding.id))
24 showCounts($)
25 $.clock.after(0, () => {
26 $.prompt.submit({ text: fixPrompt(open) }).catch(() => $.ui.toast('Unslop could not send the fix request. Try /unslop fix again.'))
27 })
28 return open.length
29}
30
31function pressFix($: EngineInterface): void {
32 if (fix($) === 0) {
33 $.ui.toast('No open slop to fix.')
34 }
35}
36
37function clear($: EngineInterface): number {
38 const cleared = clearRemoved()
39 showCounts($)
40 return cleared
41}
42
43function byFile(findings: readonly Finding[]): [string, Finding[]][] {
44 const groups = new Map<string, Finding[]>()
45 findings.forEach(finding => groups.set(finding.path, [...(groups.get(finding.path) ?? []), finding]))
46 return [...groups.entries()].reverse()
47}
48
49function plural(options: { count: number; word: string }): string {
50 return `${options.count} ${options.word}${options.count === 1 ? '' : 's'}`
51}
52
53export function installPane(on: On): void {
54 on('command.run', { command: 'unslop' }, async ($, e) => {
55 const action = e.args.trim().toLowerCase()
56 if (action === 'fix') {
57 const sent = fix($)
58 return { text: sent === 0 ? 'No open slop to fix.' : `Asked Claude to remove ${plural({ count: sent, word: 'piece' })} of slop.` }
59 }
60 if (action === 'on' || action === 'off') {
61 setOn(action === 'on')
62 await $.store.set(ON_STORE_KEY, action === 'on')
63 $.ui.invalidate('ui.render')
64 return { text: action === 'on' ? 'Unslop is on. It checks every edit and keeps the plain chat style.' : 'Unslop is paused. Edits are not checked and the chat style rule is off.' }
65 }
66 if (action === 'clear') {
67 return { text: `Cleared ${plural({ count: clear($), word: 'removed finding' })}.` }
68 }
69 await $.ui.open({ id: PANE_ID, title: 'Unslop', focus: true, rows: PANE_ROWS })
70 const { removed, open } = counts()
71 return { text: `${removed} removed, ${open} open. In the pane, f sends open slop to Claude and Esc returns to the prompt.` }
72 })
73
74 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
75 const { Box, Text, Button } = $.ui.resolve(e)
76 const view = unslopView()
77 const { removed, open, waiting } = counts()
78 const hint = e.props.isFocused
79 ? 'f fix open c clear removed Tab move Enter press Esc to prompt'
80 : 'ctrl+x tab to use the keys /unslop fix sends open slop to Claude'
81 const groups = byFile(view.findings)
82 return (
83 <Box flexDirection="column">
84 <Text>
85 <Text bold color={ACCENT}>{'✂ UNSLOP '}</Text>
86 <Text color={REMOVED}>{`${removed} removed `}</Text>
87 <Text color={OPEN}>{`${open} open`}</Text>
88 {waiting > 0 && <Text dimColor>{` (${waiting} not sent to Claude yet)`}</Text>}
89 {!view.isOn && <Text dimColor>{' paused: /unslop on'}</Text>}
90 </Text>
91 <Text dimColor>{hint}</Text>
92 <Box flexDirection="row" gap={2} marginY={1}>
93 <Button key="fix" hotkey="f" label="Fix open ones" onPress={() => pressFix($)} />
94 <Button key="clear" hotkey="c" label="Clear removed" onPress={() => clear($)} />
95 </Box>
96 {groups.length === 0 && <Text dimColor>No slop yet. Unslop checks each edit Claude makes and lists what it finds here.</Text>}
97 {groups.map(([path, findings]) => (
98 <Box key={`file-${path}`} flexDirection="column" marginBottom={1}>
99 <Text bold>{path}</Text>
100 {findings.map(finding => (
101 <Box key={`finding-${finding.id}`} flexDirection="column">
102 <Text wrap="truncate-end">
103 <Text color={finding.status === 'removed' ? REMOVED : OPEN}>{finding.status === 'removed' ? ' ✓ removed ' : ' ● open '}</Text>
104 <Text bold>{`${KIND_LABELS[finding.kind]}`}</Text>
105 <Text dimColor>{finding.line === undefined ? ' ' : ` line ${finding.line} `}</Text>
106 <Text>{shortExcerpt(finding.excerpt)}</Text>
107 </Text>
108 {finding.status === 'open' && (
109 <Box paddingLeft={4}>
110 <Text dimColor>{finding.advice}</Text>
111 </Box>
112 )}
113 </Box>
114 ))}
115 </Box>
116 ))}
117 </Box>
118 )
119 })
120}
121hooks/slop/notes.ts 72 lines1import type { Finding, SlopKind } from './types'
2
3const EXCERPT_LIMIT = 120
4
5export const STYLE_SECTION_ID = 'unslop:style'
6
7export const STYLE_TEXT = [
8 'Write your chat replies to the user in ASD-STE100 Simplified Technical English, at about 80% strictness.',
9 'Keep sentences short: 20 words or fewer for an instruction, 25 or fewer for a description. Put one idea in each sentence.',
10 'Use the active voice, simple tenses, and common words with one meaning. Use the same term for the same thing every time.',
11 'Do not use idioms, filler, flattery, hedging, or a closing summary that repeats what you said.',
12 'Do not use the em dash or the en dash. Use a comma, a period, parentheses, or two sentences.',
13 'Keep code, commands, file paths, and product names exactly as they are. This rule is for chat replies, not for code.',
14].join('\n')
15
16export const KIND_LABELS: Record<SlopKind, string> = {
17 dash: 'dash',
18 'comment-block': 'comment block',
19 'restating-comment': 'restating comment',
20 'slop-test': 'slop test',
21 'ai-phrase': 'AI phrase',
22 emoji: 'emoji',
23 'needless-code': 'needless code',
24}
25
26export function shortExcerpt(text: string): string {
27 return text.length > EXCERPT_LIMIT ? `${text.slice(0, EXCERPT_LIMIT - 1)}…` : text
28}
29
30function where(finding: Finding): string {
31 const line = finding.line === undefined ? '' : ` line ${finding.line}`
32 return `${line} (${KIND_LABELS[finding.kind]})`.trim()
33}
34
35function item(options: { finding: Finding; isPathShown: boolean }): string {
36 const { finding } = options
37 const path = options.isPathShown ? `${finding.path} ` : ''
38 return `- ${path}${where(finding)}: \`${shortExcerpt(finding.excerpt)}\` ${finding.advice}`
39}
40
41export function contextNote(options: { path: string; findings: readonly Finding[] }): string {
42 return [
43 `Unslop found slop in what you just added to ${options.path}. Remove it with a follow-up edit, and keep the behavior the same:`,
44 ...options.findings.map(finding => item({ finding, isPathShown: false })),
45 'You do not need to tell the user about this note.',
46 ].join('\n')
47}
48
49export function appendNote(findings: readonly Finding[]): string {
50 return [
51 'Unslop (a plugin) checked your recent edits and found more slop. Remove it when you next touch these files, and keep the behavior the same:',
52 ...findings.map(finding => item({ finding, isPathShown: true })),
53 ].join('\n')
54}
55
56export function fixPrompt(findings: readonly Finding[]): string {
57 return [
58 'Unslop found slop in your recent changes. Remove each item without changing behavior:',
59 ...findings.map(finding => item({ finding, isPathShown: true })),
60 'Do not add comments that explain the removal.',
61 ].join('\n')
62}
63
64export function statusLine(options: { removed: number; open: number; waiting: number }): string | undefined {
65 const { removed, open, waiting } = options
66 if (removed === 0 && open === 0) {
67 return undefined
68 }
69 const base = `${removed} removed, ${open} open`
70 return waiting > 0 ? `${base}, ${waiting} waiting /unslop fix` : `${base} /unslop`
71}
72hooks/slop/state.ts 143 lines1import { findingKey } from './scan'
2import type { Candidate, DetectJob, Finding, FindingSource } from './types'
3
4export const ON_STORE_KEY = 'isOn'
5const MAX_FINDINGS = 200
6const MAX_JOBS = 20
7
8export type UnslopSettings = { chatStyle: boolean; modelPass: boolean; detectModel: string; commentBlockLines: number }
9
10type Unslop = {
11 isOn: boolean
12 isWorking: boolean
13 turn: number
14 root: string
15 findings: Finding[]
16 jobs: DetectJob[]
17 nextId: number
18 settings: UnslopSettings
19}
20
21const unslop: Unslop = {
22 isOn: true,
23 isWorking: false,
24 turn: 0,
25 root: '',
26 findings: [],
27 jobs: [],
28 nextId: 1,
29 settings: { chatStyle: true, modelPass: true, detectModel: 'haiku', commentBlockLines: 4 },
30}
31
32export function unslopView(): Readonly<Unslop> {
33 return unslop
34}
35
36export function configureUnslop(settings: UnslopSettings): void {
37 unslop.settings = settings
38}
39
40export function resetUnslop(options: { root: string }): void {
41 unslop.root = options.root
42 unslop.isWorking = false
43 unslop.jobs = []
44}
45
46export function setOn(isOn: boolean): void {
47 unslop.isOn = isOn
48}
49
50export function startTurn(): void {
51 unslop.turn += 1
52 unslop.isWorking = true
53}
54
55export function finishTurn(): void {
56 unslop.isWorking = false
57}
58
59export function addFindings(options: {
60 path: string
61 absolutePath: string
62 candidates: readonly Candidate[]
63 source: FindingSource
64 isNotified: boolean
65}): Finding[] {
66 const known = new Set(
67 unslop.findings
68 .filter(finding => finding.status === 'open' && finding.absolutePath === options.absolutePath)
69 .map(finding => findingKey(finding)),
70 )
71 const added = options.candidates
72 .filter(candidate => !known.has(findingKey(candidate)))
73 .map(candidate => {
74 const finding: Finding = {
75 ...candidate,
76 id: unslop.nextId,
77 path: options.path,
78 absolutePath: options.absolutePath,
79 source: options.source,
80 status: 'open',
81 isNotified: options.isNotified,
82 turn: unslop.turn,
83 }
84 unslop.nextId += 1
85 return finding
86 })
87 unslop.findings = [...unslop.findings, ...added].slice(-MAX_FINDINGS)
88 return added
89}
90
91export function openFindings(): Finding[] {
92 return unslop.findings.filter(finding => finding.status === 'open')
93}
94
95export function openFindingsFor(absolutePath: string): Finding[] {
96 return openFindings().filter(finding => finding.absolutePath === absolutePath)
97}
98
99export function markRemoved(ids: readonly number[]): void {
100 unslop.findings.forEach(finding => {
101 if (ids.includes(finding.id)) {
102 finding.status = 'removed'
103 }
104 })
105}
106
107export function markNotified(ids: readonly number[]): void {
108 unslop.findings.forEach(finding => {
109 if (ids.includes(finding.id)) {
110 finding.isNotified = true
111 }
112 })
113}
114
115export function clearRemoved(): number {
116 const before = unslop.findings.length
117 unslop.findings = unslop.findings.filter(finding => finding.status === 'open')
118 return before - unslop.findings.length
119}
120
121export function queueJob(job: DetectJob): void {
122 unslop.jobs = [...unslop.jobs, job].slice(-MAX_JOBS)
123}
124
125export function takeJob(): DetectJob | undefined {
126 const [job, ...rest] = unslop.jobs
127 unslop.jobs = rest
128 return job
129}
130
131export function counts(): { removed: number; open: number; waiting: number } {
132 const open = openFindings()
133 return {
134 removed: unslop.findings.filter(finding => finding.status === 'removed').length,
135 open: open.length,
136 waiting: open.filter(finding => !finding.isNotified).length,
137 }
138}
139
140export function filesWithOpenFindings(): string[] {
141 return [...new Set(openFindings().map(finding => finding.absolutePath))]
142}
143hooks/slop/detect.ts 106 lines1import { commentRuns, isCommentLine } from './comments'
2import { familyOf } from './paths'
3import { findingKey, scanText } from './scan'
4import { testBlocks } from './tests'
5import type { Candidate, FileFamily, Finding, SlopKind } from './types'
6
7const MAX_PROMPT_CHARACTERS = 6000
8const KINDS: Readonly<Record<string, SlopKind>> = {
9 test: 'slop-test',
10 comment: 'restating-comment',
11 phrase: 'ai-phrase',
12 code: 'needless-code',
13}
14
15export const DETECT_SYSTEM = [
16 'You review lines that an AI coding assistant just added to a file, and you find slop.',
17 'Slop is what AI assistants write often, people rarely write, and that adds nothing.',
18 'Report only clear cases of these kinds:',
19 'test: a test that asserts nothing real (a tautology, only checks that a value exists, or tests a mock instead of the code).',
20 'comment: a comment that narrates what the next line plainly does, a banner, or a step label.',
21 'phrase: filler or marketing words in docs or comments.',
22 'code: defensive code that does nothing, such as a catch that only rethrows or a check that cannot fail.',
23 'Do not report real explanations of non-obvious behavior, style preferences, or lines that were not added.',
24 'Do not report test names, function names, string literals, or log messages.',
25 'Reply with JSON only: {"findings":[{"line":"<one added line, copied exactly>","kind":"test|comment|phrase|code","fix":"<what to do, under 15 words>"}]}.',
26 'Reply {"findings":[]} when the lines are clean.',
27].join('\n')
28
29export function detectPrompt(options: { path: string; addedLines: readonly string[]; known: readonly string[] }): string {
30 const added = options.addedLines.join('\n').slice(0, MAX_PROMPT_CHARACTERS)
31 const known = options.known.length === 0 ? 'none' : options.known.map(line => `- ${line}`).join('\n')
32 return `File: ${options.path}\n\nAdded lines:\n${added}\n\nAlready reported, skip these:\n${known}`
33}
34
35function jsonObject(text: string): unknown {
36 const start = text.indexOf('{')
37 const end = text.lastIndexOf('}')
38 if (start < 0 || end <= start) {
39 return undefined
40 }
41 try {
42 return JSON.parse(text.slice(start, end + 1))
43 } catch {
44 return undefined
45 }
46}
47
48function isRecord(value: unknown): value is Record<string, unknown> {
49 return typeof value === 'object' && value !== null && !Array.isArray(value)
50}
51
52function fitsKind(options: { kind: SlopKind; line: string; family: FileFamily }): boolean {
53 const isComment = isCommentLine({ line: options.line, family: options.family })
54 if (options.kind === 'restating-comment') {
55 return isComment
56 }
57 return options.kind !== 'ai-phrase' || isComment || options.family === 'prose'
58}
59
60export function parseDetection(options: { text: string; path: string; addedLines: readonly string[] }): Candidate[] {
61 const family = familyOf(options.path)
62 const parsed = jsonObject(options.text)
63 const findings = isRecord(parsed) && Array.isArray(parsed.findings) ? parsed.findings.filter(isRecord) : []
64 const trimmedLines = options.addedLines.map(line => line.trim()).filter(line => line !== '')
65 return findings.flatMap(finding => {
66 const reported = typeof finding.line === 'string' ? finding.line.trim() : ''
67 const kind = typeof finding.kind === 'string' ? KINDS[finding.kind] : undefined
68 const fix = typeof finding.fix === 'string' ? finding.fix.trim() : ''
69 const excerpt = reported === '' ? undefined : trimmedLines.find(line => line === reported || line.includes(reported))
70 if (!kind || !excerpt || !fitsKind({ kind, line: excerpt, family })) {
71 return []
72 }
73 return [{ kind, line: undefined, excerpt, advice: fix === '' ? 'Remove it.' : fix }]
74 })
75}
76
77export function testSnapshot(options: { text: string; excerpt: string }): string | undefined {
78 const block = testBlocks(options.text.split('\n')).find(candidate => candidate.header.trim() === options.excerpt)
79 return block?.body.join('\n')
80}
81
82export function withSnapshots(options: { candidates: readonly Candidate[]; text: string }): Candidate[] {
83 return options.candidates.map(candidate => {
84 const snapshot = candidate.kind === 'slop-test' ? testSnapshot({ text: options.text, excerpt: candidate.excerpt }) : undefined
85 return snapshot === undefined ? candidate : { ...candidate, snapshot }
86 })
87}
88
89export function isStillPresent(options: { finding: Finding; text: string; commentBlockLines: number }): boolean {
90 const { finding, text } = options
91 const lines = text.split('\n')
92 if (finding.snapshot !== undefined) {
93 return testSnapshot({ text, excerpt: finding.excerpt }) === finding.snapshot
94 }
95 if (finding.source === 'model') {
96 return lines.some(line => line.trim() === finding.excerpt)
97 }
98 if (finding.kind === 'comment-block') {
99 return commentRuns({ lines, family: familyOf(finding.path) }).some(
100 run => run.lines.length >= options.commentBlockLines && run.lines.some(line => line.trim() === finding.excerpt),
101 )
102 }
103 const key = findingKey(finding)
104 return scanText({ path: finding.path, text, commentBlockLines: options.commentBlockLines }).some(candidate => findingKey(candidate) === key)
105}
106hooks/slop/paths.ts 55 lines1import type { FileFamily } from './types'
2
3const SLASH_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'java', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'kts', 'scala', 'dart', 'php', 'css', 'scss', 'less', 'vue', 'svelte']
4const HASH_EXTENSIONS = ['py', 'rb', 'sh', 'bash', 'zsh', 'fish', 'yaml', 'yml', 'toml', 'r', 'pl', 'ex', 'exs', 'nix', 'tf', 'ps1']
5const DOUBLE_DASH_EXTENSIONS = ['sql', 'lua', 'hs', 'elm']
6const PROSE_EXTENSIONS = ['md', 'mdx', 'markdown', 'txt', 'rst', 'adoc']
7const BINARY_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'pdf', 'zip', 'gz', 'tgz', 'woff', 'woff2', 'ttf', 'otf', 'mp3', 'mp4', 'mov', 'wasm', 'lockb']
8const LOCKFILES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'cargo.lock', 'poetry.lock', 'gemfile.lock', 'go.sum', 'composer.lock', 'uv.lock', 'pipfile.lock']
9const IGNORED_FOLDER = /(?:^|\/)(?:node_modules|vendor|vendors|third_party|dist|build|out|coverage|\.git|\.next|target|__generated__|generated)\//i
10const GENERATED_FILE = /\.(?:min\.[a-z]+|map|snap|lock)$|\.generated\.|\.pb\.|_pb2\.py$/i
11const TEST_FILE = /(?:^|\/)(?:tests?|__tests__|spec)\/|\.(?:test|spec)\.[a-z]+$|(?:^|\/)test_[^/]+\.py$|_test\.(?:go|py|rb)$/i
12
13function extensionOf(path: string): string {
14 const name = path.split('/').at(-1) ?? ''
15 const dot = name.lastIndexOf('.')
16 return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
17}
18
19export function familyOf(path: string): FileFamily {
20 const extension = extensionOf(path)
21 if (SLASH_EXTENSIONS.includes(extension)) {
22 return 'slash'
23 }
24 if (HASH_EXTENSIONS.includes(extension)) {
25 return 'hash'
26 }
27 if (DOUBLE_DASH_EXTENSIONS.includes(extension)) {
28 return 'double-dash'
29 }
30 return PROSE_EXTENSIONS.includes(extension) ? 'prose' : 'other'
31}
32
33export function isTestPath(path: string): boolean {
34 return TEST_FILE.test(path)
35}
36
37export function isIgnoredPath(path: string): boolean {
38 const name = (path.split('/').at(-1) ?? '').toLowerCase()
39 return (
40 IGNORED_FOLDER.test(path) ||
41 GENERATED_FILE.test(path) ||
42 LOCKFILES.includes(name) ||
43 BINARY_EXTENSIONS.includes(extensionOf(path))
44 )
45}
46
47export function isBinaryText(text: string): boolean {
48 return text.includes(String.fromCodePoint(0))
49}
50
51export function relativePath(options: { path: string; root: string }): string {
52 const root = options.root.replace(/\/+$/, '')
53 return root !== '' && options.path.startsWith(`${root}/`) ? options.path.slice(root.length + 1) : options.path
54}
55hooks/slop/scan.ts 117 lines1import { commentRuns, commentText, isCommentLine, isDirective, isRestating } from './comments'
2import { familyOf, isTestPath } from './paths'
3import { dashName, hasDash, hasEmoji, isProseHeadingOrBullet, phraseIn } from './phrases'
4import { hasNoAssertion, hasOnlyWeakAssertions, isTautology, testBlocks } from './tests'
5import type { Candidate, FileFamily, Segment, SlopKind } from './types'
6
7const MAX_FINDINGS = 12
8const FENCE = /^\s*(?:```|~~~)/
9
10type Context = { segment: Segment; family: FileFamily; found: Candidate[] }
11
12export function findingKey(options: { kind: SlopKind; excerpt: string }): string {
13 return `${options.kind}:${options.excerpt}`
14}
15
16function add(context: Context, options: { kind: SlopKind; index: number; advice: string }): void {
17 const excerpt = (context.segment.lines[options.index] ?? '').trim()
18 const key = findingKey({ kind: options.kind, excerpt })
19 if (excerpt === '' || context.found.some(found => findingKey(found) === key)) {
20 return
21 }
22 const { startLine } = context.segment
23 context.found.push({
24 kind: options.kind,
25 line: startLine === undefined ? undefined : startLine + options.index,
26 excerpt,
27 advice: options.advice,
28 })
29}
30
31function scanLines(context: Context): void {
32 let isInFence = false
33 context.segment.lines.forEach((line, index) => {
34 if (hasDash(line)) {
35 add(context, { kind: 'dash', index, advice: `Replace the ${dashName(line)} with a comma, a period, parentheses, or two sentences.` })
36 }
37 if (context.family === 'prose' && FENCE.test(line)) {
38 isInFence = !isInFence
39 return
40 }
41 const isComment = isCommentLine({ line, family: context.family })
42 const isProse = context.family === 'prose' && !isInFence
43 if (!isComment && !isProse) {
44 return
45 }
46 const text = isComment ? commentText(line) : line
47 const phrase = isComment && isDirective(line) ? undefined : phraseIn(text)
48 if (phrase) {
49 add(context, { kind: 'ai-phrase', index, advice: `"${phrase}" is filler that AI writes often. Use a plain word, or cut it.` })
50 }
51 if (hasEmoji(text) && (isComment || isProseHeadingOrBullet(line))) {
52 add(context, { kind: 'emoji', index, advice: 'Remove the emoji.' })
53 }
54 })
55}
56
57function scanComments(context: Context, options: { commentBlockLines: number }): void {
58 const { lines } = context.segment
59 commentRuns({ lines, family: context.family }).forEach(run => {
60 const isLicense = run.lines.some(line => isDirective(line))
61 if (run.lines.length >= options.commentBlockLines && !isLicense) {
62 add(context, {
63 kind: 'comment-block',
64 index: run.start,
65 advice: `A ${run.lines.length}-line comment block. Remove it, or cut it to one short line and let names carry the meaning.`,
66 })
67 return
68 }
69 const last = run.lines.at(-1) ?? ''
70 const code = lines[run.start + run.lines.length] ?? ''
71 if (run.lines.length === 1 && code.trim() !== '' && isRestating({ comment: last, code })) {
72 add(context, { kind: 'restating-comment', index: run.start, advice: 'This comment repeats the next line of code. Remove it.' })
73 }
74 })
75}
76
77function scanTests(context: Context): void {
78 const { lines } = context.segment
79 lines.forEach((line, index) => {
80 if (isTautology(line)) {
81 add(context, { kind: 'slop-test', index, advice: 'This assertion is always true. Assert real behavior, or remove it.' })
82 }
83 })
84 testBlocks(lines).forEach(block => {
85 if (hasNoAssertion(block)) {
86 add(context, { kind: 'slop-test', index: block.start, advice: 'This test asserts nothing. Add a real assertion, or remove the test.' })
87 } else if (hasOnlyWeakAssertions(block)) {
88 add(context, { kind: 'slop-test', index: block.start, advice: 'This test only checks that a value exists. Assert the value itself.' })
89 }
90 })
91}
92
93type ScanOptions = { path: string; segments: readonly Segment[]; commentBlockLines: number }
94
95function scanAll(options: ScanOptions): Candidate[] {
96 const family = familyOf(options.path)
97 const found: Candidate[] = []
98 options.segments.forEach(segment => {
99 const context: Context = { segment, family, found }
100 scanLines(context)
101 scanComments(context, { commentBlockLines: options.commentBlockLines })
102 if (isTestPath(options.path)) {
103 scanTests(context)
104 }
105 })
106 return found
107}
108
109export function scanSegments(options: ScanOptions): Candidate[] {
110 return scanAll(options).slice(0, MAX_FINDINGS)
111}
112
113export function scanText(options: { path: string; text: string; commentBlockLines: number }): Candidate[] {
114 const segment: Segment = { startLine: 1, lines: options.text.split('\n') }
115 return scanAll({ path: options.path, segments: [segment], commentBlockLines: options.commentBlockLines })
116}
117hooks/slop/segments.ts 61 lines1import type { Segment } from './types'
2
3type Hunk = { newStart: number; lines: readonly string[] }
4
5export function segmentsFromPatch(hunks: readonly Hunk[]): Segment[] {
6 const segments: Segment[] = []
7 hunks.forEach(hunk => {
8 let lineNumber = hunk.newStart
9 let run: { startLine: number; lines: string[] } | undefined
10 const close = () => {
11 if (run) {
12 segments.push(run)
13 run = undefined
14 }
15 }
16 hunk.lines.forEach(line => {
17 if (line.startsWith('+')) {
18 run = run ?? { startLine: lineNumber, lines: [] }
19 run.lines.push(line.slice(1))
20 lineNumber += 1
21 } else if (line.startsWith(' ')) {
22 close()
23 lineNumber += 1
24 }
25 })
26 close()
27 })
28 return segments
29}
30
31export function segmentsFromStrings(options: { before: string; after: string }): Segment[] {
32 const remaining = new Map<string, number>()
33 options.before.split('\n').forEach(line => remaining.set(line, (remaining.get(line) ?? 0) + 1))
34 const segments: Segment[] = []
35 let run: string[] = []
36 options.after.split('\n').forEach(line => {
37 const count = remaining.get(line) ?? 0
38 if (count > 0) {
39 remaining.set(line, count - 1)
40 if (run.length > 0) {
41 segments.push({ startLine: undefined, lines: run })
42 run = []
43 }
44 return
45 }
46 run.push(line)
47 })
48 if (run.length > 0) {
49 segments.push({ startLine: undefined, lines: run })
50 }
51 return segments
52}
53
54export function segmentsFromContent(content: string): Segment[] {
55 return [{ startLine: 1, lines: content.split('\n') }]
56}
57
58export function addedLines(segments: readonly Segment[]): string[] {
59 return segments.flatMap(segment => [...segment.lines])
60}
61hooks/slop/types.ts 42 lines1export type SlopKind =
2 | 'dash'
3 | 'comment-block'
4 | 'restating-comment'
5 | 'slop-test'
6 | 'ai-phrase'
7 | 'emoji'
8 | 'needless-code'
9
10export type FindingSource = 'rules' | 'model'
11
12export type FindingStatus = 'open' | 'removed'
13
14export type Segment = { startLine: number | undefined; lines: readonly string[] }
15
16export type Candidate = {
17 kind: SlopKind
18 line: number | undefined
19 excerpt: string
20 advice: string
21 snapshot?: string
22}
23
24export type Finding = Candidate & {
25 id: number
26 path: string
27 absolutePath: string
28 source: FindingSource
29 status: FindingStatus
30 isNotified: boolean
31 turn: number
32}
33
34export type DetectJob = {
35 path: string
36 absolutePath: string
37 addedLines: readonly string[]
38 turn: number
39}
40
41export type FileFamily = 'slash' | 'hash' | 'double-dash' | 'prose' | 'other'
42hooks/shared/noop.ts 4 lines1export function noop(): undefined {
2 return undefined
3}
4hooks/slop/comments.ts 95 lines1import type { FileFamily } from './types'
2
3const DIRECTIVE = /\b(?:eslint|tslint|prettier-ignore|biome-ignore|@ts-|ts-ignore|ts-expect-error|noqa|pylint|type:\s*ignore|istanbul|c8\s|v8\s|jshint|global\s|#region|#endregion|SPDX|Copyright|@license|@preserve|nolint|rubocop|fmt:)/i
4const STOP_WORDS = new Set(['the', 'and', 'for', 'this', 'that', 'with', 'from', 'into', 'then', 'its', 'our', 'are', 'was', 'all', 'any', 'out', 'here', 'now', 'just', 'will', 'should', 'can', 'each', 'new'])
5const MAX_RESTATING_WORDS = 6
6const RESTATING_RATIO = 0.6
7
8export function isCommentLine(options: { line: string; family: FileFamily }): boolean {
9 const trimmed = options.line.trim()
10 if (trimmed === '') {
11 return false
12 }
13 if (options.family === 'slash') {
14 return /^(?:\/\/|\/\*|\*)/.test(trimmed)
15 }
16 if (options.family === 'hash') {
17 return trimmed.startsWith('#') && !trimmed.startsWith('#!')
18 }
19 if (options.family === 'double-dash') {
20 return trimmed.startsWith('--')
21 }
22 return false
23}
24
25export function commentText(line: string): string {
26 return line
27 .trim()
28 .replace(/^(?:\/\/+|\/\*+|\*+\/?|#+|--+)\s?/, '')
29 .replace(/\*+\/\s*$/, '')
30 .trim()
31}
32
33export function isDirective(line: string): boolean {
34 return DIRECTIVE.test(line) || /^#!/.test(line.trim())
35}
36
37export type CommentRun = { start: number; lines: string[] }
38
39export function commentRuns(options: { lines: readonly string[]; family: FileFamily }): CommentRun[] {
40 const runs: CommentRun[] = []
41 let current: CommentRun | undefined
42 options.lines.forEach((line, index) => {
43 if (isCommentLine({ line, family: options.family })) {
44 current = current ?? { start: index, lines: [] }
45 current.lines.push(line)
46 return
47 }
48 if (current) {
49 runs.push(current)
50 current = undefined
51 }
52 })
53 if (current) {
54 runs.push(current)
55 }
56 return runs
57}
58
59function wordsOf(text: string): string[] {
60 return text
61 .toLowerCase()
62 .split(/[^a-z]+/)
63 .filter(word => word.length >= 3 && !STOP_WORDS.has(word))
64}
65
66function tokensOf(code: string): string[] {
67 return code
68 .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
69 .toLowerCase()
70 .split(/[^a-z]+/)
71 .filter(token => token.length >= 2)
72}
73
74function stemOf(word: string): string {
75 return word.replace(/(?:ing|ed|es|s)$/, '')
76}
77
78function isWordInCode(options: { word: string; tokens: readonly string[] }): boolean {
79 const stem = stemOf(options.word)
80 return options.tokens.some(token => token === options.word || stemOf(token) === stem || (token.length >= 4 && stem.startsWith(token)))
81}
82
83export function isRestating(options: { comment: string; code: string }): boolean {
84 if (isDirective(options.comment)) {
85 return false
86 }
87 const words = wordsOf(commentText(options.comment))
88 if (words.length === 0 || words.length > MAX_RESTATING_WORDS) {
89 return false
90 }
91 const tokens = tokensOf(options.code)
92 const matched = words.filter(word => isWordInCode({ word, tokens })).length
93 return matched > 0 && matched / words.length >= RESTATING_RATIO
94}
95