SLOPSHOPPER

unslop

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…

newpaneguardcommandtoaststatus
★ 1v0.1.0MITupdated 2026-10-04theonly1me/claude-code-mods/plugins/unslop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · unslop
│ ┃ Unslop ✕ › fix the failing auth test and add an audit log call │ ┃ ✂ UNSLOP 0 removed 0 open │ ┃ ctrl+x tab to use the keys /unslop fix ⏺ Read(src/auth.ts) │ ┃ sends open slop to Claude ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Fix open ones ] [ Clear removed ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No slop yet. Unslop checks each edit Claude ⎿ 3 pass, 1 fail │ ┃ makes and lists what it finds here. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /unslop │ ⎿ unslop: 0 removed, 0 open. In the pane, f sends open slop to Cla │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Unslop
✂ UNSLOP 0 removed 0 open ctrl+x tab to use the keys /unslop fix sends open slop to Claude [ Fix open ones ] [ Clear removed ] No slop yet. Unslop checks each edit Claude makes and lists what it finds here.
README

Unslop

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.

Unslop preview

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.

How it works

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:

  • em dashes and en dashes, in code, comments, and docs
  • comment blocks of four lines or more
  • a comment that repeats the next line of code (// Return the result over return result)
  • tests that assert nothing, assert a constant (expect(true).toBe(true)), or only check that a value exists
  • filler phrases in docs and comments ("delve", "leverage", "robust", "seamless", "it is worth noting", and more)
  • emoji in comments and in doc headings or bullets

Unslop never edits a file. It tells Claude, and Claude removes the slop:

  1. Rule findings go into the tool result that Claude reads right after the edit, so Claude usually fixes them in its next step.
  2. A small model check (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.
  3. After Claude's later edits, and at the end of each turn, Unslop reads the file again. A finding whose line is gone counts as removed.

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.

How to use

  • /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.

How to interact

/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.
  • Press f to send the open findings to Claude, or c to clear the removed ones.
  • Tab moves between the buttons, and Enter presses the one that is selected.
  • Esc gives the keyboard back to the prompt. The pane stays open.
  • If you left the pane, press ctrl+x tab to put the keyboard in it again. Close it from its close mark or with ctrl+x x.

Removed findings show in green with a check. Open findings show in yellow, with what to do under them.

Settings

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.

Source 14 files
hooks/register.ts 51 lines
1import 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}
51
hooks/edits.ts 158 lines
1import 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}
158
hooks/pane.tsx 121 lines
1import 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}
121
hooks/slop/notes.ts 72 lines
1import 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}
72
hooks/slop/state.ts 143 lines
1import { 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}
143
hooks/slop/detect.ts 106 lines
1import { 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}
106
hooks/slop/paths.ts 55 lines
1import 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}
55
hooks/slop/scan.ts 117 lines
1import { 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}
117
hooks/slop/segments.ts 61 lines
1import 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}
61
hooks/slop/types.ts 42 lines
1export 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'
42
hooks/shared/noop.ts 4 lines
1export function noop(): undefined {
2  return undefined
3}
4
hooks/slop/comments.ts 95 lines
1import 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