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
  L42   [restates-code]     Increment the counter
  L67   [verbose]           This function takes the raw payload and, after
  L91   [historical]        Previously this returned a dict
Repo convention: comment the non-obvious why, not the what.
Delete or rewrite these, or explain why each is load-bearing.

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
--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
restates-code comment says what the next line already says
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
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
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
process-leak task numbers, plan references, review chatter
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
banner, section-label, step-marker decorative dividers and Step 1: labels
bare-identifier a lone name that repeats the identifier below it
narrative, meta, emoji chatty asides, comments about comments
conditional-narration short If condition, perform action restatements
code-reference pointers to other code that will rot
verbose plain: over 30 words / at least 3 prose lines; API-doc prose: over 90 / at least 10
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
unreviewed --include-unreviewed only
narration (model) --llm only

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.

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* # —

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

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 95% precision, 85% recall over 121 human labels. 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.

Release files for comment-slop 0.2.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.2.0
File Size Uploaded
comment_slop-0.2.0.tar.gz 258.1 kB Details

Built distribution (wheel)

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

Total release size: 294.9 kB

Release files / comment_slop-0.2.0.tar.gz

Download URL comment_slop-0.2.0.tar.gz
Size 258.1 kB
Tags Source
SHA-256 checksum
How to use checksums
190533458ced766e7be8be563ba3699b3ffc44f6f0894c3a48203aeeef91d384
BLAKE2b-256 checksum
How to use checksums
88a377730187cefcab3675181b9f2ff3254bc4300a2638d8bb4bf7d9fac728bd
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.2.0-py3-none-any.whl

Download URL comment_slop-0.2.0-py3-none-any.whl
Size 36.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b5cc4c82160d3e25a83526974f05d08a0941b1e3ae316c0b07b7a80c89f403fe
BLAKE2b-256 checksum
How to use checksums
53cf7ebd840ab7d056102e56d5b4ee236390f15a316ed85920a22713638d9e67
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.1

2 release files

0.1.0

2 release files

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