Catch the junk AI leaves in your code.
Your linter can't see // ... rest of code unchanged. ESLint, Ruff, and
Clippy throw away comment and string content before analysis: exactly
where AI coding artifacts live. stopslop reads what they discard: leaked
chat preambles, elision comments that silently deleted code, stray markdown
fences, placeholder credentials, and package imports that don't resolve to
anything you declared. It reads your prose too: Markdown, MDX, plain text,
reST, and HTML get the same deterministic treatment. One static binary, no
LLM at scan time: same input, same output, every run.
- One fast static binary, zero config to get started.
- Deterministic: no LLM calls, no API calls, no network access at scan time.
- Built for CI: exit codes, SARIF, JSON, and inline suppressions.
ESLint, Ruff, and Clippy parse comments and strings as trivia their default rule sets don't inspect, which is exactly where copy-pasted chat output and truncated edits land. A few examples:
| Artifact | ESLint / Ruff / Clippy | stopslop |
|---|---|---|
// ... rest of code unchanged |
Not linted (comment content is ignored by default rules) | SLOP001 |
| Leaked chat preamble ("Certainly! Here's...") | Not linted (same reason) | SLOP002 |
x as unknown as T type-escape chain |
No stock rule (as unknown alone is valid, idiomatic TS) |
SLOP007 |
YOUR_API_KEY, sk-...-shaped secret |
Not linted (string literal content is ignored by default rules) | SLOP009 |
stopslop is a deterministic linter for TypeScript, Python, Go, and Rust.
Every rule is a tree-sitter AST match or a regex over extracted
comments/strings: same input, same output, every time. This is a
quality gate that also names AI tells: it flags patterns that are junk
regardless of who (or what) produced them, and patterns that are known
giveaways of machine-generated text or code, so you can rewrite either. It
never scores a file as "written by AI" and never needs a model to say so.
cargo install stopslopOnce a day, an outdated install prints a one-line update notice to stderr
(never in CI, never for --format json|sarif|markdown, never when stderr is
not a terminal). Set STOPSLOP_NO_UPDATE_CHECK=1 (or NO_UPDATE_NOTIFIER=1)
to turn it off.
Alternatives:
# latest main, for a merged-but-unreleased fix
cargo install --git https://github.com/mgiovani/stopslop
# local checkout, for hacking on stopslop itself
git clone https://github.com/mgiovani/stopslop
cd stopslop
cargo install --path .stopslop # lint the current directory
stopslop src/ lib/ # lint specific paths
stopslop --format json . # machine-readable output
stopslop --stats . # files/lines scanned, wall time, throughput (stderr)
stopslop -j 1 . # single worker thread (perf work; 0 = automatic)
stopslop --select SLOP001 # run only the elision rule
stopslop --select rhetoric # run one rule group (see "Rule groups" below)
stopslop --select ALL # every rule (SLOP010 still needs --check-imports)
stopslop --ignore SLOP008 # run everything except stub detection
stopslop --select artifact --extend-select SLOP033 # add a rule on top of a narrower select
stopslop --list-rules # print every rule with its group, tier, and default
stopslop --check-imports . # also run SLOP010 (unresolved import) — opt-in
stopslop --config path.toml # use a specific config file instead of the discovered one
stopslop --no-config # ignore any project or user-level stopslop.toml, CLI flags only
stopslop --write-baseline . # record today's findings (see "Baseline" below)
stopslop --baseline . # report only findings that aren't in the baseline
stopslop --staged # lint what is about to be committed (index content, not disk)
stopslop --changed # staged + unstaged changes against HEAD
stopslop --since origin/main # files changed on this branch, for PR checksExample output:
--format json emits a flat array of findings; --format sarif emits a
SARIF 2.1.0 document for GitHub code scanning and similar tools;
--format markdown emits the findings grouped under one heading per tier, for
pasting into a PR comment or a report. --stats prints a
files/lines/wall-time/throughput summary to stderr in text and markdown
modes; with --format json the output becomes
{"findings": [...], "stats": {...}}, and with --format sarif the same
object lands in runs[0].properties.stats. Wall time runs from startup
through the walk, so it lines up with an external time. "skipped" counts
files the walk reached but could not lint (unsupported extension or
unreadable); paths dropped by .gitignore or exclude are never walked and
are not counted.
SLOP0NN numbers are chronological, not thematic, so a numeric prefix can't
express "just the rhetoric rules". Named groups can. They work anywhere a rule
code or prefix does, including --select, --ignore, and the
select/ignore keys in stopslop.toml:
| Group | What it covers |
|---|---|
artifact |
Mechanical leftovers from a generation session: chat turns, tool tokens, unfilled slots |
structure |
Structural code smells: swallowed errors, escaped types, stubs, speculative abstraction |
stdlib |
Code that rebuilds something the standard library or the platform already provides |
rhetoric |
Formulaic rhetorical shapes: clichés, staged reveals, manufactured significance |
verbosity |
Words that cost the reader something and return nothing: hedging, filler, padding |
sourcing |
Claims with no checkable source behind them |
format |
Typographic and Markdown affectations |
stopslop --select artifact,structure # the mechanical, high-confidence rules only
stopslop --select SLOP --ignore verbosity # everything except the density rulesEvery rule belongs to exactly one group; a test enforces that the table stays
exhaustive, so a new rule can't quietly escape group selection. --list-rules
prints the full mapping.
ALL is a reserved selector, not a group: --select ALL (or select = ["ALL"] in config) runs every registered rule plus any [[custom-rule]]
entries from your config. SLOP010 is the one exception: it stays off until
--check-imports is passed.
Turning stopslop on for an existing codebase usually surfaces findings nobody plans to fix today. A baseline grandfathers them so CI only fails on findings that are new:
stopslop --write-baseline . # writes .stopslop-baseline.json, exits 0
stopslop --baseline . # later runs report only what isn't in itUse --baseline=path.json / --write-baseline=path.json for a different file
(the = is required, so a bare --baseline doesn't swallow your scan path),
or set baseline = ".stopslop-baseline.json" in stopslop.toml.
Findings are matched by a fingerprint of code + path + message with digits
normalized out, not by line number, so editing above a finding doesn't
resurrect it and a density count drifting by one doesn't either. Paths are
normalized as well, with a leading ./ stripped, so a baseline written from
git ls-files (foo.md) still matches a stopslop . run (./foo.md). The file
stores a count per fingerprint: if a file has three accepted findings from
one rule, it absorbs exactly three, and a fourth is reported. Fix one and the
budget shrinks with it. The count ratchets down on its own; raising it takes a
deliberate --write-baseline.
Commit the baseline file so CI and local runs agree.
--staged, --changed, and --since REF (mutually exclusive) replace the walk
with a git-selected file list, so linting scales with the size of a diff
instead of the whole tree. Positional paths, if given, act as git pathspecs
(stopslop --staged src/ lints only staged files under src).
--stagedreads each file's staged content (git show :./path), so a partially staged file is checked as it will be committed, not as it sits on disk.--changedincludes both staged and unstaged changes againstHEAD; untracked files are not included.--since REFdiffs against the merge base ofREFandHEAD(git 2.30+), soREFadvancing after the branch forked doesn't pull in its changes. Built forstopslop --since origin/mainon a PR.- An empty selection exits 0. All three compose with
--baseline,exclude, and[per-file-ignores]exactly like a normal scan.
Pre-commit hook: stopslop --staged.
| Code | Group | Name | Tier | Langs | NL | Description |
|---|---|---|---|---|---|---|
| SLOP001 | artifact | Elision / "rest unchanged" comment | A, on | TS, TSX, Python, Go, Rust | en, pt-BR | A comment like // ... rest unchanged may mark code an AI dropped while truncating an edit |
| SLOP002 | artifact | Chat preamble leaked into code | A, on | TS, TSX, Python, Go, Rust | en, pt-BR | Chat-assistant preamble ("Certainly! Here's the updated...") pasted straight into a source comment |
| SLOP003 | artifact | Stray markdown code fence in source | A, on | TS, TSX, Python, Go, Rust | en, pt-BR | A bare ``` fence line left in source, usually from a whole file pasted out of a chat reply |
| SLOP004 | artifact | AI attribution / chat-share artifact | A, on | TS, TSX, Python, Go, Rust | en, pt-BR | "Generated by ChatGPT", a claude.ai/share link, or other chat-export junk in a comment |
| SLOP005 | structure | Empty / log-only catch | A, on | TS, TSX, Go, Rust | en, pt-BR | A catch/error branch that swallows the error with an empty body or just a log call |
| SLOP006 | structure | Broad / swallowing except | A, on | Python | en, pt-BR | A bare or overly broad except: that swallows the exception instead of handling it |
| SLOP007 | structure | Type-escape (as any/as unknown/@ts-ignore) |
A, on | TS, TSX | en, pt-BR | as any, an x as unknown as T chain that fully escapes the type checker, or @ts-ignore/@ts-nocheck |
| SLOP008 | structure | Stub-only / unimplemented body | A, on | TS, TSX, Python, Go, Rust | en, pt-BR | A function whose entire body is pass/.../throw new Error("not implemented")/todo!()/empty |
| SLOP009 | structure | Placeholder / sample credential value | A, on | TS, TSX, Python, Go, Rust, HTML | en, pt-BR | A hardcoded YOUR_API_KEY, example.com, sk-...-shaped secret, or other sample value; in HTML attributes also a placeholder-image host (via.placeholder.com, placehold.co) or an alt that is just image |
| SLOP010 | structure | Unresolved package import | B, off | TS, TSX, Python, Go, Rust | en, pt-BR | An imported package that isn't declared in the project's manifest or stdlib (opt-in, --check-imports) |
| SLOP011 | artifact | Assistant-response residue in prose | A, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A leftover chat-turn phrase (self-ID disclaimer, refusal boilerplate, a line-initial Certainly! opener, a trailing let me know if you have closer, reasoning-chain scaffolding like let's think about this, a mid-sentence step 1: that isn't heading a section or list item, or a paragraph-initial acknowledgment loop like To answer your question, ...) left unedited in prose |
| SLOP012 | artifact | LLM tool / citation artifact tokens | A, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A leftover search/citation-tool token (turn0search0, :contentReference[oaicite:1], a 【12†L3】 marker, utm_source=chatgpt.com) left in text |
| SLOP013 | artifact | Unfilled template placeholder text | A, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | An unfilled placeholder ([Your Name], INSERT_SOURCE_URL_30, a date: 2025-XX-XX stub) left in place of real content |
| SLOP014 | rhetoric | Formulaic cliché phrase | B, on | Markdown, MDX, Text, reST, HTML | en | A stock marketing/narrative cliché (unlock the power of, in today's fast-paced world, a testament to) |
| SLOP015 | verbosity | Hedging & filler-phrase density | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A document-wide density of hedging/filler phrases (it's worth noting that, in conclusion, first and foremost); an adjacent hedge stack like might potentially fires on its own, without waiting for the density threshold |
| SLOP016 | verbosity | Overused-vocabulary density | B, on | Markdown, MDX, Text, reST, HTML | en | A document-wide density of overused vocabulary (delve, tapestry, robust, leverage) across enough distinct terms to read as filler |
| SLOP017 | rhetoric | Rhetorical parallelism / false-depth scaffolding density | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A document-wide density of three-item rhetorical lists and not only X but also Y phrasing. Trailing , underscoring its... participles count toward the same threshold. Longer enumerations and lists of proper nouns are not counted |
| SLOP018 | format | Mid-prose em/en dash | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A mid-sentence em dash (—), en dash (–), or spaced ASCII -- that should be rewritten out of the sentence (numeric ranges like 2020–2024, and an attribution dash opening a block or blockquote like — Oscar Wilde, are exempt) |
| SLOP019 | format | Boldface & bold-lead-in list overuse | B, on | Markdown, MDX, HTML | en, pt-BR | Boldface overuse in body prose, or 3+ consecutive - **Term**: ... bold-lead-in list items |
| SLOP020 | format | Typographic (smart) quotes in source | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | Curly quotes/apostrophes in source where straight ASCII quotes are expected |
| SLOP021 | format | Heading & marker formatting affectations | B, on | Markdown, MDX, HTML | en, pt-BR | Emoji or a decorative technical symbol used as a heading/list marker (counted and reported separately), headings written in Title Case against an otherwise sentence-case document, or headings stacked over two-sentence sections |
| SLOP022 | rhetoric | Formulaic opener / rhetorical setup | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A throat-clearing or faux-insight opener (Here's the thing, What nobody tells you, Plot twist:), or a self-answered Question? Answer. pair opening a line |
| SLOP023 | rhetoric | Binary contrast / negative listing | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | The It's not X. It's Y. / The question isn't X, it's Y shape, or a Not a X. Not a Y. fragment run |
| SLOP024 | rhetoric | Importance puffery / fake-strong verb | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | An inflated significance claim (marks a pivotal moment, solidifies its position), a serves as a centralized hub-style linking verb where plain is reads better, or a faux-scale range (from the singularity of the Big Bang to the enigmatic dance of dark matter) standing in for an actual magnitude |
| SLOP025 | sourcing | Unsourced weasel attribution | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | Anonymous authority (experts agree, studies show) with nothing citing it anywhere on the line, whether a link or a footnote. Also flags notability by name-dropping three-plus outlets (cited in TechCrunch, Forbes, and Wired) with no per-citation context |
| SLOP026 | rhetoric | Dramatic colon reveal | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A short noun phrase, a colon, then a lowercase dramatic reveal (The best part: it learns) |
| SLOP027 | verbosity | Empty filler phrase & adverb density | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A document-wide density of empty phrases (when it comes to, at its core) and filler adverbs (simply, actually) |
| SLOP028 | verbosity | Weak verb phrase / vague quantifier | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A document-wide density of nominalizations (made a decision, has the ability to) and vague quantifiers used where a number belongs (significantly improves) |
| SLOP029 | rhetoric | Summary-recap ending / fake-profound kicker | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A closing block that restates the piece (In conclusion, Overall) or lands a mic-drop line (It's already here.) |
| SLOP030 | rhetoric | Dramatic fragmentation / robotic rhythm | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | Stacked one-clause fragments (That's it. That's the whole thing.), consecutive And-initial sentences, or paragraph-wide repeated sentence shapes |
| SLOP031 | rhetoric | Promotional / advertisement language | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | Brochure register in technical prose (boasts a, industry-leading, a hidden gem) at document-wide density |
| SLOP032 | verbosity | Hyphenated-compound overuse | B, on | Markdown, MDX, Text, reST, HTML | en | Stacked hyphenated modifiers used as filler (end-to-end, data-driven, battle-tested) at document-wide density |
| SLOP033 | verbosity | Overlong sentence | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A sentence over 50 words, reported with its actual word count (URLs count as one word) |
| SLOP034 | verbosity | Synonym rotation across a closed concept set | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | One concept named two ways within one section's running prose (check and verify, config and settings) where technical writing should fix one term. Bullet lists and tables are excluded, so a catalog of differently-named things doesn't count as rotation |
| SLOP035 | rhetoric | Outline-shaped filler section | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A Challenges and Future Prospects-style section heading, or despite these challenges boilerplate, standing in for specifics |
| SLOP036 | rhetoric | Diff-anchored documentation | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | Docs narrating a change (was added to replace, no longer requires) instead of describing current behavior; changelogs and migration guides are exempt |
| SLOP037 | stdlib | Reinvented stdlib / native platform feature | B, on | TS, TSX, Python, Go, Rust | en, pt-BR | Hand-rolled code where a standard-library or platform primitive exists (JSON.parse(JSON.stringify(x)), for i in range(len(xs)), ioutil.ReadFile) |
| SLOP038 | stdlib | Dependency with a stdlib equivalent | B, on | TS, TSX | en, pt-BR | An import of a package the platform already covers (moment, uuid, node-fetch, left-pad) |
| SLOP039 | structure | Pass-through wrapper function | B, on | TS, TSX, Python, Go, Rust | en, pt-BR | A function whose whole body forwards its own parameters, unchanged, to another function |
| SLOP040 | structure | Single-implementation interface / abstract | B, on | TS, TSX, Python | en, pt-BR | An interface or abstract class with exactly one implementor in the same file: abstraction with no second user |
| SLOP041 | verbosity | Mechanical uniformity (templated prose) | B, on | Markdown, MDX, Text, reST, HTML | en, pt-BR | A document of 200+ words where at least 2 of 3 document-level signals trip together: flat sentence-length burstiness, low type-token vocabulary ratio, and repeated word-trigrams |
| SLOP042 | verbosity | Comment that restates the code | B, on | TS, TSX, Python, Go, Rust | en, pt-BR | A plain comment of 2 to 12 words whose content words all already appear in the one statement it sits on, or only name the construct that statement is (// increment the counter above counter += 1), so it adds nothing the code doesn't say. Doc comments (and the plain comments that serve as docs where a language has no doc syntax: Go file scope and struct fields, Python module and class attributes), pragmas, banners, questions, comments with code symbols or quotes in them, comments naming an identifier the statement lacks, and any comment carrying a why (because, otherwise, workaround, a URL, an issue number), a constraint (not, only, unless, before) or a warning (careful, subtle) are exempt. Inflection is ignored (parsed matches parse_header) but abbreviations are not (max is not maximum) |
| SLOP043 | verbosity | Comment that runs long | B, on | TS, TSX, Python, Go, Rust | en, pt-BR | A plain comment block (consecutive comment lines count as one) of more than 40 words. A reason fits in a sentence or two; a comment that needs three full lines is narrating the code or carrying a design note that belongs in a doc comment, the README or the commit message. Doc comments, godoc (any Go comment outside a function body), license headers, generated files and commented-out code are exempt |
| SLOP044 | artifact | Boilerplate or empty page title | B, on | HTML | en, pt-BR | A <title>Document</title> (the editor's ! expansion left in place) or an empty <title> |
Every rule is exactly one of three states, --list-rules prints the DEFAULT
column so you can check any given rule at a glance:
- Tier A, on by default (12 rules): mechanical artifacts (SLOP001–009, SLOP011–013) with no legitimate reading. A finding here fails the run (exit 1) and blocks CI.
- Tier B, on by default (28 rules): everything else except SLOP010.
Judgment calls (density and style checks on prose, stdlib/structure
heuristics) that warn without ever exiting 1. Expect some noise; silence
what you don't want with
ignore/--ignoreby code or group. - Tier B, off by default (1 rule): SLOP010, gated behind
--check-importsbecause of its false-positive risk with private registries and dynamic imports.
Tier is a fixed property of each rule, and select/ignore can't change it.
What you can change is which tier the run fails on: fail-on-tier = "B" in
stopslop.toml (or --fail-on-tier B) puts every finding on the exit-1
path. CHANGELOG.md records which rules changed tier between
releases.
Findings that have one concrete replacement print it on a second line:
src/util.ts:14:11 SLOP037 deep clone via a JSON round trip
fix: use `structuredClone(value)`
--format json carries the same text in a fix field, omitted when a rule
flags a document-wide pattern with no single substitutable span.
Note on SLOP007: a bare x as unknown is not flagged on its own: it's a
legitimate first step in TypeScript's narrowing idiom. Only the chained form,
x as unknown as T, is flagged, since that's the pattern that fully defeats
the type checker.
stopslop also lints .md, .mdx, .txt, .rst, and .html/.htm files:
same binary, same deterministic regex/structural matching, no LLM involved
in the scan.
HTML is parsed with tree-sitter-html, and the rules see what a reader sees:
visible text, HTML comments, and href/src attribute values. Everything
else is blanked, including <script>, <style>, <pre>, <code>,
<textarea>, <template>, <svg>, <math>, and <noscript> bodies.
Django/Jinja {{ … }}, {% … %}, and {# … #} are blanked before parsing
too. Each leaf block element (<p>, <div>, <blockquote>, <td>) is one
paragraph for the rhythm, recap, synonym, and uniformity rules; list items,
headings, table cells, and form controls never are, and a <footer> is
never the ending. <strong>/<b> count as
bold, inline <code> counts as words, and —, —, and the
curly-quote entities are read as the characters they encode. – and
every other entity stay blank.
SLOP011–013 catch mechanical, high-confidence artifacts (leftover chat-turn
phrasing including reasoning-chain scaffolding and acknowledgment loops,
citation-tool tokens, unfilled placeholders) and are Tier A: a finding there
fails the run. SLOP014, SLOP018, and the formulaic-shape rules SLOP022–025
and SLOP029 are on by default too but Tier B, warn-only: judgment calls the
tool still surfaces every run, just without the power to fail CI. SLOP018
flags every mid-prose dash outright (the one exemption is a block-opening
attribution dash like — Oscar Wilde).
The rest are document-wide density and style checks, also on by default: hedging,
overused vocabulary, rhetorical parallelism, boldface overuse, smart quotes,
heading formatting, promotional register, hyphen stacking, sentence length,
synonym rotation, outline filler, change-narrating docs, colon reveals,
filler and adverb density, weak-verb phrasing, dramatic fragmentation, and
mechanical uniformity. SLOP041 is the one rule in this list that measures statistics
(burstiness, vocabulary diversity, trigram repetition) instead of matching a
phrase. It catches templated prose that rotates its vocabulary just enough to
slide past every phrase-based rule. These are judgment calls rather than
mechanical certainties, so they're warn-only. Silence the ones you don't want
by code (--ignore SLOP015) or by group (--ignore verbosity). Expect some
noise from them: a finding here is a lead to investigate rather than a
verdict. As with the rest of stopslop, this is a writing-quality
gate, not a claim about who or what wrote the text: every rule flags a
concrete pattern (a stale phrase, an unfilled slot, a density threshold,
a document-level statistic), never "this is AI-generated."
Every rule runs every natural-language panel it declares, on every document,
by default: nothing to configure, nothing to detect. Lang is the file
syntax (Markdown, MDX, ...); NatLang is a separate axis for which
lexicons a rule's phrase panels actually cover, listed per rule in the NL
column above. Structural and statistical rules (AST shape, punctuation,
density) need no lexicon and already run on pt-BR text. Phrase-panel rules
gain a pt-BR panel one rule at a time, each proven by a fixture under
tests/fixtures/markdown/pt-br/ before the rule may list pt-BR; SLOP014
(cliché phrases), SLOP016 (vocabulary), and SLOP032 (hyphenated-compound
overuse) still stay English-only, and
issue #30 tracks the rest.
pt and pt-PT both resolve to the same Brazilian lexicon as pt-BR, a
best-effort alias rather than a separate European-Portuguese panel. An HTML
page that declares <html lang="..."> narrows its own run to that language
when the config allows it (.rst docinfo and Markdown front matter carry no
such hint yet). SLOP033's overlong-sentence cap rises to 57 words once a run
is Portuguese-only (language = "pt-BR", measured against English's 50 on
matched corpora). SLOP041's thresholds hold as measured (Portuguese trips
them less often than English, not more); SLOP043 and SLOP029's caps are
still unmeasured for Portuguese, and language = "pt-BR" in
stopslop.toml restricts a run to the Portuguese panels only.
Two escape valves, both plain comments so they work in any language's comment syntax:
// ai-slop-ignore: suppresses findings on that line and the line below it (so it works whether the comment is trailing or sits above the flagged line).// ai-slop-ignore-file: anywhere in the file, drops every finding in that file.
In Markdown/MDX/Text/reST/HTML files, use the HTML comment form instead:
<!-- ai-slop-ignore --> / <!-- ai-slop-ignore-file -->, with the same
two-line and whole-file suppression semantics.
Bare, either directive suppresses every rule. Add : CODE,CODE (commas or
spaces both work) to scope it to specific rules, or to a group name:
// ai-slop-ignore: SLOP002,SLOP004
// Certainly! Generated by ChatGPT — kept verbatim as a fixture for this rule's own test<!-- ai-slop-ignore-file: SLOP018 -->Group names expand the same way they do in --select/--ignore, so // ai-slop-ignore: verbosity silences every rule in that group on the line.
The directive has to open the comment body, right after the delimiter. A
comment that merely mentions the token while documenting the feature, like
// dogfooding would self-flag these; "ai-slop-ignore" is the escape hatch,
is not a directive and suppresses nothing: the token has to be the first
thing after ///#/<!--/etc. This is the thing most likely to look like a
bug the first time you hit it: writing docs about the suppression syntax
in a comment that isn't itself a suppression is fine, and does nothing.
A suppression that absorbs no finding (wrong code, or nothing to suppress on that line) is a dead suppression. stopslop prints a warning for it to stderr, naming the file and line, so a stale or mistyped ignore doesn't silently rot:
stopslop: warning: src/util.ts:14: ai-slop-ignore (SLOP018) suppressed nothing
Rules that are path-gated (SLOP005, 006, 008, 009, 010, 037, 038, 039, 040,
042, 043) don't run inside directories or files that look like tests or
generated/vendored code, since empty catches, broad excepts, unresolved
imports, the stdlib/wrapper/single-implementation heuristics, and
// Check nfkd-style test-section labels are all legitimately common there:
- Any path segment named
tests,test,__tests__,testdata,fixtures,fixture,mocks,mock,examples,example,vendor,node_modules, orgenerated. - Filenames matching
test_*,conftest.py,*_test.go,*_test.py,*.test.ts(x),*.spec.ts(x),*.pyi,*.pb.go,*_pb2.py,*.min.js.
SLOP001–004 (the junk-text rules) are not path-gated: a leaked chat preamble or elision comment is junk in a test file too.
The nearest stopslop.toml walking up from the current directory to the
filesystem root, so a run from a nested cwd (an editor task runner, a
pre-commit hook) still finds the repo-root file. When no project file exists,
stopslop reads $XDG_CONFIG_HOME/stopslop/stopslop.toml, which is
~/.config/stopslop/stopslop.toml when the variable is unset or empty, on
every platform including macOS. The user-level file is a fallback, never a
merge layer: a project that ships its own stopslop.toml is unaffected by
whatever sits in ~/.config, so CI and local runs stay reproducible. Pass
--config path to use a specific file instead:
select = [] # rule codes/prefixes/groups to run (empty = every rule except SLOP010)
ignore = ["SLOP009", "verbosity"] # rule codes/prefixes/groups to subtract
extend-select = ["SLOP033"] # adds on top of `select`, instead of replacing it
extend-ignore = ["SLOP016"] # adds on top of `ignore`, same relationship
exclude = ["**/generated/**"] # extra walker excludes, on top of .gitignore
check-imports = false
baseline = ".stopslop-baseline.json" # subtract findings recorded here (omit to disable)
fail-on-tier = "A" # lowest tier that exits 1; "B" gates the build on every finding
language = ["en", "pt-BR"] # restrict which natural-language panels run (omit = every language)
[per-file-ignores]
"docs/**" = ["SLOP036"] # codes and/or group names; applied after linting, before baselineselect and ignore follow Ruff's composition rules: a CLI --select
replaces the config's select outright (rather than adding to it), and the
same for --ignore. extend-select and extend-ignore never replace
anything: the config's extend-select and any CLI --extend-select are
unioned together on top of whatever select resolved to, and extend-ignore
does the same for ignore, subtracted last so an extend-ignore always wins
over an extend-select.
[per-file-ignores] keys are globs, matched against the file's display path
with a leading ./ stripped from both the glob and the path, so
"docs/**" matches whether you run stopslop . (paths print as
./docs/...) or stopslop docs (paths print as docs/...). Values are
lists of codes and/or group names, expanded the same way select/ignore
are. It's a post-lint filter, applied after the walk and before baseline
filtering, so it composes with a baseline instead of fighting it. An invalid
glob is a config error (exit 2).
Use --no-config to ignore any project or user-level stopslop.toml. CLI flags for
select/ignore/check-imports/baseline override the config file; see
User-defined rules below for [[custom-rule]].
language restricts which natural-language lexicons run, as a string or an
array (language = "pt-BR" or language = ["en", "pt-BR"]); absent or empty
means every supported language, the default. pt and pt-PT both resolve
to the same Brazilian lexicon as pt-BR. An unrecognized tag is a config
error (exit 2) naming the supported tags. See Prose
linting for what this axis actually gates.
House-specific banned phrases don't need a Rust module. A [[custom-rule]]
array of tables in stopslop.toml compiles straight to a regex rule:
[[custom-rule]]
pattern = '(?i)\bsynergy\b' # required: a regex, matched against comments/strings
# (code files) or masked prose (Markdown/MDX/Text/reST/HTML)
message = "banned house phrase: synergy" # required: the finding text
tier = "B" # optional, "A" or "B", defaults to "B"
fix = "say what the teams actually do" # optional: printed as a second "fix:" line
files = ["docs/**"] # optional glob list; omit for every supported fileCodes are auto-assigned SLOP900, SLOP901, ... in declaration order. Each
[[custom-rule]] entry gets one code. They are grouped as custom in
--list-rules. Every
custom rule is on by default, but still subject to select/ignore and
suppressible with ai-slop-ignore: SLOP900 like any built-in rule. An
invalid regex, an invalid tier, or an invalid files glob is a config
error (exit 2) naming the offending entry's index.
files globs match the file's display path, with a leading ./ stripped
from both the glob and the path exactly as [per-file-ignores] does, so
"docs/**" behaves the same whether you run stopslop . or stopslop docs.
SLOP010 cross-references each import against the project's manifest
(pyproject.toml/requirements.txt, package.json/tsconfig.json,
go.mod, Cargo.toml) plus an embedded stdlib/builtin list per language.
Honest caveats:
- If no manifest is found for a language, that language's checks are skipped silently rather than risk false positives.
- It's a static namecheck, not a resolver. It can still false-positive or false-negative on workspace/path dependencies and dynamic imports. Unusual build setups can do the same.
- It never fails the build on its own (Tier B): treat it as a lead to investigate, not a hard gate.
0: nothing at or above the failing tier.1: at least one finding at or above the failing tier.2: usage error, a path that couldn't be scanned, or a config error (bad glob, bad regex, unknown key, invalid[[custom-rule]]orfail-on-tiervalue).
The failing tier is A by default, so Tier B findings (including any custom
rule declared tier = "B") print without blocking. Set fail-on-tier = "B"
in stopslop.toml, or pass --fail-on-tier B, to gate the build on every
finding instead; the CLI flag wins over the config. A baseline is applied
before the exit code is computed, so baselined findings never fail a run
whatever the tier.
- Not an AI-origin verdict. A rule may name a span as a known AI tell so you can rewrite it, but stopslop never scores a whole file as "written by AI", never runs a model, and never phones home. The verdict stays with the reader.
- Not a correctness checker. It doesn't run your code or understand what it's supposed to do: a stub function that's genuinely fine (e.g. an intentionally unimplemented trait default) can still need a suppression comment.
- No member-level hallucination detection. It can't tell you that
requests.get_json()isn't a real method: that's a type checker's job. - No general code-comment style/verbosity grading. In code files it flags specific junk patterns (preamble, attribution, elision, a comment that only repeats the statement under it), not comment style, length, or density in general. Prose files additionally get the density/style checks in Prose linting above; those are warn-only judgment calls, and none of them are a house-style grader.
[[custom-rule]]is a phrase matcher, not a plugin system. It compiles a regex against comments/strings or masked prose. There's no hook for custom AST shapes or arbitrary code execution. A custom rule also cannot be as sophisticated as, say, SLOP041's document statistics. For anything past "flag this phrase," it's still a Rust module insrc/rules.
MIT (see LICENSE).
stopslop's own CI fails if its source contains slop: every push runs
cargo run --quiet -- . against the whole repo, prose included, alongside
cargo fmt, cargo clippy, and cargo test. Tier A findings fail the build;
Tier B findings print as warnings, the same contract users get.




