Skip to main content

comment_slop.py

A write-time hook that reports comment slop on the lines you just changed.

Detection only — it never edits a source file, and by default it never calls a model. It runs as a PostToolUse hook in Claude Code, so the report goes back to the agent that wrote the file, which is the last point where anyone still knows whether a comment was load-bearing.

comment-slop: backend/src/pipeline.py
  L3    delete  [restates-code]     Increment the counter
  L5    shorten [verbose]           This function takes the raw payload and, after checking that
  L9    delete  [historical]        This used to return a dict
  L11   shorten [historical]        cut: It previously sent one per row.
Repo convention: comment the non-obvious why, not the what.
delete: nothing in it is worth keeping. shorten: keep its fact in fewer words,
without what `cut:` names. Or explain why a comment is load-bearing.

Each finding says what to do with the comment: delete it, or keep its fact in fewer words. See Delete or shorten.

Install

uv add comment-slop           # or: pip install comment-slop

giving you a comment-slop command and python -m comment_slop. It has no dependencies and none are planned: the hook falls back to whatever python3 is on PATH, so an import is a way for a broken environment to break the edit loop.

Installing is optional. The engine is one stdlib-only module, so vendoring src/comment_slop.py and running it with any Python 3.11+ works identically. src/comment_slop_llm.py beside it adds --llm; without it, --llm warns and reports the deterministic findings alone.

As a Claude Code hook

Register it as a PostToolUse hook on Edit|Write. Installed, the command is the console script:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "comment-slop --hook" }]
      }
    ]
  }
}

