SLOPSHOPPER

smart-comments

Reads each comment Claude adds, lets through the ones that tell a reader what the code cannot, skips docstrings, and names the lines to cut.

newguardtoastprocess
A shopper browsing a rack in a slop shop
README

smart-comments

Claude writes more comments than a reader needs. This mod reads each comment Claude adds, lets through the ones that tell a reader something the code cannot, leaves docstrings alone, and tells Claude which lines to take out.

The idea

A line count cannot tell a good comment from a bad one. This mod charges a comment for what it adds:

KindWhat it isCost
keepA measured figure, a pitfall, a rule that must hold, a reason, a link, or a tool directiveNothing
plainSays something the code does not, but nothing a reader must know1 each, against a limit of 3, or more for a big edit
noiseRepeats the code under it, or tells the story of the editTrips the check alone
docA docstring right on a declarationSkipped

Claude is told which lines to delete and why:

smart-comments: 3 new comment lines in src/a.ts; L3 is fine.
Delete or rewrite L1 (repeats the code), L5 (tells the edit story).
A comment should say what the code cannot: a figure, a pitfall or a rule. Docstrings are fine.

How a comment is judged

The first test that fits decides.

  1. A tool directive, a shebang or a licence line is kept.
  2. History words ("now uses", "previously", "refactored") make it noise.
  3. It repeats the code when at most a third of its words are new. The words of the comment, without filler words and empty verbs such as "get" and "check", are compared with the words of the code it sits on: the same line for a trailing comment, else the next two code lines. Names are split, so userCache gives "user" and "cache".
  4. A measured figure, a rule word ("must", "never", "because"), a phrase that gives the reason ("so that", "to avoid", "in case", "by the time"), a consequence ("so the text is exact"), a return or exit contract ("or null when", "Exit 4", "$1 is", "Arguments:"), a unit ("1-based", "in bytes"), or a link keeps it.
  5. Anything else is plain.

Test 3 runs before test 4, so "ensure user is not null" over if (user !== null) is noise in spite of its rule word. A line that carries on a kept sentence is kept too.

There is no model call. Every test is text matching.

The limit

An edit may add maxPlain plain comment lines, or one for each twenty lines of new code when that is more. So a 10-line edit may add 3, and a 200-line file may add 10. A limit of 0 stays 0.

When it looks

  • Each file edit. The file is read before the call and after it. A comment in the new text that the old text did not have is new; a comment that only moved is not. If the new comments fail, a note reaches Claude with the tool result. The edit is never blocked.
  • A crowded stretch. When an edit adds a plain comment and more than four in ten of the lines within ten lines of it are comments that add little, the note says so.
  • When Claude stops. Each file Claude wrote since your prompt is judged against its text from before the first write. If a file still fails, Claude gets one follow-up prompt that names the lines. The turn that follow-up starts is never sent back itself, so it cannot loop.
  • Shell edits. A file changed by a shell command (sed -i, a heredoc) is checked when Claude stops, not at the edit. It counts only when all three hold: git shows it as changed, it was clean when your prompt arrived, and a shell command of the turn names it. So your own edits, and files a generator or formatter wrote, are left alone. A new file that a cp, mv, rsync or ln put in place is left alone too: it came with its comments.

Write, Edit and MultiEdit are watched, and so are MCP tools named write, edit, batch_edit or append that take a path or file_path.

Docstrings

Allowed by default. A fixed rule decides what one is:

  • JS, TS, Java and kin: a /** */ block with a declaration on the very next line.
  • C and C++: a /** /, /! */ or /// block with a function, type, macro or top-level variable on the very next line.
  • Rust: /// lines with an item or attribute on the next line, and //! lines anywhere.
  • Go: a // run right above a func, type, var or const, starting with its name.
  • Python: a triple-quoted docstring is a string, not a comment, so it is never read.

A /** */ block over a plain statement, over a local const, or with a blank line under it is not a docstring. It is judged like any other comment.

Options

OptionDefaultWhat it does
maxPlain3Plain comment lines an edit, or a file over one turn, may add. 0 to 20.
allowDocstringsonOff, a docstring is judged like any other comment.
notifyoffOn, a toast tells you when the mod flags comments in an edit or sends Claude back at the end of a turn.

Files it reads

  • // and /* */: C, C++, TS, JS, Rust, Go, Java, Kotlin, Swift, C#, PHP, Scala, Dart, Zig.
  • #: TOML, YAML, Python, shell, Ruby, Perl, R, Elixir, Nix.
  • --: Lua, SQL, Haskell.

Other files are skipped. So are files of installed skills and plugins (~/.agents/skills, ~/.claude/skills, ~/.claude/plugins). When a folder there is a link into a repo you work in, the real file is read.

Limits

  • It is a word test, not a reader. A comment that repeats the code in other words ("fetch the person" over loadUser) reads as plain, not noise.
  • Shell edits are seen only inside a git repository, and only for files a command names. A file edited by a script the command runs is not seen.
  • A trailing comment needs a space on each side of its marker. A marker inside a string is told apart by counting quotes, which a string with an escaped quote can fool.
  • In TOML and YAML a # line inside a multi-line string is read as a comment.
  • In C and C++ a local line that looks like a declaration (Foo bar(1, 2);) lets a fake docstring through.
  • Files over 400,000 characters are not read.

