shurrup
Shurrup is a linter for commits, commit messages and documentation. Keep cruft clear of your coveted code by clobbering Claud with countless conventions.
Written in glorious Python, if I wanted Rust I'd drive a Toyota Tacoma.
Install
uv tool install shurrup
Use
shurrup . # everything it can read below here
shurrup src --format full # with the offending line quoted
shurrup src --statistics # counts by code
shurrup src --limits # how close the prose sits to each limit
shurrup --explain SHC001 # what one code means
shurrup --list-rules
shurrup . --write-baseline .shurrup-baseline.json # record what is already there
shurrup . --baseline .shurrup-baseline.json # report only what is new
shurrup --commits # commit messages not yet pushed
shurrup --commits origin/main..HEAD # or any range git understands
A commit message is prose about code, so the phrasing and length rules read it the same way. The subject is one block, each paragraph of the body another, and lines wrap at 72. SHC001 stands aside: describing a change is what a message is for.
Files .gitignore covers are skipped. Turn that off with respect-gitignore = false.
Exit code is 1 for an error, 0 for warnings alone. --strict fails on warnings too. Formats are concise, full, github (annotations, so a run in Actions marks up the diff) and json.
How it fits together
An extractor is per language. It takes source text and returns Comments: where the comment was, which delimiter opened it, and the text with the delimiters stripped. What a language can hide a comment inside is the hard part, so each extractor sits on a parser for that language:
| Language | Parser | What it has to get right |
|---|---|---|
| Python | tokenize, ast |
A string is a docstring by where it sits, and ast settles that |
| C, C++ | tree-sitter | String, character and raw literals, digit separators, // continued over a backslash-newline |
| JavaScript, JSX | tree-sitter | Regex literals against division, template holes that nest, markup mid-expression |
| HTML | tree-sitter | <!-- -->, plus <script> and <style> handed on with their positions shifted |
| CSS | tree-sitter | /* */ only, since a stylesheet reads // as a value |
| YAML | tree-sitter | Block scalars, plain scalars holding an apostrophe, anchors |
| TOML | tree-sitter | # with no space in front of it, and the three string forms |
| INI | tree-sitter | ; as well as #, and a trailing ; belonging to the value |
| shell | tree-sitter | Heredocs, ${#array[@]}, and a hashbang that is a directive |
| commit messages | git log |
The subject, then each paragraph of the body |
| Markdown | markdown-it-py |
Fences of any length, raw HTML blocks, link targets holding a bracket; each paragraph, heading, list item and table row is one block. A row is a list of cells, so the sentence limits leave it alone |
The core merges runs of line comments into one block, so a paragraph written as six # lines is measured as a paragraph. An empty comment line ends a paragraph, so a file header carrying a summary and two notes is three units. The core also attaches the code each comment appears to document.
A rule is per family, and sees the language only as a field on the comment. Rules run at three scopes: one comment, one file (whether that file mixes two styles), or every file at once (the same block pasted into six of them).
Severity
Codes are grouped by family, so --ignore SHP drops all the phrasing rules at once. A ? marks a warning.
An error is a shape with no good reading in a code comment: a hedge, a comment describing a change nobody can check, commented-out code. A warning is a tic, defensible once and a problem in quantity.
Budgets
A single rather than is fine and forty in one file is not, so the tic rules carry a per-file budget. Under it they report nothing. Over it they report once:
src/thing.py:1:1: error SHP013 9 blocks in this file (budget 3); keep the clause only
where a reader would have assumed the alternative (rather than)
Forty findings naming a word you can grep for is a wall. One finding naming the count is the work. Set [rules.SHP013] budget = 0 or budget = false for every site.
The collapsed finding carries the guidance the individual ones did, and the phrase stays in the excerpt. A message naming only the phrase reads as a blocklist, and the fix that follows from it is a synonym, which moves the count to a neighbouring rule and leaves the writing untouched.
No phrase is counted at two levels. A tic with a per-instance rule reports through that rule's budget; SHM measures only the habits with no rule of their own.
SHL, length
Measured against the code a comment documents where there is code to measure against, and against a flat limit elsewhere.
| Code | What it catches | |
|---|---|---|
| SHL001 | More words of comment than the code beneath it accounts for | |
| SHL002 | A comment line past the configured width | |
| SHL003 | A trailing comment long enough to belong on its own line above | |
| SHL004 | ? | A docstring of fewer words than the configured floor |
| SHL005 | A comment with no text in it | |
| SHL006 | A paragraph past 60 words (40 warns), or a docstring paragraph past 80 | |
| SHL007 | A paragraph past 6 lines | |
| SHL008 | ? | Timings and byte counts in a code comment, with no test named |
| SHL009 | A sentence past the word limit | |
| SHL010 | A sentence carried by more commas than the limit allows | |
| SHL011 | A document longer than anyone reads in one sitting |
Limits differ by register. Measured over a 4000-sentence corpus, code comments reach 37 words at the 95th percentile and markdown reaches 51, so markdown gets its own defaults for SHL006, SHL007, SHL009 and SHL010. Set them per language with [rules.SHL009.markdown].
Where there is code beneath to measure against, SHL001 takes the ratio; where there is not, SHL006 takes the flat limit. Only one of the two ever fires.
A docstring is measured by its longest paragraph, since the size of the thing it documents sets how much there is to say. Measured over 1490 docstrings the longest paragraph reaches 81 words at the 99th percentile, so 80 is the limit. SHL007 does not read docstrings.
Tables and worked examples inside a comment are excluded from all of these. Measured as a sentence, a key description block becomes one enormous run-on.
SHY, style
Rules that could go either way take the majority within a file as the standard and object to the minority. A codebase settled on // gets told about the stray /* */.
| Code | What it catches | |
|---|---|---|
| SHY001 | Two spellings of the same single-line comment in one file | |
| SHY002 | A docstring not written with triple double quotes | |
| SHY003 | A row of decoration in place of a sentence | |
| SHY004 | No space between the delimiter and the words | |
| SHY005 | ? | One short line in a block comment |
| SHY006 | Trailing whitespace |
SHC, content
| Code | What it catches | |
|---|---|---|
| SHC001 | Describes what the code was, which the reader has no way to check | |
| SHC002 | Code kept as a comment | |
| SHC003 | A marker with no name and no ticket on it | |
| SHC004 | ? | Restates in words what the line below expresses in code |
| SHC005 | A disabled line parked with a note about why | |
| SHC006 | ? | Reports how an approach that is not in the file behaved |
SHC001 needed the most care. Three signals mark a comment documenting a change:
- tense: "previously", "used to", "formerly", "no longer". A past the reader cannot see.
- verbs: "renamed", "moved", "replaced", "reverted" applied to the code itself. Those describe an edit to the file, where a comment should describe what the file does.
- reference: "since v2.1", "as of 2024-03", "fixes #412". A point in the history.
Each turns up in good comments too. "Now" reads plainly in "now that the buffer is full". So the signals are scored, and a comment is flagged at one strong signal or two weak ones, which keeps it quiet enough to leave switched on.
SHC006 catches the near neighbour SHC001 misses. A design note about a road not taken carries no temporal marker. The signal is a past-tense verb of outcome attached to an alternative absent from the file.
SHP, phrasing
Patterns over English, so approximations. Each catches a shape that is nearly always a mistake in a code comment, even where it would pass in prose.
SHP005 and SHP032 are tested from the verb, not the subject. The set of things a comment might personify never closes; the set of things that legitimately have a mind does. So the verb is matched first and the subject checked against that set. A scanner genuinely scans and a thread genuinely sleeps, so those verbs are absent; a file cannot spell, because whoever wrote it did the spelling.
| Code | What it catches | |
|---|---|---|
| SHP001 | its own, the theme's own used as emphasis |
|
| SHP002 | simply, obviously, of course |
|
| SHP003 | note that, it should be noted, a sentence opening on Actually, |
|
| SHP004 | basically, essentially, literally |
|
| SHP005 | The parser knows, the page wants, the epicentre breathes |
|
| SHP006 | magic, clever, hacky, nasty, gorgeous, beautifully |
|
| SHP007 | knobs, levers |
|
| SHP008 | we should, you need to |
|
| SHP009 | !!, ?! |
|
| SHP010 | for now, temporarily, placeholder |
|
| SHP011 | as you can see, needless to say |
|
| SHP012 | this function is, this class does |
|
| SHP013 | rather than |
|
| SHP014 | ? | in order to, due to the fact that |
| SHP015 | instead of |
|
| SHP016 | is what the, which is where |
|
| SHP017 | that being, being what |
|
| SHP018 | reads as, read as |
|
| SHP019 | on purpose, deliberately, by design |
|
| SHP020 | Em dashes, curly quotes, ellipsis characters, non-breaking spaces | |
| SHP021 | Two spaces after a sentence | |
| SHP022 | Emoji | |
| SHP023 | ? | worth a, only worth saying |
| SHP024 | - standing in for an em dash |
|
| SHP025 | ? | nothing, never: defining a thing by exclusion |
| SHP026 | ? | A block opening How/What/Where with no main clause |
| SHP027 | A block opening And/But, continuing the comment above |
|
| SHP028 | ? | somebody chose, someone switches: a decision credited to nobody in particular |
| SHP029 | ? | dear for expensive, furniture for chrome, whilst |
| SHP030 | the whole <thing> |
|
| SHP031 | more prose than the code can carry.: a transitive verb with its object dropped |
|
| SHP032 | a file spells, a phrase says: a written thing given its author's agency |
|
| SHP033 | honest, story, narrative |
|
| SHP034 | That is all it does, and nothing more |
|
| SHP035 | A block opening on a bare It, This or They |
|
| SHP036 | a .mpy, a HTTP: the article against the sound that follows |
|
| SHP037 | ? | which is what, where which alone says it |
| SHP038 | ? | handled apart from these, where apart from means except for |
| SHP039 | ? | taken off, come off, based off: a phrasal verb one word replaces |
| SHP040 | ? | Two ands in one sentence, where the first should be a comma |
| SHP900 | A phrase this project configured |
SHP001 declines the separative senses, where own marks a thing held apart: on its own line, in its own thread. Everywhere else it reads as emphasis. That test is blunt, declining about 10% of a 439-instance corpus, so the rule is a warning and its budget carries the density. Extend the set with [rules.SHP001] allow = [...].
SHM, per-file measures
Some phrasing is fine once and a tic by the twentieth time, which one comment can never show. These are measured as a proportion of the file's blocks and reported once, at the top, with the count.
| Code | Default limit | What it measures |
|---|---|---|
| SHM002 | 30% | Blocks ending on "so the ..." |
| SHM003 | 40% | Blocks pivoting on a colon |
SHD, duplication
| Code | What it catches |
|---|---|
| SHD001 | The same block of prose in more than one file |
The one rule that needs every file at once. A block appearing verbatim in six files was pasted, and pasted prose goes stale in five places at once. Matching is on the words, with case, punctuation and wrapping removed, so a block rewrapped to a different width still counts. Blocks under 12 words are skipped, as is licence boilerplate.
Settings
shurrup.toml, .shurrup.toml, or a [tool.shurrup] table in pyproject.toml, whichever turns up first at or above the path being checked.
select = ["SH"]
ignore = ["SHP014", "SHM"]
exclude = ["build", "vendor"]
# Any rule's severity and options.
[rules.SHL002]
max_length = 100
[rules.SHL006]
max_words = 60
warn_words = 40
[rules.SHP013]
severity = "warning"
[rules.SHP001]
allow = ["line", "thread", "namespace"]
[rules.SHM003]
max_ratio = 0.4
min_blocks = 20
# Prefixes match, so this drops every phrasing rule in one file.
[per-file-ignores]
"tests/fixtures/*" = ["SH"]
"src/legacy/*.c" = ["SHP", "SHL006"]
# House style, the short way.
[prefer]
knob = "setting"
utilise = "use"
# House style, the long way.
[[phrases]]
code = "SHP901"
pattern = "\\b(kick off|fire off|spin up)\\b"
message = "say what it starts"
severity = "warning"
languages = ["python", "javascript"]
An option can be set for some files only, which keeps a weakened check where an ignore would give up the rule entirely:
# A generated API surface writes one member per line, and a signature alone reaches 225
# characters. The other files keep the 96 the rest of the project uses.
[per-file-rules."api/*.py".SHL002]
max_length = 240
Every phrase rule takes an allow, for vocabulary a project cannot avoid: trivially copyable is the C++ standard's own adjective, and magic is what a file's magic number is called.
[rules.SHP002]
allow = ["trivially"]
Options for each rule are listed by shurrup --explain <code>. --select on the command line replaces the file's list; --ignore adds to it.
Checking the settings themselves
A lint run reports what failed and says nothing about what passed, so a file one word under a limit and a file half its length look the same. --limits reports the distribution beside the limit:
rule measures limit default n p50 p95 max headroom
SHL002 python comment line length 96 1042 80 94 96 0%
SHL011 markdown document words 3000 2 21 2592 2592 14%
SHP013 python rather-than a file 3 36 0 0 1 67%
Two things to read off it. A limit far from its default is doing less than the rule list suggests. Headroom near zero means the next paragraph written fails, and where several rules sit there at once the limits are likelier to have been fitted to the prose than the prose to the limits.
Rows with too little behind them to have a distribution are left out, as are budgets nothing has spent. Fixing a finding normally lands well under a limit, since a 45-word sentence splits into two 22-word ones, so landing repeatedly just under is worth a look. It is a prompt and not a verdict.
Starting on an existing codebase
Turning the phrasing rules on across a repository that has never seen them produces hundreds of findings, none of which is a regression. Record them and work from there:
shurrup . --write-baseline .shurrup-baseline.json
shurrup . --baseline .shurrup-baseline.json
A finding is fingerprinted by file, code and matched phrase, never by line, so editing the top of a file leaves the rest of the baseline intact.
Suppressing one comment
A directive in the comment itself silences its own lines and the first line of code below it. The directive is stripped before any rule runs, and is never itself reported.
# Kept for the API, which we do not control. shurrup: ignore[SHC001]
Bare shurrup: ignore silences everything on those lines. Prefixes work: shurrup: ignore[SHP].
Extending it
Both halves attach through entry points, so a project can add a language or a house style without touching this package.
[project.entry-points."shurrup.languages"]
rust = "my_pack.rust:extract"
[project.entry-points."shurrup.rules"]
house = "my_pack.rules:register"
An extractor is a callable taking source and returning Comments, with a SHURRUP_EXTENSIONS attribute listing the extensions it handles. Plugins take precedence over builtins, so a project can replace one.
A rule pack's register takes no arguments, and applies the @rule decorator to what it brings:
from shurrup.rules import Finding, rule
def register():
@rule("XYZ001", "no-latin", severity="warning")
def no_latin(comment, context, settings):
"""Latin abbreviations a reader has to expand."""
if re.search(r"\b(e\.g\.|i\.e\.|etc\.|viz\.)", comment.text):
yield Finding("spell it out", excerpt="e.g.")
Rules yield Findings. Position defaults to the start of the comment, so a rule without a more precise one need not work one out.
As a library
from shurrup import lint
for problem in lint(["src"]):
print(problem.code, problem.path, problem.line, problem.message)
shurrup.core.read(path) returns a Context if extraction is all you want.
Notes
The phrasing rules are opinionated. The defaults follow one house style, and ignore and severity are there because yours will differ. Start with --statistics to see which rules fire before turning any of them into errors.
SHP020 bans the em dash and SHP024 bans the spaced hyphen that usually replaces it. Together they leave no dash at all, a policy a project has to choose, so SHP024 ships off. Turn it on with [rules.SHP024] severity = "error" once the dash question is settled. Any rule can be switched off the same way.
Words are matched without any notion of what they refer to, so a word earns its place by measurement. SHP007 asks for "settings" in place of knob and lever, which name a setting in most of their uses. dial is absent because its 70 uses across five codebases were rotary controls, gauge rendering and telephone numbers. actually is absent from SHP004 because it corrects a claim where a hedge weakens one: over 93 instances in four codebases, 96% marked observed behaviour against assumed, as in "the bytes actually read". SHP003 has the sentence-opening case, where it carries nothing.
SHP005, SHP031 and SHP032 turn on grammar. Whether a verb has a subject, whether that subject is animate, whether a transitive verb has an object. A pattern answers those by listing every word that may sit between a subject and its verb, and that list never closes. The optional nlp extra puts a parse behind them:
pip install "shurrup[nlp]"
python -m spacy download en_core_web_sm
Measured over 45,000 comment blocks from the standard library, the parse reports about 30% fewer than the pattern, and drops a class of nonsense subject: a pattern reads Don't think as the word t thinking. Neither path is exact, so the extra is opt-in and every rule falls back to its pattern when the model is absent.
The JavaScript regex-against-division heuristic is the standard one (what the previous significant token was) and it can be fooled. A mis-scan costs a missed comment or a spurious one. JSX expressions inside markup are left unparsed.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shurrup-1.2.0.tar.gz.
File metadata
- Download URL: shurrup-1.2.0.tar.gz
- Upload date:
- Size: 173.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.9.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
241e5d5dcce8b79a04a94d7649fe667d7f3ce127339133ea340d0315c93c3aef
|
|
| MD5 |
6675238e76c17342519f01eab9132c87
|
|
| BLAKE2b-256 |
46d140c01494dad8a5af38b3b869730628bd298745f7f6615a49221956e1b399
|
File details
Details for the file shurrup-1.2.0-py3-none-any.whl.
File metadata
- Download URL: shurrup-1.2.0-py3-none-any.whl
- Upload date:
- Size: 78.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.9.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bdc3cd87576241b7d4d4d52ba4b3c72ddb532cb140a24ea53c96011a3277df14
|
|
| MD5 |
ef99ab785723731dab9f6a03117f2557
|
|
| BLAKE2b-256 |
9efa3aab8b9b9031d3fab267b0e7b8da35cf1c1aa06f8f16b670f82052b7d9a3
|