From a checkout or a vendored copy, register the wrapper beside the source instead — "command": "sh path/to/comment-slop/run_hook.sh", directly or through a symlink. It finds the detector relative to itself, runs it under uv when that is available (reading the version from your project's .python-version), and falls back to python3. If the detector is missing it exits 1, a non-blocking hook error, rather than reporting a clean edit.

Exit 2 means findings, 0 means clean, and any internal failure also exits 0 — the hook must never break the edit loop. The wrapper exists because every interpreter also exits 2 for its own failures, which the hook protocol would show the agent as findings; it re-derives the code from the output instead. An installed console script has its interpreter fixed at install time, so it needs no wrapper. See docs/internals.md.

With no git information it scans the whole file; inside a repo it scans only lines changed since HEAD, so it reports on what you just wrote rather than on the file's history.

As a commit gate

The write-time hook sees Edit and Write, not Bash: an agent editing with sed -i or a heredoc is never checked. A pre-commit hook closes that, because everything reaching a commit passes through it, whoever or whatever wrote it.

In CI, pass --since the merge-base with the target branch:

comment-slop --since "$(git merge-base origin/main HEAD)" <paths>

A clean CI checkout matches HEAD everywhere, so without --since the gate scans nothing and passes. Against the branch tip instead, git diff is two-dot, and a branch behind main is blamed for every line main gained since the fork. An unresolvable --since ref scans nothing rather than everything, so fail the job if the ref is missing.

Usage

Run it on a file, a directory, or a glob:

comment-slop backend/src/foo.py
comment-slop --all 'frontend/src/**/*.ts'
comment-slop --all backend/src

Quote a glob: the tool expands ** itself, because whether a shell recurses on a bare ** depends on the shell. Directories skip node_modules, target, dist and the rest of the build output. Paths git reports as unchanged are dropped before scanning; untracked files count as changed.

flag effect
--hook read the file path from hook JSON on stdin; bounds the model sweep by wall clock
--since REF compare against REF instead of HEAD, and scan only the files REF changed
--all report every line, not only those changed since HEAD. Required to sweep committed code — without it a clean tracked file reports nothing
--include-unreviewed also report every short comment no tier caught. High recall, low precision; for a deliberate sweep, never the hook
--strict report every comment the change adds; see Strict mode. COMMENT_SLOP_STRICT=1 sets it where no flag can be passed
--llm add the model narration sweep. Exploratory; see docs/llm-sweep.md

A sweep of already-committed code is therefore --all --include-unreviewed <paths>; the hook itself passes neither.

What it reports

tier catches fix
restates-code comment says what the next line already says, or names the definition it sits on: Cleanup function over cleanup() { delete
edit-narration narrates a completed change (Fixed: skip None entries). Past tense and the verb: changelog prefix only — an imperative opening is how a docstring summary describes the code cut the Fix: label, or the sentence narrating the edit
historical what the code used to do — needs a pronoun subject (this used to …), since the pattern used to build X is a contract, not history. A date inside a timestamp, a path or a dotted value is a format example cut the sentence
unbuilt code that never reached main — first-person edit narration (we removed the retry wrapper) and a road not taken (tried a shared cache but it did not help). Both belong in the PR, whose branch git log cannot resolve once it is gone cut the sentence
process-leak task numbers, plan references, review chatter cut the reference
benchmark a measured performance snapshot (~144x fewer buffers, 98.2% smaller, measured 812ms). The unit is the discriminator, not the digit, and a cost that carries an argument (costs 1.3 ms/file, because …) stays silent cut the sentence
banner, section-label decorative dividers and section labels. A label over a group of statements it names, State over six fields, is a heading and stays delete
step-marker Step 1: and 1. labels cut the number
bare-identifier a lone name that repeats the identifier below it, not one naming a group of statements delete
narrative, meta, emoji chatty asides, comments about comments cut the aside
conditional-narration short If condition, perform action restatements delete
code-reference pointers to other code that will rot cut the pointer
verbose plain: over 30 words / at least 3 prose lines; API-doc prose: over 90 / at least 10. Formula, matrix, worked-arithmetic and table rows spend neither the word nor prose-line budget shorten
density a changed region that is more comment than reference practice: 30% by default, 40% for shell, whose scope is the whole script rather than a function shorten
unreviewed --include-unreviewed only delete
strict --strict only: any comment the change adds delete, or cut the added lines
narration (model) --llm only delete

A trailing comment is exempt from the tiers whose signal is terseness — restates-code, section-label, bare-identifier, narrative, process-leak, historical, unbuilt and conditional-narration — because it annotates the value beside it: timeout = 5 # seconds. Edit narration, banners, Step 3: markers, meta, emoji, rotting file.ts:120 pointers, benchmark snapshots and length still fire on one. Density counts a line carrying both code and a comment as a code line.

Doc comments are judged on the prose the author chose — the narrative before the first @tag, rustdoc section or numpydoc header, plus every free-text section (Notes, Warnings, @remarks) — and sit on neither side of the density ratio. A parameter list is as long as the signature and an example as long as the code it shows, so neither spends the budget.

Delete or shorten

A finding asks for one of two fixes, and they are not interchangeable: deleting a comment that carries a fact loses the fact, and shortening one that carries nothing keeps it.

  • delete: nothing in the comment is worth keeping.
  • shorten: it carries a fact, in more words than the fact needs. Where a tier objected to one part, the row shows that part as cut:.

A tier whose fix above is cut objects to a part of the comment. It asks to shorten when what is left stands as a comment, and to delete when it does not. What is left stands if no tier would delete it and it has more than CUT_REST_WORDS (8) distinct content words. Fewer is a label or a narrated step, not a fact: under Step 6:, Detect connected components goes with it. A docstring's rest needs only a word, since there it is the summary line.

A docstring is never deleted from under its Parameters, Returns, @param or other prescribed sections. The tiers read only its prose, so where those follow, a finding that would delete the docstring asks to cut the prose instead.

Strict mode

--strict reports every comment a change adds, whatever the tiers make of it. Two kinds stay allowed. A Python docstring, which the tiers still judge. And a comment a tool reads: a lint directive (# noqa, // eslint-disable-next-line, # shellcheck disable=, # yamllint disable, # hadolint ignore=), a Rust // SAFETY:, a Python encoding declaration, a YAML # yaml-language-server: line, a Dockerfile # syntax=, a pinned action's # v4.1.1, since code can need one to pass a check. JSDoc, rustdoc and #: are comments, and so is an empty # or // on its own, though not a blank line inside a comment. A changelog entry is not one, and keeps its own tiers.

The hook and the commit gate take no flags, so COMMENT_SLOP_STRICT=1 in the environment turns it on: start the agent with it set (COMMENT_SLOP_STRICT=1 claude) for a strict session, or commit with it set (COMMENT_SLOP_STRICT=1 git commit) for a strict gate. Empty or 0 is off.

A comment line counts as added when the change touched it and the file now has more comment lines with its text than it had at the comparison ref. Reindenting a block, moving a comment within the file or editing the code beside it adds nothing, so strict never asks for someone else's comment to go, while a second copy pasted in with the code under it is added. Where a comment only gained lines, the finding asks to shorten it, with the new lines as its cut:. A file git reports as renamed is compared with its old path, so a rename adds nothing, but a comment moved in from another file, such as a module split in two, counts as added.

The change is every line since HEAD, as it is without strict, not only the agent's. Commit comments of your own before a strict session, or the agent is told to remove them.

Changelogs

A CHANGELOG.md entry is held to PyBaMM's guideline: a single bullet ending in its PR link. The tiers above would read an entry's Added … and previously … as slop, so an entry takes three of its own instead:

tier catches fix
verbose over 40 words, counting a link's text but not its URL shorten
multi-line a wrapped entry, or one carrying a nested list rewrite
pr-link an entry that does not end in ([#123](https://github.com/owner/repo/pull/123)) or a bare (#123), or whose #123 is not the number it links. A GitLab [!123](…/-/merge_requests/123) counts rewrite

The bare (#123) is for a changelog published outside its repo, such as a public mirror of a private monorepo, where a link would point readers at a page they cannot open.

As everywhere, only the entries a change touches are judged, so adopting it does not report a changelog's history.

Languages

language extensions plain doc
Python .py .pyi #, #: docstrings
JS/JSX/TSX .js .jsx .mjs .cjs .tsx //, /* */ /** */
TS (no JSX) .ts .mts .cts //, /* */ /** */
YAML .yml .yaml # —
TOML .toml # —
Rust .rs //, /* */ /// //! /** */ /*! */
Shell .sh .bash .zsh .ksh # —
Dockerfile Dockerfile, Dockerfile.*, *.dockerfile, Containerfile* # —
Changelog CHANGELOG.md top-level list items —

.ts is its own row because TypeScript forbids JSX there, so <Foo>x stays a type assertion. Languages are recognised by path only, so an extensionless #!/usr/bin/env bash script is not scanned.

Tuning

When the hook reports something deliberate, prefer widening NEVER over narrowing a tier in TIERS. NEVER is the documented escape hatch and already covers lint directives (noqa, eslint-, shellcheck, …), TODO/SAFETY/ NOTE, licences, URLs and issue references. An exempt line does not merge with its neighbours, so a new NEVER alternative silences only the comment it matches. The exception is a sentence that runs through the exempt line: that is one comment, and it is exempt whole. A line opening with a completed edit (fixed the cache logic) continues nothing, unless the line above ends in an auxiliary (is, or is never), a subject pronoun or a coordinator (salted and): under a TODO with no period it would otherwise read as that marker's predicate.

The intent markers match uppercase only, because each is also an ordinary English word: BUG: broken is exempt and a real lexer bug sat here is not. The issue key (ABC-123) must be shouted too, or us-east-1 and theta-1 read as one; a new alternative that can match a lowercase word-digits token needs the same (?-i:...) treatment. UTF-8 and SHA-256 have the key's shape and are excluded by name, and #123 needs a word boundary so #020E0D is a colour.

Thresholds live at the top of comment_slop.py and are calibrated rather than guessed — see docs/calibration.md before changing one. A language's own facts, from its extensions to its density ceiling, are one Lang entry in LANGUAGES.

Tests

uv run --extra dev python -m pytest tests
COMMENT_SLOP_ORACLE_STRICT=1 uv run --extra dev --extra oracle \
    python -m pytest tests/test_oracle_differential.py

The second is the tree-sitter differential, which checks the hand-written lexers against real grammars over real files. Run it before touching any lexer; docs/internals.md covers why it is kept apart and why it reads every file rather than a sample.

Current deterministic accuracy is 91% precision, 77% recall over 121 human labels, counting a finding only when it asks for the fix the label names. As a flag on a comment the labeller would change, whatever the fix, it is 100% and 85%; docs/evaluation.md has the gap. Precision is the figure that matters: a false positive spends the author's trust, a miss costs nothing.

Docs

file contents
docs/internals.md pipeline, the hook wrapper, comment kinds, span semantics, how to verify a lexer change
docs/calibration.md every threshold and where it came from; firing rates
docs/evaluation.md the labelled corpus, sampling channels, dogfood window
docs/llm-sweep.md the --llm sweep: model and prompt measurements, why it is off by default

Provenance

Originally developed at https://github.com/BradyPlanden/claude-comment-hook and vendored into the Ionworks monorepo at 20d7caf. Development continues there and the two have diverged; this package is built from that copy, which is the source of truth.

Released under the MIT licence — see LICENSE.

Metadata

Release files for comment-slop 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for comment-slop 0.3.0
File Size Uploaded Provenance
comment_slop-0.3.0.tar.gz 299.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for comment-slop 0.3.0
File Interpreter ABI Platform
comment_slop-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 349.5 kB

Release files / comment_slop-0.3.0.tar.gz

Download URL comment_slop-0.3.0.tar.gz
Size 299.7 kB
Tags Source
SHA-256 checksum
How to use checksums
ed15dd98ed6e05f46fe1f3e111e97f6adb348571ca6915854de5ac1c62735e6a
BLAKE2b-256 checksum
How to use checksums
2b61769e1ccf04e6e180f83c8ad0cb7ad744ff803d11e0adcec2ae1e72858621
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / comment_slop-0.3.0-py3-none-any.whl

Download URL comment_slop-0.3.0-py3-none-any.whl
Size 49.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
11055f60b5e87420ccf41def50fe7c06477eb897ede93d56cb66168303e550b1
BLAKE2b-256 checksum
How to use checksums
839c927cc341dfda845ef999c3af0f973daa7e75c5119d0340fc3cf6a9bfe34e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Security for comment-slop 0.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page