Install

claude plugin install smart-comments@vii-mods
Source 9 files
hooks/register.ts 212 lines
1import type { Hook, Register } from 'claude-code'
2
3import { addedCode, changedRange, crowdAround, freshLines } from '../src/diff'
4import { judgeFile, roomFor } from '../src/judge'
5import type { Judged } from '../src/judge'
6import { FOLLOW_UP, editNote, flaggedIn, turnPrompt } from '../src/note'
7import type { FileReport } from '../src/note'
8import { readOptions } from '../src/options'
9import { absolute, isInstalled } from '../src/paths'
10import { isCopied, mentions, parseStatus } from '../src/shell'
11import { syntaxOf } from '../src/syntax'
12import type { Syntax } from '../src/syntax'
13
14type Engine = Parameters<Hook<'tool.call'>>[0]
15
16const FILE_TOOLS: ReadonlySet<string> = new Set(['Write', 'Edit', 'MultiEdit'])
17const MCP_FILE_TOOL = /^mcp__.+__(write|edit|batch_edit|append)$/
18const SHELL_TOOL = 'Bash'
19// A file past this size is not read.
20const MAX_CHARS = 400_000
21const REALPATH_MS = 2000
22const GIT_MS = 3000
23const MAX_SHELL_FILES = 40
24
25// The file a call writes, as the call spells it, or null for a call that writes none.
26const targetOf = (e: { tool: string }): string | null => {
27  if (!FILE_TOOLS.has(e.tool) && !MCP_FILE_TOOL.test(e.tool)) return null
28  const args = e as { file_path?: unknown; path?: unknown }
29  const path = args.file_path ?? args.path
30  return typeof path === 'string' && path !== '' ? path : null
31}
32
33const textOf = async ($: Engine, path: string): Promise<string | null> => {
34  try {
35    const text = await $.fs.read(path)
36    return text.length > MAX_CHARS ? null : text
37  } catch {
38    return null
39  }
40}
41
42// Whether the file belongs to an installed skill or plugin. A path spelled inside one may be a link
43// into a checkout: it is followed, and the file it leads to decides.
44const isSomeoneElses = async ($: Engine, path: string): Promise<boolean> => {
45  const home = (await $.env.get('HOME')) ?? ''
46  if (!isInstalled(path, home)) return false
47  try {
48    const run = await $.process.run(['realpath', '--', path], { timeoutMs: REALPATH_MS })
49    const real = run.stdout.trim()
50    return run.exitCode !== 0 || real === '' || isInstalled(real, home)
51  } catch {
52    return true
53  }
54}
55
56const git = async ($: Engine, root: string, args: readonly string[]): Promise<string | null> => {
57  try {
58    const run = await $.process.run(['git', '-C', root, ...args], { timeoutMs: GIT_MS })
59    return run.exitCode === 0 && !run.isStdoutTruncated ? run.stdout : null
60  } catch {
61    return null
62  }
63}
64
65// The files git sees as changed, by absolute path, each marked new or not. Null outside a repository.
66type Changed = { root: string; files: Map<string, boolean> }
67
68const changedFiles = async ($: Engine): Promise<Changed | null> => {
69  try {
70    const root = (await $.session.repo())?.root
71    if (root === undefined) return null
72    const out = await git($, root, ['status', '--porcelain', '-z', '--untracked-files=all'])
73    return out === null ? null : { root, files: new Map(parseStatus(out).map(f => [`${root}/${f.path}`, f.isNew])) }
74  } catch {
75    return null
76  }
77}
78
79// A file as it was before Claude wrote it.
80type Before = { syn: Syntax; before: string | null }
81type Touched = Before & { shown: string }
82
83// The file a call is about to write, read before it runs. Null for a call that is not watched.
84const watch = async ($: Engine, e: { tool: string }): Promise<(Touched & { path: string }) | null> => {
85  const shown = targetOf(e)
86  const syn = shown === null ? null : syntaxOf(shown)
87  if (shown === null || syn === null) return null
88  const path = absolute(shown, await $.session.cwd())
89  if (await isSomeoneElses($, path)) return null
90  const before = await textOf($, path)
91  // A file that is there but cannot be read, or is too big, is not a new file: its comments are its own.
92  if (before === null && (await $.fs.exists(path))) return null
93  return { shown, syn, path, before }
94}
95
96type Gained = { all: Judged[]; added: Judged[]; max: number }
97
98// What the turn knows when it ends: its shell commands, the files git saw as changed when its prompt
99// arrived, and the files it wrote through a file tool.
100type Turn = { shell: readonly string[]; start: Changed | null; touched: ReadonlyMap<string, Touched> }
101
102// Files a shell command of the turn named and changed. Only a file that was clean at the prompt counts,
103// so its text in HEAD is its text from before the turn.
104const shellReports = async ($: Engine, turn: Turn, gained: (path: string, b: Before, after: string) => Gained): Promise<FileReport[]> => {
105  if (turn.shell.length === 0 || turn.start === null) return []
106  const now = await changedFiles($)
107  if (now === null) return []
108  const out: FileReport[] = []
109  for (const [path, isNew] of now.files) {
110    if (out.length >= MAX_SHELL_FILES) break
111    const syn = syntaxOf(path)
112    if (syn === null || turn.start.files.has(path) || turn.touched.has(path) || !mentions(turn.shell, path)) continue
113    if (isNew && isCopied(turn.shell, path)) continue
114    if (await isSomeoneElses($, path)) continue
115    const shown = path.slice(now.root.length + 1)
116    const before = isNew ? null : await git($, now.root, ['show', `HEAD:${shown}`])
117    const after = await textOf($, path)
118    if (after === null || (!isNew && before === null)) continue
119    const { added, max } = gained(path, { syn, before }, after)
120    out.push({ path: shown, judged: added, max })
121  }
122  return out
123}
124
125export const register: Register = (on, options) => {
126  const config = readOptions(options)
127  const touched = new Map<string, Touched>()
128  // The shell commands of this turn, and the files git saw as changed when the turn's prompt arrived.
129  let shell: string[] = []
130  let atPrompt: Promise<Changed | null> | null = null
131  let isAsked = false
132
133  // The comments a file gained, judged, with the room its new code earns for plain ones.
134  const gained = (path: string, b: Before, after: string): Gained => {
135    const lines = freshLines(b.before, after, b.syn)
136    const all = judgeFile(path, after, config.allowDocs)
137    return {
138      all,
139      added: all.filter(j => lines.has(j.line) && j.kind !== 'doc'),
140      max: roomFor(config.max, addedCode(b.before, after, b.syn)),
141    }
142  }
143
144  // Every turn of the main loop starts the count again, but the one this mod's follow-up starts: it
145  // keeps `isAsked`, so the turn that cleans up is not sent back itself.
146  on('turn.start', ($, e, next) => {
147    touched.clear()
148    shell = []
149    atPrompt = changedFiles($)
150    if (!e.text.includes(FOLLOW_UP)) isAsked = false
151    return next(e)
152  })
153
154  // The file is read before the call and after it, so every file tool is handled the same way. The call
155  // runs exactly once: a failure of the check on either side leaves its result as it is.
156  on('tool.call', async ($, e, next) => {
157    const command = e.tool === SHELL_TOOL ? (e as { command?: unknown }).command : undefined
158    if (typeof command === 'string') shell.push(command)
159    const file = await watch($, e).catch(() => null)
160    const out = await next(e)
161    if (file === null || out.deny !== undefined || out.isError === true) return out
162    try {
163      const { shown, syn, path, before } = file
164      const after = await textOf($, path)
165      if (after === null || after === before) return out
166      if (!touched.has(path)) touched.set(path, { shown, syn, before })
167
168      const { all, added, max } = gained(path, { syn, before }, after)
169      const crowd = added.length > 0 ? crowdAround(after, all, changedRange(before, after)) : null
170      const note = editNote({ path: shown, judged: added, max, crowd, allowDocs: config.allowDocs })
171      if (note === null) return out
172      if (config.notify) {
173        const flagged = flaggedIn(added, max, crowd)
174        $.ui.toast(`smart-comments: ${flagged} comment line${flagged === 1 ? '' : 's'} flagged in ${shown}`)
175      }
176      return { ...out, context: [...(out.context ?? []), note] }
177    } catch {
178      return out
179    }
180  })
181
182  // When the main loop answers, each file is judged against its text from before the turn. One prompt
183  // for each turn of the user's, so a second miss does not loop.
184  on('turn.complete', async ($, e, next) => {
185    if (e.agentId === undefined && e.reason === 'answer' && !isAsked && (touched.size > 0 || shell.length > 0)) {
186      try {
187        const files: FileReport[] = []
188        for (const [path, t] of touched) {
189          const after = await textOf($, path)
190          if (after === null) continue
191          const { added, max } = gained(path, t, after)
192          files.push({ path: t.shown, judged: added, max })
193        }
194        const start = atPrompt === null ? null : await atPrompt
195        files.push(...(await shellReports($, { shell, start, touched }, gained)))
196        const prompt = turnPrompt(files, config.allowDocs)
197        if (prompt !== null) {
198          isAsked = true
199          if (config.notify) {
200            const failing = files.filter(f => flaggedIn(f.judged, f.max, null) > 0).length
201            $.ui.toast(`smart-comments: sent Claude back to fix comments in ${failing} file${failing === 1 ? '' : 's'}`)
202          }
203          void $.prompt.submit({ text: prompt }).catch(() => undefined)
204        }
205      } catch {
206        // A turn that cannot be checked ends as it would without the mod.
207      }
208    }
209    return next(e)
210  })
211}
212
src/diff.ts 73 lines
1import type { Judged } from './judge'
2import { commentsOf } from './syntax'
3import type { Syntax } from './syntax'
4
5// The lines of `after` whose comment `before` did not have. A comment that only moved is not new; a
6// second copy of one is. `before` is null for a file that did not exist.
7export const freshLines = (before: string | null, after: string, syn: Syntax): Set<number> => {
8  const had = new Map<string, number>()
9  if (before !== null) for (const c of commentsOf(before, syn).list) had.set(c.raw, (had.get(c.raw) ?? 0) + 1)
10  const fresh = new Set<number>()
11  for (const c of commentsOf(after, syn).list) {
12    const left = had.get(c.raw) ?? 0
13    if (left > 0) had.set(c.raw, left - 1)
14    else fresh.add(c.line)
15  }
16  return fresh
17}
18
19const codeOf = (text: string, syn: Syntax): string[] => {
20  const { lines } = commentsOf(text, syn)
21  return text
22    .split('\n')
23    .filter((l, i) => l.trim() !== '' && !lines.has(i + 1))
24    .map(l => l.trim())
25}
26
27// How many lines of code `after` has that `before` did not: blank lines and lines that are all
28// comment are not counted.
29export const addedCode = (before: string | null, after: string, syn: Syntax): number => {
30  const had = new Map<string, number>()
31  if (before !== null) for (const l of codeOf(before, syn)) had.set(l, (had.get(l) ?? 0) + 1)
32  let added = 0
33  for (const l of codeOf(after, syn)) {
34    const left = had.get(l) ?? 0
35    if (left > 0) had.set(l, left - 1)
36    else added++
37  }
38  return added
39}
40
41export type Range = { from: number; to: number }
42
43// The lines of `after`, 1-based, between the head and the tail it shares with `before`. Where lines
44// were only taken out, the one line at the cut.
45export const changedRange = (before: string | null, after: string): Range => {
46  const b = after.split('\n')
47  if (before === null) return { from: 1, to: b.length }
48  const a = before.split('\n')
49  let head = 0
50  while (head < a.length && head < b.length && a[head] === b[head]) head++
51  let tail = 0
52  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
53  const from = Math.min(head + 1, b.length)
54  return { from, to: Math.max(from, b.length - tail) }
55}
56
57export type Crowd = Range & { comments: number; lines: number }
58
59// Lines looked at on each side of an edit.
60const REACH = 10
61const MIN_LINES = 8
62const MAX_SHARE = 0.4
63
64// The stretch around an edit, when more than four in ten of its lines are comments that add little.
65export const crowdAround = (text: string, judged: readonly Judged[], changed: Range): Crowd | null => {
66  const all = text.split('\n')
67  const from = Math.max(1, changed.from - REACH)
68  const to = Math.min(all.length, changed.to + REACH)
69  const lines = all.slice(from - 1, to).filter(l => l.trim() !== '').length
70  const comments = judged.filter(j => j.line >= from && j.line <= to && (j.kind === 'plain' || j.kind === 'noise')).length
71  return lines >= MIN_LINES && comments / lines > MAX_SHARE ? { from, to, comments, lines } : null
72}
73
src/judge.ts 145 lines
1import { commentsOf, extOf, syntaxOf } from './syntax'
2import type { Comment } from './syntax'
3import { hasRuleWord, newness } from './words'
4
5// `keep` costs nothing, `plain` counts against the limit, `noise` trips the check alone, `doc` is skipped.
6export type Kind = 'keep' | 'plain' | 'noise' | 'doc'
7
8export type Judged = { line: number; raw: string; kind: Kind; why: string }
9
10type Verdict = { kind: Kind; why: string }
11
12const MAX_NEW = 1 / 3
13// Code lines under a comment that it is compared with.
14const CONTEXT_LINES = 2
15
16const DIRECTIVE =
17  /^(eslint|tslint|prettier|biome|@ts-|ts-(ignore|expect|nocheck)|istanbul|c8 |v8 |noqa|type: ?ignore|pylint|mypy|pragma|clippy|rustfmt|go:|nolint|shellcheck|safety:|region\b|endregion\b|copyright|spdx|license|todo\b|fixme\b|<reference\b|<amd-|frozen_string_literal|fmt:|ruff:|pyright:|rubocop:|deno-(lint|fmt)|cspell|swiftlint|\+build|@(flow|jsx|generated|vitest-|jest-|vite-))/i
18
19const HISTORY: readonly RegExp[] = [
20  /\b(previously|originally|formerly|anymore|any more)\b|\bno longer\b(?! than\b)/i,
21  // "used to" right after a pronoun is history; after a noun it names a purpose, as in "a table used to dedupe peers".
22  /(^|\b(it|this|that|we|they|which|there|code|function|method) )used to\b|\bused to (be|have)\b/i,
23  /\b(was|were|been|got) (changed|moved|renamed|removed|added|replaced|refactored|rewritten|updated)\b/i,
24  /^(changed|moved|renamed|removed|added|replaced|refactored|updated|switched|migrated|extracted|rewrote|simplified|cleaned up)\b/i,
25  /^fixed\b(?![ -](size|width|length|point|set|number|order|rate|step|seed))/i,
26  /\b(instead of the (old|previous)|the old (code|version|way|implementation|approach|call))\b/i,
27  /\bas (requested|asked|discussed)\b|\bper (the )?(review|request|feedback|user)\b/i,
28  /^now\b|\bnow (uses?|returns?|handles?|supports?|we|it)\b/i,
29  /^new:|^newly (added|introduced|created)\b/i,
30]
31
32const FIGURE =
33  /\d+(\.\d+)?\s?(ms|s|sec|min|h|hz|kb|mb|gb|kib|mib|gib|px|%|x|fps|bytes?|tokens?|chars?|lines?|rows?|items?|entries|times|tries)\b|\d{2,}|\d\.\d|\bO\(/i
34const LINK = /https?:\/\/|#\d+\b|\b[A-Z]{2,}-\d+\b|\bRFC ?\d+|§/
35const REASON =
36  /\b(so that|to (avoid|prevent|stop)|in case|by the time|or else|in order to|as long as|even (if|when|though)|rather than|on purpose|by design|relies on|depends on)\b/i
37
38const CONTRACT =
39  /\b(or (null|undefined)|(null|undefined|none) (when|for|if)|[01]-based|which is (how|why)|so (a|an|the|that|they|it|its|their|each|every|two|there|one|no) )\b|^(exit \d|\$\d|arguments?:)|\bin (bytes|bits|characters|columns|cells|rows|pixels|seconds|milliseconds|[km]ib)\b/i
40
41const judgeOne = (c: Comment, code: string): Verdict => {
42  if (c.shebang || DIRECTIVE.test(c.text)) return { kind: 'keep', why: 'a tool directive' }
43  if (HISTORY.some(r => r.test(c.text))) return { kind: 'noise', why: 'tells the edit story' }
44  const fresh = newness(c.text, code)
45  if (fresh !== null && fresh <= MAX_NEW) return { kind: 'noise', why: 'repeats the code' }
46  if (FIGURE.test(c.text)) return { kind: 'keep', why: 'a measured figure' }
47  if (LINK.test(c.text)) return { kind: 'keep', why: 'a link' }
48  if (CONTRACT.test(c.text)) return { kind: 'keep', why: 'a contract or a consequence' }
49  if (hasRuleWord(c.text) || REASON.test(c.text)) return { kind: 'keep', why: 'a rule or a reason' }
50  return { kind: 'plain', why: 'adds little' }
51}
52
53// A line that declares something a docstring can sit on. A local const, let or var is not one.
54const DECLARES =
55  /^(export|default|declare|async|function|class|interface|type|enum|abstract|public|private|protected|static|readonly|override|get|set|pub|fn|struct|impl|trait|mod|unsafe|func|package|namespace|module)\b|^@\w|^#\[|^[\w$]+\s*(<[^>]*>)?\(.*\)\s*(:\s*[^{]+)?\{$|^[\w$]+\??:\s/
56const TOP_LEVEL_BINDING = /^(const|let|var)\b/
57// A block statement has the shape of a method: a word, brackets, a brace.
58const CONTROL = /^(if|for|while|switch|catch)\b/
59
60const C_FAMILY: ReadonlySet<string> = new Set(['c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'hxx', 'ipp', 'inl'])
61const C_STATEMENT = /^(return|if|else|while|for|switch|case|do|throw|delete|new|goto|sizeof|co_return|co_await)\b/
62const C_DECLARES =
63  /^(struct|union|typedef|template|using|extern|virtual|explicit|inline|constexpr|#\s*define)\b|^(?:[\w:]+(?:<[^>]*>)?[\s*&]+)+[\w:~]+\s*\([^;{]*\)[\w\s]*[{;]?$/
64// C and C++: a variable at the top level. Tested on the untrimmed line.
65const C_GLOBAL = /^(?:[\w:]+(?:<[^>]*>)?[\s*&]+)+\w+(\[[^\]]*\])?\s*(=.*)?;$/
66const GO_NAMED = /^(?:func\s+(?:\([^)]*\)\s*)?|type\s+|var\s+|const\s+)(\w+)/
67
68const declares = (source: string, ext: string): boolean => {
69  // The indent is kept, the line end is not: a CRLF file leaves a carriage return on each line.
70  const line = source.trimEnd()
71  const t = line.trim()
72  if (CONTROL.test(t)) return false
73  if (DECLARES.test(t) || TOP_LEVEL_BINDING.test(line)) return true
74  return C_FAMILY.has(ext) && !C_STATEMENT.test(t) && (C_DECLARES.test(t) || C_GLOBAL.test(line))
75}
76
77// Every comment of a file with its verdict, in line order. Empty for a file whose comments are not read.
78export const judgeFile = (path: string, text: string, allowDocs: boolean): Judged[] => {
79  const syn = syntaxOf(path)
80  if (syn === null) return []
81  const ext = extOf(path)
82  const lines = text.split('\n')
83  const { list, lines: commentLines } = commentsOf(text, syn)
84
85  // The first line under the run of comment lines that `line` is in.
86  const under = (line: number): string => {
87    let n = line
88    while (commentLines.has(n + 1)) n++
89    return lines[n] ?? ''
90  }
91  const below = (line: number): string => {
92    const got: string[] = []
93    for (let i = line; i < lines.length && got.length < CONTEXT_LINES; i++) {
94      const source = lines[i] ?? ''
95      if (source.trim() !== '' && !commentLines.has(i + 1)) got.push(source)
96    }
97    return got.join('\n')
98  }
99  // A docstring sits right on a declaration. In Go it is a plain comment run that starts with the name.
100  const isDoc = (c: Comment): boolean => {
101    if (c.code !== '') return false
102    if (c.doc) return c.raw.startsWith('//!') || declares(under(c.line), ext)
103    if (ext !== 'go') return false
104    let top = c.line
105    while (commentLines.has(top - 1)) top--
106    const name = GO_NAMED.exec(under(c.line).trim())?.[1]
107    return name !== undefined && list.find(x => x.line >= top)?.text.split(/\s/)[0] === name
108  }
109
110  const out: Judged[] = []
111  // The verdict of the line above, when it was kept and its sentence has not ended.
112  let open: { line: number; why: string } | null = null
113  for (const c of list) {
114    if (allowDocs && isDoc(c)) {
115      open = null
116      out.push({ line: c.line, raw: c.raw, kind: 'doc', why: 'a docstring' })
117      continue
118    }
119    let verdict = judgeOne(c, c.code !== '' ? c.code : below(c.line))
120    if (verdict.kind === 'plain' && open !== null && open.line === c.line - 1) verdict = { kind: 'keep', why: open.why }
121    open = verdict.kind === 'keep' && c.code === '' && !/[.!?:]$/.test(c.text) ? { line: c.line, why: verdict.why } : null
122    out.push({ line: c.line, raw: c.raw, ...verdict })
123  }
124  return out
125}
126
127export type Tally = { keep: number; plain: number; noise: number }
128
129export const tallyOf = (judged: readonly Judged[]): Tally => ({
130  keep: judged.filter(j => j.kind === 'keep').length,
131  plain: judged.filter(j => j.kind === 'plain').length,
132  noise: judged.filter(j => j.kind === 'noise').length,
133})
134
135const CODE_PER_PLAIN = 20
136
137// How many plain comments an edit may add: the limit, or one for each twenty lines of new code when
138// that is more. A limit of zero stays zero.
139export const roomFor = (max: number, codeLines: number): number => (max === 0 ? 0 : Math.max(max, Math.floor(codeLines / CODE_PER_PLAIN)))
140
141export const trips = (judged: readonly Judged[], max: number): boolean => {
142  const t = tallyOf(judged)
143  return t.noise > 0 || t.plain > max
144}
145
src/note.ts 72 lines
1import type { Crowd } from './diff'
2import { tallyOf } from './judge'
3import type { Judged } from './judge'
4
5const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? '' : 's'}`
6
7// Line numbers as text, with lines in a row as one range: L1, L3-5.
8const linesOf = (judged: readonly Judged[]): string => {
9  const lines = judged.map(j => j.line)
10  const out: string[] = []
11  for (let from = 0; from < lines.length; ) {
12    let to = from
13    while (lines[to + 1] === lines[to]! + 1) to++
14    out.push(to === from ? `L${lines[from]}` : `L${lines[from]}-${lines[to]}`)
15    from = to + 1
16  }
17  return out.join(', ')
18}
19
20const byFault = (noise: readonly Judged[]): string =>
21  [...new Set(noise.map(j => j.why))].map(why => `${linesOf(noise.filter(j => j.why === why))} (${why})`).join(', ')
22
23const closing = (allowDocs: boolean): string => `A comment should say what the code cannot: a figure, a pitfall or a rule.${allowDocs ? ' Docstrings are fine.' : ''}`
24
25// How many of a file's new comment lines Claude is asked to act on: the noise, and the plain lines
26// when they are over the limit or sit in a crowded stretch.
27export const flaggedIn = (judged: readonly Judged[], max: number, crowd: Crowd | null): number => {
28  const t = tallyOf(judged)
29  return t.noise + (t.plain > max || crowd !== null ? t.plain : 0)
30}
31
32// What is wrong with the new comments of one file, or null when nothing is. `judged` holds the new
33// comments only.
34const fileLines = (path: string, judged: readonly Judged[], max: number, crowd: Crowd | null): string[] | null => {
35  const tally = tallyOf(judged)
36  const noise = judged.filter(j => j.kind === 'noise')
37  const plain = judged.filter(j => j.kind === 'plain')
38  if (flaggedIn(judged, max, crowd) === 0) return null
39
40  const kept = judged.filter(j => j.kind === 'keep')
41  const fine = kept.length === 0 ? '' : `; ${linesOf(kept)} ${kept.length === 1 ? 'is' : 'are'} fine`
42  const out = [`smart-comments: ${plural(tally.keep + tally.plain + tally.noise, 'new comment line')} in ${path}${fine}.`]
43  if (noise.length > 0) out.push(`Delete or rewrite ${byFault(noise)}.`)
44  if (plain.length > max) {
45    const stay = max === 0 ? 'delete' : `keep ${max === 1 ? 'one' : max} of`
46    out.push(`${plural(plain.length, 'line')} ${plain.length === 1 ? 'adds' : 'add'} little, and the limit is ${max}: ${stay} ${linesOf(plain)}.`)
47  } else if (crowd !== null && plain.length > 0) {
48    out.push(`L${crowd.from}-${crowd.to} is crowded, ${crowd.comments} of ${crowd.lines} lines are comments that add little: add no more like ${linesOf(plain)}.`)
49  }
50  return out
51}
52
53export type EditReport = { path: string; judged: readonly Judged[]; max: number; crowd: Crowd | null; allowDocs: boolean }
54
55// The note Claude reads after an edit, or null when the edit's comments pass.
56export const editNote = (r: EditReport): string | null => {
57  const lines = fileLines(r.path, r.judged, r.max, r.crowd)
58  return lines === null ? null : [...lines, closing(r.allowDocs)].join('\n')
59}
60
61// The first line of the follow-up prompt. A turn whose prompt holds it was started by this mod.
62export const FOLLOW_UP = 'smart-comments: comments you added this turn need one more pass.'
63
64export type FileReport = { path: string; judged: readonly Judged[]; max: number }
65
66// The prompt sent when a turn ends with files that still trip, or null when none does.
67export const turnPrompt = (files: readonly FileReport[], allowDocs: boolean): string | null => {
68  const blocks = files.map(f => fileLines(f.path, f.judged, f.max, null)).filter(b => b !== null)
69  if (blocks.length === 0) return null
70  return [FOLLOW_UP, ...blocks.flat(), `${closing(allowDocs)} Change these lines and nothing else.`].join('\n')
71}
72
src/options.ts 16 lines
1export type Config = { max: number; allowDocs: boolean; notify: boolean }
2
3const DEFAULT_MAX = 3
4const MIN_MAX = 0
5const MAX_MAX = 20
6
7export const readOptions = (o: Readonly<Record<string, unknown>>): Config => {
8  const max = o['maxPlain']
9  const docs = o['allowDocstrings']
10  return {
11    max: typeof max === 'number' && Number.isFinite(max) ? Math.min(MAX_MAX, Math.max(MIN_MAX, Math.floor(max))) : DEFAULT_MAX,
12    allowDocs: typeof docs === 'boolean' ? docs : true,
13    notify: o['notify'] === true,
14  }
15}
16
src/paths.ts 17 lines
1// Folders, under the home directory, that hold installed skills and plugins.
2const INSTALLED = ['.agents/skills', '.claude/skills', '.claude/plugins']
3
4// Whether a path, as spelled, is inside an installed skill or plugin. A link is not followed here.
5export const isInstalled = (path: string, home: string): boolean => home !== '' && INSTALLED.some(dir => path.startsWith(`${home}/${dir}/`))
6
7// The path from the root, with `.`, `..` and doubled slashes taken out, so two spellings of one file
8// give one path. A link is not followed.
9export const absolute = (path: string, cwd: string): string => {
10  const parts: string[] = []
11  for (const part of (path.startsWith('/') ? path : `${cwd}/${path}`).split('/')) {
12    if (part === '..') parts.pop()
13    else if (part !== '' && part !== '.') parts.push(part)
14  }
15  return `/${parts.join('/')}`
16}
17
src/shell.ts 37 lines
1// A file `git status` lists as changed. `path` is relative to the repository root.
2export type StatusFile = { path: string; isNew: boolean }
3
4// Reads `git status --porcelain -z`: entries end in NUL, and a rename or copy is followed by its old
5// name. Deleted files are left out.
6export const parseStatus = (out: string): StatusFile[] => {
7  const parts = out.split('\0')
8  const files: StatusFile[] = []
9  for (let i = 0; i < parts.length; i++) {
10    const entry = parts[i] ?? ''
11    if (entry.length < 4) continue
12    const status = entry.slice(0, 2)
13    if (status.includes('R') || status.includes('C')) i++
14    if (status.includes('D')) continue
15    files.push({ path: entry.slice(3), isNew: status === '??' || status.includes('A') })
16  }
17  return files
18}
19
20const nameOf = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
21
22// Whether a shell command of the turn names the file. A file no command names was made by a tool the
23// command ran, such as a generator or a formatter, and is not judged.
24export const mentions = (commands: readonly string[], path: string): boolean => {
25  const name = nameOf(path)
26  return name !== '' && commands.some(c => c.includes(name))
27}
28
29const COPIES = /(^|[\s;&|(])(cp|mv|rsync|ln|git\s+mv)\s/
30
31// Whether a command that names the file copies or moves files. A new file put in place that way
32// came with its comments: nobody wrote them this turn.
33export const isCopied = (commands: readonly string[], path: string): boolean => {
34  const name = nameOf(path)
35  return name !== '' && commands.some(c => c.includes(name) && COPIES.test(c))
36}
37
src/syntax.ts 75 lines
1export type Syntax = { line: string; block: readonly [string, string] | null }
2
3type Family = Syntax & { ext: readonly string[] }
4
5const C_BLOCK = ['/*', '*/'] as const
6
7const FAMILIES: readonly Family[] = [
8  {
9    line: '//',
10    block: C_BLOCK,
11    ext: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'rs', 'go', 'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'hxx', 'ipp', 'inl', 'java', 'kt', 'swift', 'cs', 'php', 'scala', 'dart', 'zig'],
12  },
13  { line: '#', block: null, ext: ['py', 'rb', 'sh', 'bash', 'zsh', 'fish', 'yaml', 'yml', 'toml', 'pl', 'r', 'ex', 'exs', 'nix'] },
14  { line: '--', block: null, ext: ['lua', 'sql', 'hs'] },
15]
16
17export const extOf = (path: string): string => (/\.([a-z0-9]+)$/i.exec(path)?.[1] ?? '').toLowerCase()
18
19// The comment syntax of a file, by its ending. Null for a file whose comments are not read.
20export const syntaxOf = (path: string): Syntax | null => FAMILIES.find(f => f.ext.includes(extOf(path))) ?? null
21
22export type Comment = {
23  // 1-based.
24  line: number
25  text: string
26  // The comment as written, marker included, without the code before it.
27  raw: string
28  // The code a trailing comment shares its line with; empty for a line that is all comment.
29  code: string
30  // Written in docstring form: `/**`, `/*!`, `///` or `//!`.
31  doc: boolean
32  shebang: boolean
33}
34
35// `lines` holds every line that is all comment, the empty lines of a block too.
36export type Comments = { list: Comment[]; lines: Set<number> }
37
38// A marker after an odd count of quotes sits inside a string.
39const evenQuotes = (s: string): boolean => ['"', "'", '`'].every(q => s.split(q).length % 2 === 1)
40
41export const commentsOf = (text: string, syn: Syntax): Comments => {
42  const list: Comment[] = []
43  const lines = new Set<number>()
44  const [open, close] = syn.block ?? ['', '']
45  let inBlock = false
46  let docBlock = false
47  text.split('\n').forEach((source, i) => {
48    const t = source.trim()
49    const push = (body: string, code = '', doc = false): void => {
50      if (code === '') lines.add(i + 1)
51      const clean = body.replace(/^[\s*!/#-]+/, '').trim()
52      if (clean !== '') list.push({ line: i + 1, text: clean, raw: t.slice(code.length).trim(), code, doc, shebang: false })
53    }
54    if (inBlock) {
55      const end = t.indexOf(close)
56      if (end >= 0) inBlock = false
57      push(end >= 0 ? t.slice(0, end) : t, '', docBlock)
58    } else if (open !== '' && t.startsWith(open)) {
59      docBlock = /^\/\*[*!](?!\/)/.test(t)
60      const rest = t.slice(open.length)
61      const end = rest.indexOf(close)
62      if (end < 0) inBlock = true
63      push(end >= 0 ? rest.slice(0, end) : rest, '', docBlock)
64    } else if (t.startsWith(syn.line)) {
65      lines.add(i + 1)
66      if (i === 0 && t.startsWith('#!')) list.push({ line: 1, text: t, raw: t, code: '', doc: false, shebang: true })
67      else push(t.slice(syn.line.length), '', syn.line === '//' && /^\/\/[/!](?!\/)/.test(t))
68    } else {
69      const at = t.indexOf(` ${syn.line} `)
70      if (at > 0 && evenQuotes(t.slice(0, at))) push(t.slice(at + syn.line.length + 1), t.slice(0, at))
71    }
72  })
73  return { list, lines }
74}
75
src/words.ts 64 lines
1// A rough stem: enough for "users" to meet "user" and "caches" to meet "cache".
2const stem = (word: string): string => {
3  let s = word
4  if (s.length > 4 && s.endsWith('ing')) s = s.slice(0, -3)
5  else if (s.length > 4 && s.endsWith('ed')) s = s.slice(0, -2)
6  else if (s.length > 4 && s.endsWith('es')) s = s.slice(0, -2)
7  else if (s.length > 3 && s.endsWith('s') && !s.endsWith('ss')) s = s.slice(0, -1)
8  return s.length > 3 && s.endsWith('e') ? s.slice(0, -1) : s
9}
10
11// The stemmed words of prose or code. A name is split at its humps and underscores.
12export const wordsOf = (s: string): string[] =>
13  (
14    s
15      .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
16      .toLowerCase()
17      .match(/[a-z]{2,}/g) ?? []
18  ).map(stem)
19
20const setOf = (list: string): ReadonlySet<string> => new Set(list.split(' ').map(stem))
21
22const FILLER = setOf(
23  'the an of to in on for and or is are be this that these those it its with from by as at into over out up we our if then so each all any here there will when which whether what who how can may also just very than them they their has have was were been being param return returns',
24)
25
26// Verbs and nouns that say an action happens without saying which.
27const EMPTY = setOf(
28  'get set do does make call use run perform handle check create add update initialize init define declare loop iterate go start begin end put take give helper function method variable value',
29)
30
31const RULE = setOf(
32  'must never always only unless not cannot before after until under below above exceed otherwise because since avoid ensure require assume invariant guarantee careful beware trap gotcha workaround hack race deadlock overflow underflow leak unsafe safety important warning note else',
33)
34
35export const hasRuleWord = (text: string): boolean => wordsOf(text).some(w => RULE.has(w))
36
37// Words a keyword or operator in the code stands for.
38const SPOKEN: readonly (readonly [RegExp, string])[] = [
39  [/\+\+|\+= ?1\b/, 'increment'],
40  [/--|-= ?1\b/, 'decrement'],
41  [/\b(import|require|use)\b/, 'import load'],
42  [/\b(throw|raise)\b/, 'throw raise error'],
43  [/\b(catch|except|rescue)\b/, 'catch error exception'],
44  [/\b(print|println|console\.log|echo)\b/, 'print log output'],
45  [/\b(delete|del)\b/, 'delete remove'],
46  [/\bawait\b/, 'wait await'],
47  [/\bnew\b|\{\}|\[\]/, 'new empty instance'],
48]
49
50const codeWords = (code: string): Set<string> => {
51  const known = new Set(wordsOf(code))
52  for (const [seen, said] of SPOKEN) if (seen.test(code)) for (const w of wordsOf(said)) known.add(w)
53  return known
54}
55
56// The share of a comment's words that the code beside it does not already say: 0 when the code says all
57// of them, 1 when it says none. Null for a comment with no word that could carry meaning.
58export const newness = (text: string, code: string): number | null => {
59  const words = [...new Set(wordsOf(text))].filter(w => !FILLER.has(w) && !EMPTY.has(w) && !RULE.has(w))
60  if (words.length === 0) return null
61  const known = codeWords(code)
62  return words.filter(w => !known.has(w)).length / words.length
63}
64