Skip to main content

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.

Set SHL002 to whatever already formats the project before reading anything else. A repo on prettier's printWidth: 100 produced 323 findings on a first run, every one of them the formatter's output, which is the largest single source of noise on a codebase shurrup has not seen.

[rules.SHL002]
max_length = 100

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

shurrup-1.2.1.tar.gz (174.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

shurrup-1.2.1-py3-none-any.whl (79.2 kB view details)

Uploaded Python 3

File details

Details for the file shurrup-1.2.1.tar.gz.

File metadata

  • Download URL: shurrup-1.2.1.tar.gz
  • Upload date:
  • Size: 174.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.2

File hashes

Hashes for shurrup-1.2.1.tar.gz
Algorithm Hash digest
SHA256 b7f23b6d97b11b8f4138beaf46080670d867dfb9a0b9d9e64bd8bcdb9d52e499
MD5 dc0f6b6d72175cbfb512dc405013548f
BLAKE2b-256 02f98903e079c2b6a4bd1a2f943fea6ed412778cb128fded72cb110d22d853b6

See more details on using hashes here.

File details

Details for the file shurrup-1.2.1-py3-none-any.whl.

File metadata

  • Download URL: shurrup-1.2.1-py3-none-any.whl
  • Upload date:
  • Size: 79.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.2

File hashes

Hashes for shurrup-1.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 e0178d54a770e3b6a39cec805f255207161a6d9b5059b1814cae35b568f2139c
MD5 83452f0040aa54f2958efe390e7b82ac
BLAKE2b-256 5706365a9fe8b75e47f1d73a56ca939f3febc1ea9f665f7992ab2b930798f36d

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

1.2.1 This release

2 files

1.2.0

2 files

1.1.1

2 files

1.1.0

2 files

1.0.0

2 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