agentleFS
BubblegunnBubblegunn/ai-slop-linter/llms-full.txt

ai-slop-linter

llms.txt14 starsChanged 58 days agoMITView on GitHub

Generated by node scripts/llms-full.mjs from the files named below. Do not edit by hand. The short index is llms.txt. Source: README.md My coding agent keeps putting em dashes and stock phrases into commit messages, pull requests and READMEs even though my rules file bans them, and nothing checks the text before it ships. ai-slop-linter is a linter that flags AI-sounding phrases and em dashes in commit messages, pull request descriptions and Markdown docs, and runs in CI. It lists each…

  • Pipes a download into a shell
  • Installs packages

We split this llms-full.txt into the rules it gives an agent

28 rules. About 0 are common, 17 are distinctive, and 11 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈17

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 11

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Before any rule runs, the document is blanked in the places code lives: front matter in YAML or TOML, fenced blocks including a fence …

    line 326
  2. Distinctive:

    Commit messages and pull request descriptions are the writing this section is about, and they are a harder register to read than an article.

    line 343
  3. Very specific:

    Run pre-commit install for Markdown hooks and pre-commit install --hook-type commit-msg for the commit message hook.

    line 401
  4. Distinctive:

    If commitlint already guards your messages, add the rule there instead of a second hook:

    line 412
  5. Distinctive:

    Run it with Tasks: Run Test Task.

    line 457
  6. Very specific:

    Every release is archived on Zenodo with a DOI, and CITATION.cff carries the same identifier, so GitHub's "Cite this repository" button …

    line 581
  7. Distinctive:

    Check the text before it ships, and make the check run without anyone remembering it.

    line 629
  8. Distinctive:

    Add the GitHub Action. It lints the pull request title, its description and every changed Markdown file, and keeps one comment on the pull …

    line 633
  9. Very specific:

    Use --ignore <rule> on the command line, or "ignore": ["rule"] in .slop.json, with overrides for particular paths.

    line 685
  10. Very specific:

    No issue in a competitor's repository.

    line 713
  11. Distinctive:

    No Stack Overflow question and no Reddit thread.

    line 714
  12. Distinctive:

    Every finding carries a line and a column, and the column is a 1-based offset in UTF-16 code units, counted from the start of the line in …

    line 836
  13. Distinctive:

    No Spanish or German tell detection.

    line 915
  14. Distinctive:

    No threshold separates a writer from a model in any language.

    line 916
  15. Distinctive:

    Every rule runs over both.

    line 1023
  16. Very specific:

    Keep error. Put the one-line reason and the off switch (--ignore dash) on the first screen of the README.

    line 1263
  17. Very specific:

    Keep error but only from a density (the table above), which needs a new threshold option.

    line 1265
  18. Very specific:

    Add it to the rules array in src/index.ts.

    line 1291
  19. Very specific:

    Add one sentence to test/fixtures/sloppy.md that triggers it, and make sure test/fixtures/clean.md still grades A.

    line 1292
  20. Distinctive:

    Add a row to the rule table in README.md, with its source.

    line 1293
  21. Very specific:

    Write src/rules/<lang>.ts exporting the rules, each with an id prefixed by the language, so zh/chatbot and never chatbot.

    line 1312
  22. Very specific:

    Add a fixture, test/fixtures/sloppy.<lang>.md, with one sentence per rule, and a test that the rules fire there and that the file is clean …

    line 1316
  23. Very specific:

    Run node bench/packs.mjs to write bench/PACKS.md.

    line 1322
  24. Distinctive:

    Add the rows to the rule table in README.md, with sources.

    line 1333
  25. Distinctive:

    You get a real review, quickly: Not a rubber stamp and not a queue.

    line 1350
  26. Distinctive:

    Your work gets checked, not admired: If you send a test, it gets run against something broken to confirm it fails.

    line 1353
  27. Distinctive:

    Keep the package dependency-free unless the issue discussing the dependency was accepted first.

    line 1380
  28. Distinctive:

    No em dashes in shipped text (README, help, output).

    line 1381

≈ “Common” is an estimate: at least 1 in 5 of the 3,225 llms.txt files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
llms-full.txt
# ai-slop-linter, full text

Generated by `node scripts/llms-full.mjs` from the files named below. Do not edit by hand. The short index is llms.txt.

---

Source: README.md

My coding agent keeps putting em dashes and stock phrases into commit messages, pull requests and READMEs even though my rules file bans them, and nothing checks the text before it ships.

ai-slop-linter is a linter that flags AI-sounding phrases and em dashes in commit messages, pull request descriptions and Markdown docs, and runs in CI. It lists each tell with a line, a column and a reason, and `--explain <rule>` gives the source for that rule. It runs on your machine and calls no model.

```
npx ai-slop-linter README.md
```

![ai-slop-linter on a model-written pull request description, then --explain bold-label](docs/demo.gif)

The recording is the unedited output of the build in this repository. The file it lints is a model-written sample from `bench/corpus`, not a real pull request. The command exits 1 when a file has an error-severity finding or a score above `--max-score` (default 10). Run on this README, the same command prints grade A.

Benchmark: [round 8 result](#how-it-compares). Write-up: [Humanizer has about 60,000 stars. On 12 public tasks my linter plugin scored 2.67 higher](https://dev.to/efe_genc/humanizer-has-about-60000-stars-on-12-public-tasks-my-linter-plugin-scored-267-higher-2cdg).

<p align="center">English | <a href="README.tr.md">Türkçe</a></p>

<p align="center">
  <a href="https://www.npmjs.com/package/ai-slop-linter"><img src="https://img.shields.io/npm/v/ai-slop-linter?style=flat-square&color=111111&label=npm" alt="npm version"></a>
  <a href="https://www.npmjs.com/package/ai-slop-linter"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FBubblegunn%2Fai-slop-linter%2Fmetrics%2Fbadges%2Fnpm-week.json&style=flat-square" alt="npm downloads per week"></a>
  <a href="https://github.com/Bubblegunn/ai-slop-linter/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Bubblegunn/ai-slop-linter/ci.yml?style=flat-square&color=111111&label=ci" alt="ci"></a>
  <img src="https://img.shields.io/badge/dependencies-0-111111?style=flat-square" alt="zero dependencies">
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT licence"></a>
  <a href="https://doi.org/10.5281/zenodo.22396875"><img src="https://img.shields.io/badge/DOI-10.5281%2Fzenodo.22396875-111111?style=flat-square" alt="DOI"></a>
  <a href="https://github.com/marketplace/actions/ai-slop-linter"><img src="https://img.shields.io/badge/GitHub_Marketplace-ai--slop--linter-111111?style=flat-square&logo=github" alt="GitHub Marketplace"></a>
</p>

The `dash` rule is an error today, so one em dash fails a run. Turn it off with `--ignore dash`. Whether it should stay an error by default has not been decided; the measurement is in [bench/DASH-DEFAULT.md](bench/DASH-DEFAULT.md).

The first line paraphrases what users wrote in public issues. The sources are in [docs/PAIN.md](docs/PAIN.md).

`slop --compare` shows which numbers, names, versions, links, quotes or code spans a rewrite lost or added, so keeping the facts is checked rather than assumed. The tool calls no model and has zero dependencies.

Most of the rules are built from the Wikipedia page [Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), which records the patterns that editors who review machine-written edits keep finding. The rule table below names the source of each rule.

## Install

Three routes, one set of rules.

- Command line: `npx ai-slop-linter README.md`, from npm. The npm version is 0.1.10. It takes `--language` but does not detect the language of a file, does not exempt dialogue, and has no `--compare`; those are in the unreleased 0.2.0. <!-- release-status -->
- Agent skill: `npx skills add Bubblegunn/ai-slop-linter`. The skill makes an agent lint what it writes and compare the facts after each rewrite. Its `--compare`, per-file language detection and French rule need ai-slop-linter 0.2.0 or later, so until 0.2.0 is on npm the skill's `npx` commands run the older version. <!-- release-status -->
- Claude Code plugin: `/plugin marketplace add Bubblegunn/ai-slop-linter`, then `/plugin install ai-slop-linter@ai-slop-linter`.

## Before and after

The input is [`bench/head-to-head/fixtures/h2/README.md`](bench/head-to-head/fixtures/h2/README.md). The output below is what this build prints for it.

````markdown
# Fastq

Fastq 2.3 is not just a queue — it's a game-changer for modern backends. Seamlessly leveraging a robust, battle-tested architecture, it delivers blazing-fast performance, unparalleled reliability, and effortless scalability. In benchmarks, Fastq 2.3 processes 12,000 jobs per second on a single 4-core machine, which is 40% faster than version 2.2. Maintainers at Acme Corp reported a 35% drop in p99 latency on their staging cluster. It requires Node 20 or later and is released under the MIT license. Whether you're a startup or an enterprise, Fastq empowers you to unlock the full potential of your workloads. The future of job processing is here.

## Install

```sh
npm install fastq@2.3
```

See the [changelog](CHANGELOG.md) for details.
````

```
$ npx ai-slop-linter bench/head-to-head/fixtures/h2/README.md
bench/head-to-head/fixtures/h2/README.md  F (score 85.5, 117 words, 8 findings)
     3:14  warning not-x-but-y          "not just X but Y": say the thing itself
     3:30  error   dash                 em dash
     3:40  warning ai-vocabulary        "game-changer": a word models reach for; use the plain one
     3:74  warning ai-vocabulary        "Seamlessly": a word models reach for; use the plain one
     3:85  warning ai-vocabulary        "leveraging": a word models reach for; use the plain one
     3:98  warning ai-vocabulary        "robust": a word models reach for; use the plain one
     3:172 warning sales                "unparalleled": sounds like an advertisement; give the fact instead
     3:569 warning ai-vocabulary        "unlock the": a word models reach for; use the plain one

Why any of these is a tell, and what to write instead: slop --explain not-x-but-y
```

The rewrite, [`bench/head-to-head/golden/h2/README.md`](bench/head-to-head/golden/h2/README.md), is a hand-written version of the same paragraph:

````markdown
# Fastq

Fastq 2.3 is a queue for job processing. In benchmarks, Fastq 2.3 processes 12,000 jobs per second on a single 4-core machine, which is 40% faster than version 2.2. Maintainers at Acme Corp reported a 35% drop in p99 latency on their staging cluster. It requires Node 20 or later and is released under the MIT license.

## Install

```sh
npm install fastq@2.3
```

See the [changelog](CHANGELOG.md) for details.
````

It grades A with no finding:

```
$ npx ai-slop-linter bench/head-to-head/golden/h2/README.md
bench/head-to-head/golden/h2/README.md  A (score 0, 69 words, 0 findings)
```

`--compare` checks the rewrite against the original:

```
$ npx ai-slop-linter --compare bench/head-to-head/fixtures/h2/README.md bench/head-to-head/golden/h2/README.md
facts: 11 kept, 0 lost, 0 added
OK: no fact lost or added
```

It also reads first-person words and claim words, which a rewrite can add without touching a number or a name. A rewrite that turns "In benchmarks" into "In our benchmarks" prints `added  person   our` and exits 1, because the source never said whose benchmarks. Only words that passed a measurement on the head-to-head rewrites are flagged, and [`bench/COMPARE-WORDS.md`](bench/COMPARE-WORDS.md) lists every candidate word with its verdict. Text in double quotation marks, code, urls and link targets are not read.

A qualifier next to a number is part of the fact. When the rewrite keeps the number but drops `up to`, `at least`, `nearly`, `environ`, `bis zu`, `más de` or a word like them, or adds one, the comparison prints `lost   qualifier up to 3` or `added  qualifier over 300` and exits 1. A swap within one meaning (`roughly` for `around`, `over` for `more than`) is not reported. Every qualifier passed a stricter measurement than the words above: it flags none of the goldens, none of the 357 round 1 rewrites by other tools and none of 40 hand-written honest pairs ([`bench/COMPARE-QUALIFIERS.md`](bench/COMPARE-QUALIFIERS.md)). English `about` is not read: it flagged one honest rewrite.

`--verify <before> <after>` is the single gate for a rewrite. On the `<after>` file it runs the lint errors, the same comparison as `--compare`, a scan of the stock phrases listed in `plugin/skills/ai-slop-linter/references/<lang>.tells.txt` for the text's language (`--language` sets it), and a `hedge` check: a modal verb with a hedge adverb within three words (`could potentially`, `may possibly`) or an evidential with a vague link (`seems to be related to`), in English, Portuguese, Spanish, French and German. A modal or `seems` alone is never a hit. Text in code, quotation marks and dialogue lines is skipped. It prints `line:col catalog <phrase>` for each phrase it finds, then `verify: ok` or `verify: N problem(s)`, and exits 1 on any problem. A language with no tells file gets no phrase hits. It also blocks a length collapse or growth (the rewrite outside 35 to 130 percent of the original, measured outside code blocks), and prints without blocking each original sentence that the rewrite dropped with no finding to explain it.

## How it compares

A head-to-head benchmark against six other Claude Code skills (Humanizer, unslop, everwrite, slopless, avoid-ai-writing and slop-cop; the first three also called by name) and Claude with no plugin, on editing tasks scored by scripted checks and blind grading. It is a result on those tasks, not a general ranking.

Full write-up: [Humanizer has about 60,000 stars. On 12 public tasks my linter plugin scored 2.67 higher](https://dev.to/efe_genc/humanizer-has-about-60000-stars-on-12-public-tasks-my-linter-plugin-scored-267-higher-2cdg).

![Benchmark round 8. Public set: ai-slop-linter 58.67 of 60, first of 11 arms. Held-out set: four arms at 29.67 or more of 30, a near-tie.](docs/benchmark-round8.png)

<!-- h2h:start -->
Round 8 (2026-10-23, Sonnet 5.5, 11 arms, 3 runs each): on the 12 public tasks this linter scored 58.67 of 60 and the best other arm (Humanizer, called by name) 56.00; the 95% bootstrap interval of the difference is [1.00, 4.33]. On 6 held-out tasks written by an author who had not read the code, four arms scored 29.67 or more of 30 (this linter 29.67, unslop 30.00), so that set does not separate them. Every cell, the departures from the frozen protocol and a recompute step are in bench/head-to-head/results/round8-final-2026-10-23/; the rounds and rulings are in docs/HEAD-TO-HEAD.md; the tasks, criteria and harness are public in bench/head-to-head/.
<!-- h2h:end -->

## Use it

```
npx ai-slop-linter                    # every .md file under the current directory
npx ai-slop-linter --commit           # the last commit message
npx ai-slop-linter README.md --fix    # apply the safe fixes in place
```

`slop` is the same binary, for people who type it a lot. To read the rules against your own text in a browser before installing anything, open **[bubblegunn.github.io/ai-slop-linter](https://bubblegunn.github.io/ai-slop-linter/)**. The page runs the same engine this README documents, in your own browser. No request in it can carry your text anywhere, and it loads nothing from a third party. Two tests check that, and the deploy builds the page from this repository. `npm run playground` builds the same page locally.

Some readers have learned these tells: the em dash, `not just X but Y`, `I hope this helps`, `a testament to`, the bold label on every bullet.

**It is not an authorship detector.** It never prints a probability that a model wrote the text, and it cannot tell you who typed it: someone who writes `delve` gets the same finding a model does. A clean pass means one thing, that none of the listed tells are present. Every rule names its source and says when you should switch it off rather than obey it. [What it cannot show](#what-it-cannot-show) is the full list of limits.

## Where it runs

| where | how | status |
|---|---|---|
| Command line | `npx ai-slop-linter` | published on npm |
| GitHub Action | `uses: Bubblegunn/ai-slop-linter@v0`, see [In a repository](#in-a-repository) | listed on GitHub Marketplace |
| commit-msg hook, pre-commit, commitlint | [In a repository](#in-a-repository) | available from this repository |
| Agent skill | `npx skills add Bubblegunn/ai-slop-linter`, see [For agents](#for-agents) | available from this repository |
| Claude Code plugin | `/plugin marketplace add Bubblegunn/ai-slop-linter` | available from this repository |
| MCP server | `npx --yes ai-slop-linter --mcp`, see [docs/mcp.md](docs/mcp.md) | in the unreleased 0.2.0 <!-- release-status --> |
| textlint rule | package `textlint-rule-ai-slop`, see [its README](textlint-rule-ai-slop/README.md) | in this repository, not yet on npm <!-- release-status --> |
| VS Code extension | a `.vsix` built from `vscode/`, see [its README](vscode/README.md) | not yet on the Marketplace <!-- release-status --> |
| Browser | [the playground](https://bubblegunn.github.io/ai-slop-linter/) | live |

### A structural use of bold that is not a mini-heading

A short CV-style role list can bold the role or linked company because that is the entity each row describes:

```markdown
- **Founder, [LILA](https://hellolila.app)** (Feb 2026 to present): a proactive personal AI product.
- **Founding engineer, [Cendra](https://cendra.ai)** (Aug 2025 to Jul 2026): primary author of the frontend.
```

There is no dependable signal in the bold span alone that separates this structural use from a mini-heading. The `bold-label` rule therefore keeps its existing pattern rather than guessing from punctuation or list shape. If a legitimate role list is flagged, put `<!-- slop-ignore-next-line RULE -->` immediately before each affected item, or `<!-- slop-ignore RULE -->` before the section. Replace `RULE` with `bold-label` in the directive.

## What it looks like

Real output on [`test/fixtures/sloppy.md`](test/fixtures/sloppy.md), a 296-word file
written to trip every rule once (first eleven of fifty-three findings):

```
test/fixtures/sloppy.md  F (score 193.6, 296 words, 53 findings)
     5:1   info    title-case-heading   Title Case heading; sentence case reads as written by a person
     7:1   warning announcing           "Let's dive into": make the point instead of announcing it
     7:26  error   dash                 em dash
     7:32  warning inflated             "is a testament to": say what happened; let the reader judge the importance
     7:37  warning ai-vocabulary        "testament": a word models reach for; use the plain one
     7:72  warning ai-vocabulary        "In today's fast-paced": a word models reach for; use the plain one
     7:83  info    hyphen-density       7.1 hyphenated compounds per 100 words; drop the hyphen after the noun ("the report is high quality")
     7:115 warning inflated             "stands as a": say what happened; let the reader judge the importance
     7:127 warning inflated             "pivotal moment": say what happened; let the reader judge the importance
     7:127 warning ai-vocabulary        "pivotal": a word models reach for; use the plain one
     7:156 warning ing-tail             ", highlighting": cut the tail or make it its own sentence with a fact in it
```

Each file gets a score, weighted findings per 1,000 words (error 3, warning 1,
info 0.3), and a grade: A under 3, B under 8, C under 15, D under 30, F above. The run
exits 1 when any file has an error-severity finding or a score above `--max-score`
(default 10), so it can run in CI without a cleanup commit first.

How the score is built, why density and not severity, how words are counted and what a finding's
column means: [docs/SCORING.md](docs/SCORING.md).

## The rules

Twenty-three rules. Most come from the Wikipedia page
[Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing),
written by editors who review thousands of machine-written edits; the three marked "house"
are ours. Run `npx ai-slop-linter --rules` for the same list from the binary, and
`npx ai-slop-linter --explain <rule>` for one rule in full: why the pattern reads as
machine-made, a line that trips it, the same line rewritten, and the case where you should
switch the rule off instead of obeying it. Every finding in the text output ends with the
command for the rule that fired.

```
$ slop --explain ing-tail
ing-tail  warning  Present-participle tail adding significance
source: Wikipedia, Signs of AI writing: Superficial analyses

Why
  The tail attaches a claim of significance to a fact already stated, without adding a
  second fact. It is the cheapest way to make a sentence sound like a conclusion.

Instead of
  We shipped the fix on Friday, underscoring the team's commitment to quality.

Write
  We shipped the fix on Friday. The next release is on Tuesday.

Ignore it when
  The tail states a real consequence with its own detail, and the sentence would be
  incomplete without it.
```

The tests check that every rule carries this material and that the "instead of" line
actually trips the rule it illustrates.

| rule | severity | what it catches | fix |
|---|---|---|---|
| `dash` | error | em dash or en dash used as a dash (ranges like 2019–2021 are left alone) | comma, full stop, or removed after punctuation |
| `chatbot` | error | `I hope this helps`, `Certainly!`, `Let me know if`, `As an AI` | |
| `cutoff-disclaimer` | error | `as of my last update`, `as of [date], I cannot` | |
| `not-x-but-y` | warning | `not just X but Y`, `It's not X. It's Y.`, `not because A, because B` | |
| `triad` | warning | three single words in a row sharing a suffix (`careful, thoughtful, and mindful`) | |
| `reveal` | warning (house) | `The real question is`, `At its core`, `What really matters` | |
| `ing-tail` | warning | a comma then `, highlighting`, `, showcasing`, `, underscoring` | |
| `inflated` | warning | `is a testament to`, `pivotal`, `stands as a`, `marks a turning point` | |
| `ai-vocabulary` | warning | `delve`, `tapestry`, `robust`, `seamless`, `vibrant`, `leverage`, `elevate`, `foster`, `crucial`, abstract `landscape` and friends | |
| `sales` | warning | `world-class`, `best-in-class`, `state-of-the-art`, `cutting-edge`, `nestled in the heart of` | |
| `vague-source` | warning | `experts argue`, `studies show`, `it is widely believed` (silent when the line has a link) | |
| `announcing` | warning (house) | `Let's dive in`, `In this article we will`, `Without further ado` | |
| `closer` | warning | `Exciting times ahead`, `the future looks bright`, `stay tuned` | |
| `bold-label` | warning | `- **Speed:** ...` mini-headings in list items | |
| `emoji` | warning | emoji decorating a heading or list item | |
| `filler` | info | `in order to`, `due to the fact that`, `it is important to note that` | the plain phrase |
| `curly-quotes` | info | `“ ” ‘ ’` | straight quotes |
| `title-case-heading` | info | Every Word Capitalised In A Heading (ALL CAPS is left alone) | |
| `challenges-section` | info | a `Challenges and future outlook` section | |
| `hyphen-density` | info | more than three hyphenated compounds per hundred words | |
| `reference-markup` | error | internal citation and rendering residue such as `contentReference`, `oaicite`, `turn0search0`, `grok_card`, `attached_file` and related markers | |
| `chatbot-tracking` | error (house) | chatbot-added URL parameters: `utm_source=openai`, `utm_source=chatgpt.com`, `utm_source=copilot.com` and `referrer=grok.com` | |
| `invisible-character` | warning (house) | zero-width spaces and joiners, direction controls and Unicode tag characters in Latin-script text (joined emoji, flags, other scripts, a leading byte order mark and right-to-left lines are left alone) | deleted |

`reference-markup` was written by [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) ([#46](https://github.com/Bubblegunn/ai-slop-linter/pull/46)), with one test line per marker so that deleting any of them fails the suite.

Fenced code, inline code, front matter, link targets, URLs, HTML tags and comments are
masked before any rule runs, so a dash in a code sample is never a finding.

To silence a finding you have judged wrong:

```markdown
<!-- slop-ignore-next-line RULE -->
<!-- slop-ignore RULE-A, RULE-B -->    (from here to the end of the file)
```

## Commit message rules

`--commit`, `--commit-msg` and `--pr` also run these rules, which only a commit message or a pull request description gets. The phrase lists are adapted from [tbhb/vale-ai-tells](https://github.com/tbhb/vale-ai-tells) by Tony Burns (MIT); `commit-procedural-assurance` comes from Wikipedia's Signs of AI writing page and was written by [@Nitjsefnie](https://github.com/Nitjsefnie) ([#75](https://github.com/Bubblegunn/ai-slop-linter/pull/75)). `bench/COMMIT-RULES.md` shows how each was measured on commits written before 2021.

| rule | what it flags |
|---|---|
| `commit-hedging` | a hedge about a change already made, such as "this should fix" |
| `commit-overexplanation` | a frame that adds nothing, such as "as part of this change" |
| `commit-marketing` | claims such as "production ready" or "enterprise grade" |
| `commit-trailing-justification` | a trailing clause that claims a benefit the diff has to show |
| `commit-unquantified` | a speed claim with no number |
| `commit-procedural-assurance` | a promise that the change kept something safe, such as "while preserving" or "without breaking", never the bare verbs |

## If you do not write in English

The vocabulary rules are English word lists, so they find nothing in another language. The `dash` rule is the one that matters here, because correct French, Polish or Russian punctuation is not an English dash. The tool works out the language of each file from `"language"` in `.slop.json`, from `--language`, from a `lang:` key in the front matter, or from the prose, which is detected for French, Spanish, German, Italian, Portuguese and Dutch. For text that is not English, `dash` does not run, and the output names the language it used. Dialogue dashes and dashes inside `« »`, `‹ ›` and `„ “` quotations are exempt. A quoted line of fewer than 12 tokens inside an English paragraph is not.

The French pack has one rule, `fr/closer`, and it is provisional. Spanish and German have no rule. The measured rates and the limits are in [docs/LANGUAGES.md](docs/LANGUAGES.md), and the candidate patterns with their counts are in [bench/packs/CANDIDATES.md](bench/packs/CANDIDATES.md).

## Configuration

`slop --init` writes a `.slop.json` to start from, `slop --init action` adds the pull request
workflow, and `slop --init hook` adds the commit-msg hook. None of them overwrites a file that
is already there.

A repository rarely wants one setting everywhere. Published docs can be held to a stricter
score than a changelog, and a file that quotes machine writing needs the rules that quote
turns on switched off. `overrides` are applied in order to any file whose path matches, and a
later entry wins:

```json
{
  "include": ["**/*.md"],
  "maxScore": 10,
  "ignore": ["emoji"],
  "overrides": [
    { "files": ["docs/**/*.md"], "maxScore": 3, "ignore": [] },
    { "files": ["CHANGELOG.md"], "ignore": ["bold-label", "emoji"] },
    { "files": ["**/prompts/*.md"], "only": ["chatbot", "cutoff-disclaimer"] }
  ]
}
```

On the command line, `--only dash,chatbot` runs those rules and nothing else, `--ignore`
(also spelled `--skip`) drops rules, and both reject a rule id that does not exist rather than
silently doing nothing. `--languages <tag>`, or `"languages"` in `.slop.json`, switches on a
rule pack for a language other than English; asking for one that does not exist is an error
rather than a quiet clean run. Inside a file, `<!-- slop-ignore-next-line RULE -->` covers one line
and `<!-- slop-ignore RULE-A, RULE-B -->` covers the rest of the file (`RULE` stands for a rule id).

A star hint appears once per machine in an interactive terminal; turn it off with `--quiet` or `AI_SLOP_LINTER_NO_STAR_HINT=1`.

## What the rules never see

Before any rule runs, the document is blanked in the places code lives: front matter in YAML
or TOML, fenced blocks including a fence inside a blockquote, code quoted by four-space or tab
indentation, inline code, link targets, bare URLs, HTML tags and the contents of `pre` and
`code`. A README that quotes a diff is the common case, and a diff line that starts with `-  `
is not a dash finding. Content indented under a list item stays visible, because list
continuation is prose more often than it is code.

## Fixes

`--fix` only does what cannot change meaning: dashes become commas or full stops, curly
quotes become straight, filler phrases become their plain form, and an invisible character
next to a space or punctuation is deleted (one between two letters is only reported). It runs to a fixed
point (fixing a fixed file changes nothing) and leaves every other finding for you,
because `not just X but Y` needs a rewritten sentence, not a character swap.

## In a repository

Commit messages and pull request descriptions are the writing this section is about, and
they are a harder register to read than an article. They are short, often templated, and
already the subject of a benchmark: Zhang, Liu, Di and Qian's
[CodeFuse-CommitEval](https://arxiv.org/abs/2511.19875) asks six models to spot commit
messages that contradict their diff, and reports average recall of 85.95% and precision of
80.28% against specificity of 63.8%, so on consistent commits the models raised a false
alarm about a third of the time. That number is the reason this tool is rule-based. A
rule that fires on a phrase it can point at is wrong in ways a reader can check.

Whether the tells this linter looks for survive into that register is an open question, and
[RESEARCH.md](RESEARCH.md) is the harness for answering it, along with the two runs done so
far and what they do not yet support.

**Pull requests.** The Action lints the pull request title, its description and every
changed Markdown file, annotates the lines on the files tab, and keeps one comment on the
pull request with the findings table, updated in place on every push:

```yaml
name: prose
on: pull_request
permissions:
  contents: read
  pull-requests: write   # read is enough with comment: "false"
jobs:
  slop:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: Bubblegunn/ai-slop-linter@v0
        # with:
        #   max-score: "5"
        #   baseline: ".slop-baseline.json"
        #   comment: "false"
        #   warn-only: "true"
```

| input | default | what it does |
|---|---|---|
| `files` | changed `.md` files | Files or globs to lint instead of the changed Markdown, separated by spaces, or one per line (`files: \|`) when a path contains a space; a path that does not exist fails the run |
| `pr-body` | `true` | Lint the title and description as one more text |
| `comment` | `true` | Post and update the review comment; needs `pull-requests: write` |
| `baseline` | none | A file from `--baseline-write`; only new findings fail |
| `max-score` | `10` | Fail above this weighted score per 1,000 words |
| `warn-only` | `false` | Annotate and comment, never fail |

The Action outputs `findings`, the total count, for a later step.

**pre-commit.** The repository ships both a Markdown hook and a `commit-msg` hook. `pre-commit` creates the Node environment and installs this repository at the pinned rev, so neither Node nor `ai-slop-linter` needs to be installed globally.

Pin the `-pre-commit` rev, not the plain release tag: it is the same release plus the built output, which is what `pre-commit` needs to find an executable. Paste these four lines into `.pre-commit-config.yaml`:

```yaml
repos:
- repo: https://github.com/Bubblegunn/ai-slop-linter
  rev: v0.2.1-pre-commit
  hooks: [{id: ai-slop-markdown}, {id: ai-slop-commit-msg}]
```

Run `pre-commit install` for Markdown hooks and `pre-commit install --hook-type commit-msg` for the commit message hook. Both hooks were written by [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) ([#27](https://github.com/Bubblegunn/ai-slop-linter/pull/27)).

**Commit messages.** A `commit-msg` hook refuses a message with an error-severity tell:

```
curl -fsSL https://raw.githubusercontent.com/Bubblegunn/ai-slop-linter/main/scripts/install-hook.sh | sh
```

Skip it once with `git commit --no-verify`. The hook reads the message file git hands
it; `npx ai-slop-linter --commit-msg "$1"` is all it does.

If commitlint already guards your messages, add the rule there instead of a second hook:

```js
// commitlint.config.js
export default {
  extends: ["@commitlint/config-conventional", "ai-slop-linter/commitlint/config"],
};
```

That turns on `ai-slop-linter/tells` as an error; `[2, "always", { maxScore: 10, ignore: [] }]`
is the full form if you want the threshold or the ignore list changed.

**Pull request bodies from the shell.** `npx ai-slop-linter --pr 42` reads the body
through `gh` and lints it like a file.

**Adopting it in a repository with history.** A baseline records the findings that are
already there, so the check fails only on new ones and nobody has to land a cleanup
commit first:

```
npx ai-slop-linter --baseline-write      # writes .slop-baseline.json
npx ai-slop-linter --baseline            # fails only on findings the baseline does not list
```

Entries are keyed by file, rule and the sentence, not by line number, so editing above
a known tell does not make it new. Commit the file; `"baseline": "path"` in `.slop.json`
names a different one. When a file is cleaned up, run `--baseline-write` again to shrink it.

**CI output.** `--format github` prints workflow annotations and an error line for every
text that fails; `--format markdown` prints the table the Action posts, for an issue or a
review; `--format json` prints findings with `fixable` flags for other tools.

**In the editor.** The text output is stable enough for a problem matcher, so VS Code shows
findings in the Problems panel and underlines them inline. Copy the task from
[.vscode/tasks.json](.vscode/tasks.json) into your own; the matcher is two patterns, the file
header line and the finding lines under it:

```json
"pattern": [
  { "regexp": "^(\\S.*?)  (?:[A-F] \\(score |not graded \\().*$", "file": 1 },
  { "regexp": "^\\s+(\\d+):(\\d+)\\s+(error|warning|info)\\s+(\\S+)\\s+(.*)$",
    "line": 1, "column": 2, "severity": 3, "code": 4, "message": 5, "loop": true }
]
```

Run it with Tasks: Run Test Task. A test pins the output format to these patterns.

## Your own writing, over time

`--history` reads the commit messages you have written in a repository and reports the tells
per 1,000 words, by month. It defaults to the address in that repository's git config, so
what it shows you first is your own writing.

```
npx ai-slop-linter --history
```

A real run, on this repository, for the author's public GitHub address (run on 2026-10-21 at commit abdace4):

```
50203466+Bubblegunn@users.noreply.github.com · 124 messages · 22,234 words · by month

period   messages   words  per 1,000  most common tell
--------------------------------------------------------
2026-09        81  15,693        1.6  dash (7), hyphen-density (4)
                                    ████████████████████████
2026-10        43   6,541        0.1  hyphen-density (3)
                                    ██
```

The table shows the tells per 1,000 words in one author's commit messages, month by month. It cannot tell you
what changed the number, and it cannot tell you who wrote anything.

`--author a@b.c,d@e.f` reads other addresses, which you need if your own commits are split
across several. `--by quarter` and `--by year` widen the period, `--since` narrows the
range, and `--format json` gives you the periods to chart.

**What this deliberately does not do.** There is no grade, no exit code and no per-person
column, and pointing it at another person takes a deliberate flag. This tool's position is
that it lists tells and never guesses who wrote the text, and a timeline is the shape most
likely to be screenshotted as an accusation. A number that decides nothing is harder to
point at someone. Trailers are not counted either, because a `Co-Authored-By` line is a
template rather than prose.

## For agents

Most of the tells above are written by coding agents, in the commit messages and pull
request descriptions they produce all day. The skill makes an agent lint its own prose
before it hands it over, and carries the rules in plain language for an agent with no
shell:

```
npx skills add Bubblegunn/ai-slop-linter
```

Or as a Claude Code plugin:

```
/plugin marketplace add Bubblegunn/ai-slop-linter
/plugin install ai-slop-linter@ai-slop-linter
```

The plugin also adds a `UserPromptSubmit` hook (`plugin/hooks/rewrite-prompt.sh`, POSIX `sh`, no network). When a prompt asks to rewrite, polish, proofread or humanize prose, in English or in French, Spanish, German, Turkish and a few other languages, it adds one line telling the agent to use the skill, to add no `we`, `our`, `I`, claim or fact the source lacks, to keep every number with its qualifier, to read the reference file for French, German or Spanish, and to run `--compare` until it exits 0; on any other prompt it prints nothing. It matches keywords, so it misses some phrasings: on prompts it was not tuned on it caught about half.

## From code

```ts
import { lintText, fixText } from "ai-slop-linter";

const r = lintText("post.md", text);
r.grade;              // "A" .. "F"
r.findings[0];        // { rule, severity, line, column, endLine, endColumn, excerpt, message, fix? }

const f = fixText("post.md", text);
f.text;               // fixed text
f.applied;            // number of fixes
```

`endLine` and `endColumn` mark the first position after a finding (1-based, UTF-16 code units, end exclusive); the `github` format adds `endColumn` when the finding ends on its own line. The end positions were added by [@Nitjsefnie](https://github.com/Nitjsefnie) ([#76](https://github.com/Bubblegunn/ai-slop-linter/pull/76)).

## Measured on a fixed corpus

`npm run bench` runs every rule over five texts written before 2021 and five pieces of unedited model
output, and writes [bench/PRECISION.md](bench/PRECISION.md). CI fails when the committed table is stale.
The method, the rule the first run found wrong, and every finding on the human texts are in
[docs/MEASUREMENT.md](docs/MEASUREMENT.md).

## Run on our own writing

The tool is run on the READMEs of the author's five repositories. The dated results are in
[docs/OWN-WRITING.md](docs/OWN-WRITING.md). This repository's own Markdown is linted in CI with
`maxScore` 3 (see `.slop.json`).

## What it cannot show

- A probability. The tool does not compute one, deliberately. Detectors that score text
  statistically misclassify people whose English is a second language: seven commercial
  detectors marked 61% of TOEFL essays written by human candidates as machine-written, while
  grading US eighth-grade essays almost perfectly
  ([Liang et al., *Patterns*, 2023](https://doi.org/10.1016/j.patter.2023.100779)). A named
  phrase on a numbered line can be disagreed with. A score cannot.
- Authorship. A person who writes `delve` gets the same finding as a model. Passing
  says the tells are absent; it says nothing about who typed.
- Meaning. It cannot tell a hollow paragraph from a good one when the hollow one
  avoids every listed phrase. Text that passes can still be empty.
- Style outside the list. Twenty-three rules cover the patterns editors flag most; a
  writer with a different tell passes. Add a rule when you find one.
- Other languages. The vocabulary rules are English, and the structural ones work anywhere. Outside English the `dash` and `title-case-heading` rules stand down. There is one provisional French rule and no Spanish or German rule, because no candidate passed the shipping limit, so the tool does not claim to detect model text in those languages. Hindi has since been tested against a corpus rather than assumed, and produced no rule worth shipping: see [the Hindi negative result](docs/hindi-rules-negative-result.md). See [CONTRIBUTING.md](CONTRIBUTING.md) and issue #1.

## Research

Two questions about this tool were answered by measurement rather than by argument, and both
answers are written down whether or not they flattered the tool.

- [Typography across languages](docs/typography-across-languages.md). The `dash` rule was
  penalising correct French, Polish and Russian punctuation. Measured across thirteen
  languages, then fixed by letting a repository declare its language.
- [A Hindi rule set, and why none of it shipped](docs/hindi-rules-negative-result.md), by
  [@shivam-070208](https://github.com/shivam-070208). Six families of candidate pattern tested
  against the CT² Hindi corpus (7,043 human articles, five models); every one rejected, with the
  reason stated. A frequency ratio is not a tell, and a pattern strong in one model is a fact
  about that model.
  Whether CT² itself could serve as a pre-2021 control is answered in
  [CT² Hindi publication dates](docs/hindi-ct2-publication-dates.md), also by
  [@shivam-070208](https://github.com/shivam-070208): its metadata cannot establish when the
  articles were published, so it cannot.

## Cite this

Every release is archived on Zenodo with a DOI, and `CITATION.cff` carries the same identifier, so GitHub's
"Cite this repository" button gives correct BibTeX and APA. Details: [docs/CITE.md](docs/CITE.md).

## GitHub code scanning (SARIF)

`--format sarif` writes SARIF 2.1.0 for GitHub code scanning. The workflow, the column convention and the
limits are in [docs/SARIF.md](docs/SARIF.md).

## Contributing

Adding a rule is one function, one fixture sentence and one table row; see
[CONTRIBUTING.md](CONTRIBUTING.md), which also describes the one-command release. Zero runtime
dependencies, Node 20 or newer, tests with `node:test`.

## Contributors

Outside contributions, by the people who wrote them:

- [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan): SARIF output and valid artifact URIs
  ([#41](https://github.com/Bubblegunn/ai-slop-linter/pull/41), [#63](https://github.com/Bubblegunn/ai-slop-linter/pull/63)),
  the `reference-markup` rule ([#46](https://github.com/Bubblegunn/ai-slop-linter/pull/46)), the pre-commit hooks
  ([#27](https://github.com/Bubblegunn/ai-slop-linter/pull/27)), the `bold-label` role-list documentation
  ([#72](https://github.com/Bubblegunn/ai-slop-linter/pull/72)) and the external-tools study design
  ([#73](https://github.com/Bubblegunn/ai-slop-linter/pull/73)).
- [@Nitjsefnie](https://github.com/Nitjsefnie): the `commit-procedural-assurance` rule
  ([#75](https://github.com/Bubblegunn/ai-slop-linter/pull/75)), the end position of every finding
  ([#76](https://github.com/Bubblegunn/ai-slop-linter/pull/76)) and a documentation page per rule, linked from SARIF
  ([#77](https://github.com/Bubblegunn/ai-slop-linter/pull/77)).
- [@shivam-070208](https://github.com/shivam-070208): the Hindi negative result
  ([#24](https://github.com/Bubblegunn/ai-slop-linter/pull/24)) and the CT² publication-date note
  ([#71](https://github.com/Bubblegunn/ai-slop-linter/pull/71)).

## Licence

MIT. Rule sources are credited in the table above and in `--rules`.

---

Source: docs/FAQ.md

# Frequently asked questions

[Back to the README](../README.md)

The answers are short. The README and the pages it links to have the detail.

## How do I stop a coding agent from putting em dashes and stock phrases in commit messages and pull request descriptions?

Check the text before it ships, and make the check run without anyone remembering it. Four options, smallest first:

- Lint the last commit message with `npx ai-slop-linter --commit`.
- Install the `commit-msg` hook, which refuses a message that has an error-severity tell: `curl -fsSL https://raw.githubusercontent.com/Bubblegunn/ai-slop-linter/main/scripts/install-hook.sh | sh`.
- Add the GitHub Action. It lints the pull request title, its description and every changed Markdown file, and keeps one comment on the pull request. Setup is in [In a repository](../README.md#in-a-repository).
- Install the agent skill with `npx skills add Bubblegunn/ai-slop-linter`, or the MCP server with `npx --yes ai-slop-linter --mcp`, so the agent lints its own text before it hands it over. The MCP server is in the unreleased 0.2.0. <!-- release-status -->

## How do I lint commit messages and pull request descriptions for AI-sounding phrases?

`npx ai-slop-linter --commit` reads the last commit message. `npx ai-slop-linter --commit-msg <file>` reads the message being written, which is what a `commit-msg` hook receives. `npx ai-slop-linter --pr 42` reads the body of pull request 42 through the `gh` command. These three paths also run five rules that only a commit message or a pull request description gets, such as a hedge ("this should fix") or a frame that adds nothing ("as part of this change").

## Can I run it in CI?

Yes. It exits 1 when a file has an error-severity finding or a score above `--max-score` (default 10), and 0 otherwise. The Action is `uses: Bubblegunn/ai-slop-linter@v0`. `--format sarif` writes SARIF for GitHub code scanning; see [SARIF](SARIF.md).

## Does it detect AI-written text?

No. It lists phrases and characters that readers have learned to associate with machine writing, and it never prints a probability. A person who writes `delve` gets the same finding a model does. Statistical detectors have misclassified people who write English as a second language ([Liang et al., Patterns, 2023](https://doi.org/10.1016/j.patter.2023.100779)). A named phrase on a numbered line can be disagreed with, and a score cannot.

## Does it send my text anywhere or call a model?

Linting reads the files you name and calls no model. `--pr` runs the `gh` command to read a pull request body. The browser playground runs the same engine in your tab and makes no request after it loads; two tests read the page source for anything that could send text out.

## Humans use em dashes. Why does it flag them?

It flags a character, not an author. The `dash` rule has severity `error`, so one em dash fails a run. That is the current default, and whether it should stay has not been decided. [bench/DASH-DEFAULT.md](../bench/DASH-DEFAULT.md) measures the alternatives on the corpus and ends with "Decision: pending". Turn the rule off with `--ignore dash`, or for part of a file with an ignore comment, described under [Configuration](../README.md#configuration).

## How many false alarms does it raise?

The committed corpus answers that for the rules as they stand. [bench/PRECISION.md](../bench/PRECISION.md) gives the rate of each rule on five texts written before 2021 and on model output, and lists every finding on the human texts. The corpus is small. [Measurement](MEASUREMENT.md) explains the method and what it cannot show.

## Does it work for languages other than English?

Partly. The `dash` and `title-case-heading` rules stand down for a file whose language is not English, because the em dash is ordinary punctuation in several languages and German capitalises every noun. The language comes from `"language"` in `.slop.json` or `--language`, from a `lang:` key in the front matter, or from the prose, which is detected for French, Spanish, German, Italian, Portuguese and Dutch. The vocabulary rules are English word lists and find nothing in other languages. A French pack ships one provisional rule; Spanish and German ship none. [Languages](LANGUAGES.md) has the measured rates and the limits, and [the candidate patterns](../bench/packs/CANDIDATES.md) lists what was tried.

## Does it flag French dialogue?

No, when the dialogue is written the way French writes it. A line that starts a paragraph with a dash followed by a space or a letter is dialogue, and the whole line is exempt from `dash`, including the dashes that set off a speaker tag. Text inside guillemets (`« »`) is exempt too. Two cases still get a finding: a dash that opens a continuation line of a paragraph that was hard-wrapped, and a short quoted line inside an English paragraph of fewer than 12 tokens. [Languages](LANGUAGES.md#the-limit) explains the limit and how to clear it.

## How do I check a rewrite did not change facts?

Run `npx ai-slop-linter --compare before.md after.md`. It lists each number, version, date, link, quotation, code span and name that the rewrite lost or added, and exits 1 when there is one. It also reports first-person words (`our`, `we`, `ich`, `nous`) that the rewrite added or dropped and claim words (`always`, `guaranteed`, `because`) that it added, so an "In benchmarks" that becomes "In our benchmarks" is caught; the flagged words are those that passed a measurement, listed in `bench/COMPARE-WORDS.md`. Keep a copy of the original before you edit it. The comparison reads the facts, not the wording, so it cannot tell you whether the rewrite is good; it only shows what changed in the facts. The skill does this after every rewrite.

## Can an AI agent use it directly?

Two ways. The agent skill tells an agent to lint its own commit messages, pull request descriptions and docs before it finishes: `npx skills add Bubblegunn/ai-slop-linter`. The MCP server gives an agent three tools, `check_slop`, `check_slop_file` and `explain_rule`, that return findings as JSON with the line, the column and the exact range of each safe fix. [mcp.md](mcp.md) has setup commands for Claude Code and Codex, and says that they were not run against those clients. The MCP server is in the unreleased 0.2.0. <!-- release-status -->

## Does it work with textlint, VS Code, commitlint and pre-commit?

- commitlint: `extends: ["@commitlint/config-conventional", "ai-slop-linter/commitlint/config"]`.
- pre-commit: the hooks `ai-slop-markdown` and `ai-slop-commit-msg`. Pin the tag that ends in `-pre-commit`.
- textlint: the package `textlint-rule-ai-slop` is in this repository and is not yet on npm. <!-- release-status -->
- VS Code: an extension in `vscode/` that builds to a `.vsix`. It is not on the Marketplace yet. <!-- release-status -->

## How do I turn off one rule, one line or one file?

Use `--ignore <rule>` on the command line, or `"ignore": ["rule"]` in `.slop.json`, with `overrides` for particular paths. `<!-- slop-ignore-next-line rule -->` covers one line, and `<!-- slop-ignore rule -->` covers the rest of the file. `npx ai-slop-linter --explain <rule>` says when a rule should be switched off.

## Can it fix what it finds?

`--fix` applies the safe fixes only: dashes become commas or full stops, curly quotes become straight quotes, and filler phrases become their plain form. A finding that needs a rewritten sentence stays a finding.

---

Source: docs/PAIN.md

# Where the problem is described in public

[Back to the README](../README.md)

The README opens with this sentence: "My coding agent keeps putting em dashes and stock phrases into commit messages, pull requests and READMEs even though my rules file bans them, and nothing checks the text before it ships." It is a paraphrase, not a quotation. The reports below are what it paraphrases. They were read on 2026-10-21. Open each link to check the wording.

## Issues filed by people who run coding agents on their own repositories

- [williamthorsen/codeassembly#1072](https://github.com/williamthorsen/codeassembly/issues/1072), opened 2026-07-22. The author's rule against em dashes "is repeatedly broken even when the rule is present in context", with em dashes recorded in READMEs, commit bodies, tickets and pull request bodies, and "nothing detects a violation before it ships".
- [NonoHM/budgetpilot#779](https://github.com/NonoHM/budgetpilot/issues/779), opened 2026-09-28. The issue says the repository's em dash test "is the only automated prose gate in the tree today" and asks for prose linting beyond that one character, with Vale. It shows demand for a prose check; it does not mention this tool.
- [MalloyTheDev/MalloyStudio#15](https://github.com/MalloyTheDev/MalloyStudio/issues/15), opened 2026-09-06. The repository's convention is that no em dash appears in committed content, commit messages included, and "85 tracked files still contain em dashes from before that rule".

## A wider thread about the same resentment

- [Stop Sloppypasta](https://news.ycombinator.com/item?id=47389570) on Hacker News, 667 points when read, posted 2026-03-15. Its author defines the term as "Verbatim LLM output copy-pasted at someone, unread, unrefined, and unrequested". The thread is about chat messages, not commits. It shows that readers resent unedited model text.

## What was not found

- No issue in a competitor's repository. `gh search issues "em dash" --repo tbhb/vale-ai-tells` returned nothing on 2026-10-21.
- No Stack Overflow question and no Reddit thread. Reddit refused automated reads, and Stack Overflow was not searched by hand.
- The three issues come from people who already automate against the problem. They show demand for a mechanical check. They do not show how many people have the problem.

---

Source: docs/mcp.md

# Use ai-slop-linter from an AI agent

`slop --mcp` starts a [Model Context Protocol](https://modelcontextprotocol.io) server on standard input and output. An agent that writes a commit message, a pull request description or a README can lint its own text and read the findings as JSON before it hands the text over.

```
npx --yes ai-slop-linter --mcp
```

## Tools

| tool | input | returns |
|---|---|---|
| `check_slop` | `text`, and optionally `language`, `ignore`, `only` | the findings for that text |
| `check_slop_file` | `file_path`, and the same options | the findings for that file |
| `compare_facts` | `before` and `after` (two texts), and optionally `language` | the facts the rewrite lost or added: numbers, versions, dates, urls, issue ids, quotations, code and names, first-person words (kind `person`) the rewrite added or dropped, claim words (kind `claim`) it added, an approximator or bound next to a number (kind `qualifier`, such as `up to 3`) that it dropped or added, and how many facts were kept. Set `language` for a text under about 25 words or in Turkish, which detection cannot tell |
| `explain_rule` | `rule` | why the rule exists, an example that trips it, a rewrite, and when to ignore it |

A finding has `rule`, `severity`, `line`, `column`, `endLine`, `endColumn`, `excerpt`, `message` and `fixable`. A fixable finding also carries `fix`, the character range (`start`, `end`, in UTF-16 code units) and the `replacement`, so the agent can apply it without guessing. The result also has `score` (weighted findings per 1,000 words), `grade` (null under 50 words) and `errors`.

`explain_rule`, `only` and `ignore` accept the ids of the language pack rules (`fr/closer`) as well as the English ones.

A rule id that does not exist, a file that cannot be read and a directory in place of a file come back as an error result with a message, never as a clean one.

## Set up

Claude Code:

```
claude mcp add ai-slop-linter -- npx --yes ai-slop-linter --mcp
```

Codex:

```
codex mcp add ai-slop-linter -- npx --yes ai-slop-linter --mcp
```

Any client that reads an `mcpServers` object:

```json
{
  "mcpServers": {
    "ai-slop-linter": { "command": "npx", "args": ["--yes", "ai-slop-linter", "--mcp"] }
  }
}
```

The tests in this repository speak the protocol to `slop --mcp` directly. The two commands above were not run against Claude Code or Codex when this page was written, so check them in your client.

## What it does not do

It lists tells and does not say who wrote the text. It implements `initialize`, `ping`, `tools/list` and `tools/call` and nothing else, so it offers no resources or prompts. The package still has no dependencies: the server is a separate module that the command loads only for `--mcp`.

---

Source: docs/SCORING.md

# How the score, the grade and the word count work

[Back to the README](../README.md)

Scoring by density rather than by severity is the one design choice here with empirical
support behind it, and it is worth saying where that support stops. Charoenwet,
Thongtanunam, Pham and Treude compared ways of ordering static-analysis warnings
([ISSTA 2024](https://arxiv.org/html/2407.12241v1)) and found warning density, warnings per
line of code, gave the largest improvement, up to 5.6% on recall and about 13% fewer initial false
alarms, while warning severity, the ordering developers reach for first, gave the lowest
improvement on every metric they measured. That study ranks warnings in changed functions
during security code review; this tool ranks findings per thousand words of prose. The shape
of the choice is the same and the evidence is from a different domain, so treat it as
support for the shape and not as proof about this tool.

The rest of the arithmetic has nothing behind it and should be read that way. The weights of
three, one and three tenths are a convention. So are the grade boundaries. The fifty-word
floor below which nothing is graded is arithmetic rather than a finding, and the reason for
it is in the next section.

### How words are counted

Scripts that put spaces between words are tokenised: a word keeps its accents, so
`değişiklik` and `naïve` are one word each rather than three and two.

Chinese, Japanese, Thai, Khmer, Lao, Burmese and Tibetan do not separate words with
spaces, so a run of them is handed to the platform's Unicode word segmenter, the same
`Intl.Segmenter` the browser and Node ship, which applies UAX #29 with ICU's dictionaries.
The alternative was a characters-per-word constant, and there is no number to put there
that anybody measured. Counts in those scripts therefore come from ICU and can move a
little between Node versions; the table in `bench/PRECISION.md` is regenerated in CI, so
a shift shows up as a failure rather than as a quietly different number. Where a build
has no `Intl.Segmenter`, two characters count as one word and the count is an estimate,
which counts fewer words and so grades a little harder, never easier.

A document under 50 words is not graded at all. Below that the denominator is small
enough that a single finding decides the letter, so the grade says more about the length
than about the writing: a twelve-word fragment with one em dash used to print an F. The
findings still stand, and an error-severity finding still fails the run; only the letter
is withheld. Commit messages and pull request descriptions are short by nature, so those
paths keep their score, because there the question is whether the text carries a tell and
not how dense the tells are.

Where the platform has `Intl.Segmenter` but ICU carries no word dictionary for the script,
which is what a build with small ICU gives you, the segmenter returns the whole run as one
segment. That is the same bug as before with no symptom, so the result is checked per run
rather than trusted: a run of twelve characters or more that comes back as a single word
falls to the estimate instead. The check is per run, so a build that has the dictionary for
one of these scripts and not another gets the right treatment for each.

One thing this measure cannot do is compare languages against each other. Turkish builds
long words out of suffixes, so the same text carries fewer of them than English does, and
one tell in a Turkish document scores higher than the same tell in its English
translation. The score is a comparison against other documents in the same language, not
across them.

### Where a finding points

Every finding carries a line and a column, and the column is a 1-based offset in UTF-16 code
units, counted from the start of the line in logical order. That is the same number your
editor's status bar shows, the same one GitHub uses for a workflow annotation, and the same
one a language server would report, so a tool that consumes the JSON can jump to the finding
without knowing anything about the script.

For a reader of Arabic or Hebrew this is worth saying plainly, because logical order is not
what the eye sees: the character at column 12 of a right-to-left line is the twelfth character
from the start of the text, which is drawn near the right edge, not the twelfth position from
the left of the screen. Counting columns the way the text is drawn would break every editor
that consumes them and would still be ambiguous in a line that mixes scripts. So the column
stays logical, and the finding carries an `excerpt` of the text it matched: for reading, the
excerpt is the reliable part, and the column is for the tool that jumps to it.

---

Source: docs/LANGUAGES.md

# Languages

[Back to the README](../README.md)

The vocabulary rules are English word lists, so on other languages they find nothing. The structural rules (quotes, bold labels, emoji, headings) work in any language. `dash` is the one rule that flags punctuation a writer in another language may type correctly, and it has the highest severity this tool has. This page says what the tool does about that, what was measured, and what it does not claim.

## What it does now

The tool works out the language of each file in this order, and the first answer wins:

1. Declared: `"language": "fr"` in `.slop.json`, or `--language fr` for one run. `auto` or an empty value is not a declaration.
2. Front matter: A `lang:` or `language:` key at the top of the file. A two-letter tag is used as written. The three-letter codes `fra`, `fre`, `spa`, `deu`, `ger`, `ita`, `por`, `nld`, `dut` and `eng` are mapped to their two-letter tags.
3. Detection: The prose, with code and front matter masked, is scored on function words for English, French, Spanish, German, Italian, Portuguese and Dutch. The file needs at least 25 tokens, the top language must account for at least 12 percent of the function words, and it must have at least 1.5 times the count of the next language. Otherwise the file is English.

`--language auto` is the default, so steps 2 and 3 run for each file. When the language came from detection, the output says so: `dash, title-case-heading did not run for notes.md: language fr (detected)`. Languages outside that list are not detected; declare them with `--language`.

Whatever the language, `--compare` works the same way. It reads numbers, versions, dates, links, quotations, code spans and names, not wording. Names are found by capitalisation, and spelled-out numbers are not read.

### Paragraphs

- In an English file, a paragraph of at least 12 tokens that is confidently written in another language has its English-only findings dropped. Only `dash` is English-only today.
- In any file, a paragraph whose first line starts with an em dash or an en dash followed by a space, a letter, `¿` or `¡` is dialogue. That whole line is exempt from `dash`, including the dashes that set off a speaker tag. A continuation line of a hard-wrapped paragraph is not dialogue, and keeps its finding.
- A dash inside a quotation in `« »`, `‹ ›` or `„ “` (or `„ ”`) is exempt when the quotation sits in one paragraph. English curly and straight quotes are not exempt.

### The limit

A short quoted line inside an English paragraph is not exempt when the paragraph has fewer than 12 tokens. The tool cannot tell that line from English prose. To clear it, lengthen the paragraph with its own text, or put `<!-- slop-ignore-next-line RULE -->` above the line, with `dash` in place of RULE, and say why in your answer.

## Packs

A language pack is a set of rules that runs only on text in its language. A pack rule ships only when it has zero findings on every human file of its language and at least one finding on the machine corpus, and both corpora are large enough to measure. [`bench/PACKS.md`](../bench/PACKS.md) is generated from the corpora, and `test/packs.test.mjs` enforces the limit.

| language | human files | human words | machine files | machine words | rules shipped |
|---|---:|---:|---:|---:|---|
| German (`de`) | 8 | 20,748 | 30 | 5,343 | none |
| Spanish (`es`) | 8 | 19,198 | 26 | 5,152 | none |
| French (`fr`) | 14 | 40,160 | 22 | 4,373 | one: `fr/closer` |

French, one rule, provisional. `fr/closer` flags a call to act followed by `dès aujourd'hui`, such as "Installez ... dès aujourd'hui" with up to two words between. Over the 40,160 human words it has no hit, 0 per 1,000 words. On the machine corpus it has four hits in four files, 0.9 per 1,000 words. The four files come from three invented products, and the pattern was chosen after seeing those hits, so the rule is provisional. It has not been tested on new machine text.

Spanish and German, no rule. No candidate passed the shipping limit. Nothing in this pack says that Spanish or German text is written by a model, and the tool does not claim to detect it in either language. The candidates that were measured, and why each was dropped, are in [`bench/packs/CANDIDATES.md`](../bench/packs/CANDIDATES.md). That file also says how to measure a new pattern.

## Typography

The `dash` rule was penalising correct French, Polish and Russian punctuation. The rates below are findings per 1,000 words on correct, published prose in each language, measured by [`bench/typography.mjs`](../bench/typography.mjs) and written to [`bench/TYPOGRAPHY.md`](../bench/TYPOGRAPHY.md). Each excerpt was set by a publisher or translator who followed that language's own conventions.

| language | `dash` /1k, as the files stand |
|---|---:|
| Polish | 73.5 |
| Hungarian | 1.9 |
| Russian | 6.5 |
| English (baseline) | 1.0 |
| German, Spanish, French, Italian, Portuguese, Dutch | 0 |

Some files reach us with the em dash typed as two hyphens, `--`, which the rule does not match. `bench/TYPOGRAPHY.md` measures those files twice, once as they are and once with each `--` restored to the em dash. Polish has 77 double hyphens, and restoring them moves its rate from 73.5 to 1. French has 25, and its rate stays at 0. Read the restored column as what a writer meets today, since nobody types a dash as two hyphens in a README.

These are the rates as the committed excerpts measure now, after the dialogue and quotation exemptions described above. They replace earlier figures that were taken before those exemptions.

## What stays unclaimed

- Outside English, the vocabulary rules are inert. A French README written entirely by a model passes them, because `in order to` is not a French phrase.
- No Spanish or German tell detection. Spanish and German have no rules in their packs.
- No threshold separates a writer from a model in any language. A rate is a rate, and a rule with a human hit is not shipped however often the model uses the phrase.
- A paragraph, or a quoted span of an English paragraph, of 8 or more words with no function word of any listed language (English included) and at least one letter outside ASCII is read as a language that has no word list, such as Turkish, and the `dash` rule skips it and `--fix` leaves it. A dash inside straight quotes is not exempt in general.
- A short non-English heading inside an English file is still judged by `title-case-heading`. The paragraph guess needs 12 word tokens, and a heading rarely has that many, so only a file whose own language is non-English (declared, in the front matter or detected) stands the rule down.
- Languages outside the seven that are detected get no packs and no automatic detection. Declare them and the `dash` and `title-case-heading` rules stand down; no other rule changes.

---

Source: docs/MEASUREMENT.md

# Measured on a fixed corpus

[Back to the README](../README.md)

A linter that raises many false alarms gets uninstalled, so the cost of a false alarm is measured and
committed rather than asserted. `bench/corpus` holds ten texts: five written before any
language model existed, three of them literary and scientific prose from the 1840s to 1860s
and two Python Enhancement Proposals from 2001, and five pieces of unedited model output in
the shapes this tool is pointed at. `npm run bench` runs every rule over both and writes
[`bench/PRECISION.md`](../bench/PRECISION.md), which lists the rate per rule, per 1,000 words, in
each corpus. CI fails when the committed table no longer matches a fresh run.

| corpus | words | grades |
|---|---:|---|
| human, all pre-2021 | 5,924 | A, A, B, B, B |
| unedited model output | 1,045 | F, F, F, F, F |

The first run found a rule that was wrong. `curly-quotes` flagged 92 lines of *Pride and
Prejudice*, which is all of Austen's dialogue, and nothing at all in the model corpus. Curly
marks are typography. What carries information is a file that mixes them with straight ones,
which is what pasting out of a chat interface leaves behind. The rule now stays quiet unless a
file mixes both, and Austen grades A.

Eight findings remain on the human corpus and the table names every one. Six are real dashes
in Darwin and Douglass and `--` in a 2001 style guide. One is `not just X but Y`, written by
Frederick Douglass in 1845. One is `it's worth noting`, written by Guido van Rossum in PEP 8.
None of them is a bug. They are the listed tells appearing in writing nobody would call slop,
which is the standing cost of leaving those rules switched on, and it is the reason the output
is a line number and a reason rather than a verdict about a person.

---

Source: docs/SARIF.md

# GitHub code scanning (SARIF)

[Back to the README](../README.md)

Use `--format sarif` to emit SARIF 2.1.0 for GitHub code scanning. SARIF output was written by [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) ([#41](https://github.com/Bubblegunn/ai-slop-linter/pull/41)), who also made file paths with spaces and non-ASCII names valid URIs ([#63](https://github.com/Bubblegunn/ai-slop-linter/pull/63)). Every rule is included in the run metadata with its description and source citation, a `helpUri` linking its own page on the site (`https://bubblegunn.github.io/ai-slop-linter/rules/<id>/`) and `help` text and markdown carrying the same material `--explain` prints; the rule pages and these fields are by [@Nitjsefnie](https://github.com/Nitjsefnie) ([#77](https://github.com/Bubblegunn/ai-slop-linter/pull/77)). Severities map directly: `error` to `error`, `warning` to `warning`, and `info` to `note`.

The run declares `utf16CodeUnits` for SARIF columns, matching the tool's documented 1-based UTF-16 column convention. Do not convert these columns to Unicode scalar values.

Example workflow:

```yaml
name: ai-slop-linter code scanning

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  security-events: write

jobs:
  slop:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: "24"
      - run: npm ci
      - run: npm run build
      - run: node dist/src/cli.js --format sarif --warn "**/*.md" > results.sarif
      - uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: results.sarif
```

One limit worth knowing before you point this at something unusual: the `artifactLocation.uri` in the output is the path you gave on the command line, so running from outside the repository root can produce `../` segments that code scanning will not accept. The workflow above runs at the root, where that cannot happen.

An earlier version of this example ran `sarif-multitool validate` before the upload. It is gone because it could not fail: it exited 0 on every broken file it was given ([#43](https://github.com/Bubblegunn/ai-slop-linter/issues/43)). This repository checks its own SARIF against the vendored 2.1.0 schema in `test/sarif-schema.test.ts` instead. The upload requires `security-events: write`; no broader write permission is needed. `--max-score` remains a separate repository policy and is not encoded as SARIF severity.

---

Source: bench/PRECISION.md

# Rule precision on a fixed corpus

Generated by `node bench/precision.mjs`. Do not edit by hand; CI fails when this
file does not match a fresh run.

## Method

Two corpora, both committed under `bench/corpus`:

The human corpus, 5,924 words, is public-domain prose written before any language
model existed: three literary and scientific works from the 1840s to 1860s, and two Python
Enhancement Proposals from 2001, which are the closest public-domain match to the register this
tool is aimed at, technical documentation kept in a repository.

The machine corpus, 1,045 words, is unedited model output written for this corpus in
five shapes the tool actually sees: a README section, a pull request description, an article
introduction, a docs page and a set of commit messages.

Every rule runs over both. The table reports findings per 1,000 words in each corpus and the
ratio between them.

What the rules never see: front matter (YAML or TOML), fenced code including a fence inside a
blockquote, code quoted by four-space or tab indentation, inline code, link targets, bare URLs,
HTML tags and the contents of `pre` and `code` blocks. All of it is blanked before any rule
runs, so a dash in a diff a README quotes is not a finding. The exception is content indented
under a list item, which stays visible because list continuation is prose more often than it is
code.

What these numbers are not: they are not detection accuracy, and no row says a text was
machine-written. A rule that fires on the human corpus is not necessarily wrong: `in order to`
is filler whoever typed it. The human rate measures how much noise a rule adds to writing that
is already good, which is the cost a maintainer pays to keep the rule switched on. The machine
corpus was written by one model in one sitting, so a high ratio shows a rule catches what that
output looks like, not what every model produces. A corpus this size cannot support a percentage.

## Per rule

| rule | severity | human /1k | machine /1k | ratio |
|---|---|---:|---:|---|
| `dash` | error | 1 | 5.7 | 5.7x |
| `chatbot` | error | 0 | 2.9 | clean |
| `cutoff-disclaimer` | error | 0 | 0 | no data |
| `not-x-but-y` | warning | 0.2 | 2.9 | 14.5x |
| `triad` | warning | 0 | 0 | no data |
| `reveal` | warning | 0 | 0 | no data |
| `ing-tail` | warning | 0 | 1 | clean |
| `inflated` | warning | 0 | 4.8 | clean |
| `ai-vocabulary` | warning | 0.2 | 38.3 | 191.5x |
| `sales` | warning | 0 | 2.9 | clean |
| `vague-source` | warning | 0 | 0 | no data |
| `announcing` | warning | 0 | 1.9 | clean |
| `closer` | warning | 0 | 0 | no data |
| `bold-label` | warning | 0 | 11.5 | clean |
| `emoji` | warning | 0 | 0 | no data |
| `filler` | info | 0 | 3.8 | clean |
| `curly-quotes` | info | 0 | 0 | no data |
| `title-case-heading` | info | 0 | 3.8 | clean |
| `challenges-section` | info | 0 | 0 | no data |
| `hyphen-density` | info | 0 | 0 | no data |
| `reference-markup` | error | 0 | 0 | no data |
| `chatbot-tracking` | error | 0 | 0 | no data |
| `invisible-character` | warning | 0 | 0 | no data |

`clean` means the rule never fired on the human corpus. `no data` means it fired on neither,
so this corpus says nothing about it. The machine corpus was typed as plain text, so the
typography rules (`curly-quotes`, `emoji`) are not exercised by it; their rows say nothing
about how well they work.

## Every finding on the human corpus

<!-- slop-ignore not-x-but-y ai-vocabulary -->

The full list, so the cost of running these rules on good writing is visible rather than
summarised.

| file | line | rule | message |
|---|---:|---|---|
| `human/darwin-origin-of-species.md` | 5 | `dash` | em dash |
| `human/darwin-origin-of-species.md` | 9 | `dash` | em dash |
| `human/douglass-narrative.md` | 5 | `dash` | em dash |
| `human/douglass-narrative.md` | 7 | `dash` | em dash |
| `human/douglass-narrative.md` | 11 | `not-x-but-y` | "not just X but Y": say the thing itself |
| `human/pep-257-docstrings.md` | 41 | `dash` | double hyphen used as a dash |
| `human/pep-8-style-guide.md` | 15 | `dash` | double hyphen used as a dash |
| `human/pep-8-style-guide.md` | 31 | `ai-vocabulary` | "it's worth noting": a word models reach for; use the plain one |

Most of these are real dashes in nineteenth-century prose and `--` in a 2001 style guide.
They are not mistakes by the rule: the dashes are there. They are a house rule for text in a
repository, and this table is the argument for keeping that rule at the severity it has rather
than a claim about who wrote Darwin.

## Per file

| file | words | grade | score | findings |
|---|---:|---|---:|---:|
| `human/austen-pride-and-prejudice.md` | 1120 | A | 0 | 0 |
| `human/darwin-origin-of-species.md` | 1198 | B | 5 | 2 |
| `human/douglass-narrative.md` | 1202 | B | 5.8 | 3 |
| `human/pep-257-docstrings.md` | 1174 | A | 2.6 | 1 |
| `human/pep-8-style-guide.md` | 1230 | B | 3.3 | 2 |
| `machine/article-intro.md` | 175 | F | 129.1 | 22 |
| `machine/commit-messages.md` | 225 | F | 75.6 | 13 |
| `machine/docs-page.md` | 225 | F | 56 | 10 |
| `machine/pull-request.md` | 161 | F | 47.2 | 7 |
| `machine/readme-section.md` | 259 | F | 137.5 | 31 |

## The same tell in seven scripts

Each file under `bench/scripts` is one paragraph of release notes carrying exactly one em
dash, written for this table. It exists because the word count used to be a Latin-alphabet
character class, which read a run of Japanese as one word and Hebrew or Arabic as none, so
every document in those scripts scored 60 and graded F whatever its length or content.

| script | characters | words | score | grade |
|---|---:|---:|---:|:--:|
| `ar` | 831 | 146 | 0 | A |
| `en` | 1027 | 193 | 15.5 | D |
| `he` | 766 | 143 | 0 | A |
| `ja` | 465 | 240 | 12.5 | C |
| `ko` | 506 | 129 | 0 | A |
| `tr` | 1011 | 142 | 0 | A |
| `zh` | 331 | 176 | 17 | D |

Words in Chinese, Japanese, Thai and the other scripts that do not separate words come from
the platform's Unicode segmenter, so those rows can move a little with the ICU version Node
carries; that is why they are checked here rather than assumed. The spread between scripts is
real and is not a defect: a language that packs more meaning into each word carries fewer of
them, so one tell weighs more. The score compares documents within a language.

## Sources

See [`corpus/MANIFEST.md`](corpus/MANIFEST.md) for the origin and licence of every file.

---

Source: bench/COMMIT-RULES.md

# Commit-message rules measured on human commits

Generated by `node bench/commit-rules.mjs`. Do not edit by hand.

The rules in `src/commits.ts` use phrase lists adapted from [tbhb/vale-ai-tells](https://github.com/tbhb/vale-ai-tells) (MIT), as `src/commit-phrases.ts` states. A list written for another tool is only useful here if it stays quiet on commit messages people wrote, so each rule was run on commits from before 2021.

## Method

| source | tag | commit | licence | tuning messages | held-out messages |
|---|---|---|---|---:|---:|
| [django](https://github.com/django/django) | 3.1.4 | `fa206fe72220` | BSD-3-Clause | 1000 | 1000 |
| [flask](https://github.com/pallets/flask) | 1.1.2 | `93dd1709d05a` | BSD-3-Clause | 1000 | 1000 |

Messages are read from shallow clones in `_upstream/` and are not copied into this repository. The tuning messages are the newest ones before the tag; a phrase that matched any of them was left out. The held-out messages are the next ones back in time. They were not used to choose phrases, so their hit count is the honest estimate: after tuning, the tuning count is zero by construction and says nothing about unseen text.

A rule was adopted when it had no hit on the tuning messages after the left-out phrases were removed, at most one hit across the held-out messages of all sources, and at least ten phrases remaining (a regular expression rule is judged on hits alone). The model column counts the 4 commit messages in `bench/corpus/machine/commit-messages.md`.

## Per rule

Hits are messages with at least one finding, listed per source in the order django, flask.

| rule | phrases kept | tuning hits | held-out hits | model messages | phrases the prose rules already flag | decision |
|---|---|---|---|---|---|---|
| `commit-self-reference` | 64 of 70 | 0 / 0 | 1 / 1 | 1 of 4 | 0 of 70 | not adopted: 2 messages in the held-out sample, over the limit of 1, even after the phrases that matched the tuning sample were left out |
| `commit-hedging` | 38 of 38 | 0 / 0 | 0 / 1 | 0 of 4 | 0 of 38 | adopted |
| `commit-overexplanation` | 43 of 43 | 0 / 0 | 0 / 0 | 0 of 4 | 0 of 43 | adopted |
| `commit-marketing` | 25 of 25 | 0 / 0 | 0 / 0 | 0 of 4 | 2 of 25 | adopted |
| `commit-buzzwords` | 77 of 80 | 0 / 0 | 0 / 3 | 0 of 4 | 9 of 80 | not adopted: 3 messages in the held-out sample, over the limit of 1, even after the phrases that matched the tuning sample were left out |
| `commit-trailing-justification` | 70 of 70 | 0 / 0 | 0 / 0 | 1 of 4 | 1 of 70 | adopted |
| `commit-test-enumeration` | regex | 0 / 1 | 0 / 0 | 0 of 4 | n/a | not adopted: matched a human commit message in the tuning sample, and a regular expression has no phrase to drop |
| `commit-unquantified` | regex | 0 / 0 | 0 / 0 | 1 of 4 | n/a | adopted |
| `commit-procedural-assurance` | regex | 0 / 0 | 0 / 0 | 0 of 4 | n/a | adopted |

## Phrases left out

| rule | phrase | tuning messages it matched |
|---|---|---:|
| `commit-self-reference` | `In this commit` | 1 |
| `commit-self-reference` | `This change addresses` | 1 |
| `commit-self-reference` | `This commit adds` | 1 |
| `commit-self-reference` | `This commit enhances` | 1 |
| `commit-self-reference` | `This commit implements` | 1 |
| `commit-self-reference` | `This commit removes` | 1 |
| `commit-buzzwords` | `properly handle` | 2 |
| `commit-buzzwords` | `corresponding tests` | 2 |
| `commit-buzzwords` | `update relevant` | 1 |

## What this does not show

- Two projects, both BSD-licensed Python, both with strong commit conventions. A project with another habit, or a person writing "This commit fixes ..." on purpose, will see findings. That is why these are warnings and can be switched off with `--ignore`.
- The model sample is 4 messages written for this repository. It shows the rules fire on that kind of text, not how often they fire on all model output.

---

Source: bench/DASH-DEFAULT.md

# Should `dash` fail the build by default?

Generated by `node bench/dash.mjs`. Do not edit by hand; CI fails when this file does not match a fresh run. No default has been changed. This is the evidence for a decision that belongs to the maintainer.

## The question

`dash` has severity `error`, so one em dash in a file fails the run (exit 1) whatever the rest of the file looks like. The topic is contested: readers argue that people have always used the em dash. This file measures what each alternative would do on the committed corpus (`bench/corpus`), nothing more.

## Files that fail, by policy

A file fails when it has an error-severity finding or its score is above the limit. Limit 10 is the default; 3 is what this repository sets in `.slop.json`.

| policy | human, limit 10 | machine, limit 10 | human, limit 3 | machine, limit 3 |
|---|---|---|---|---|
| dash is an error (today) | 4 of 5 | 5 of 5 | 4 of 5 | 5 of 5 |
| dash is a warning | 0 of 5 | 5 of 5 | 0 of 5 | 5 of 5 |
| dash is off | 0 of 5 | 5 of 5 | 0 of 5 | 5 of 5 |

Human files are texts written before 2021. Machine files are unedited model output. Lower is better for the first and third columns; higher is better for the second and fourth, if failing on model output is what you want.

Under today's policy these human files fail at the default limit:

- `human/darwin-origin-of-species.md`
- `human/douglass-narrative.md`
- `human/pep-257-docstrings.md`
- `human/pep-8-style-guide.md`

## Dashes per file

| file | words | dashes | per 1,000 words |
|---|---:|---:|---:|
| `human/austen-pride-and-prejudice.md` | 1120 | 0 | 0 |
| `human/darwin-origin-of-species.md` | 1198 | 2 | 1.7 |
| `human/douglass-narrative.md` | 1202 | 2 | 1.7 |
| `human/pep-257-docstrings.md` | 1174 | 1 | 0.9 |
| `human/pep-8-style-guide.md` | 1230 | 1 | 0.8 |
| `machine/article-intro.md` | 175 | 1 | 5.7 |
| `machine/commit-messages.md` | 225 | 1 | 4.4 |
| `machine/docs-page.md` | 225 | 1 | 4.4 |
| `machine/pull-request.md` | 161 | 0 | 0 |
| `machine/readme-section.md` | 259 | 3 | 11.6 |

## Option: error only above a density

Files at or above each density of dashes per 1,000 words, which is the set that would keep the error if the rule only fired from that density up:

| dashes per 1,000 words | human files | machine files |
|---:|---:|---:|
| 0.5 | 4 | 4 |
| 1 | 2 | 4 |
| 2 | 0 | 4 |
| 3 | 0 | 4 |
| 5 | 0 | 2 |
| 10 | 0 | 1 |

## What this does not show

- 5 human files and 5 model files. Some of the human files are more than a century and a half old, and none is a modern README.
- It does not measure how readers react. Whether a failing build over one dash costs more goodwill than it saves is a judgement about people, not a number here.
- It does not say a file was written by a model. It counts a character.

## Decision needed

Pick one, and the change is one line in `src/rules/dashes.ts` plus the tests that name the severity:

1. Keep `error`. Put the one-line reason and the off switch (`--ignore dash`) on the first screen of the README.
2. Make it `warning`. A dash then counts one towards the score and fails a file only when the score is above the limit.
3. Keep `error` but only from a density (the table above), which needs a new threshold option.
4. Turn it off by default. The rule stays available with `--only dash`.

Decision: pending.

---

Source: CONTRIBUTING.md

# Contributing

ai-slop-linter lists the patterns that mark text as machine-made, with line numbers, and fixes the safe ones. Contributions are welcome, and a new rule with a fixture is the easiest kind to merge.

## Running the tests

```
npm ci
npm test        # tsc build, then node:test over the compiled tests
```

Node 20 or newer and git are required (one CLI test builds a throwaway repository). Node 20's test runner does not expand glob patterns, so test files are named explicitly in `package.json`.

## Adding a rule

1. Pick the file in `src/rules/` that fits (`dashes`, `constructions`, `vocabulary`, `residue`, `formatting`) or add a new one.
2. Export a `Rule`: `id` (kebab-case, stable, it appears in `.slop.json` and ignore comments), `title`, `severity`, `source`, and `check(doc)`. Use `scan()` from `src/doc.ts` for regex rules; it handles masking, positions, excerpts and suppression for you.
3. Add it to the `rules` array in `src/index.ts`.
4. Add one sentence to `test/fixtures/sloppy.md` that triggers it, and make sure `test/fixtures/clean.md` still grades A. The first test asserts that every rule fires on the sloppy fixture, so a rule with no fixture line fails the suite.
5. Add a row to the rule table in `README.md`, with its source.

Rules must name a source. Most come from Wikipedia's [Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing) page; a house rule says so in its `source` field.

A fix (`Finding.fix`) is only allowed when applying it cannot change meaning: a character swap, a deletion of a phrase that carries no information. Anything that needs a sentence rewritten stays a finding without a fix.

## Adding a language

The vocabulary rules (`ai-vocabulary`, `sales`, `filler`, `chatbot`, `closer`, `announcing`) are
English word lists. The structural rules (`dash`, `curly-quotes`, `bold-label`, `emoji`,
`title-case-heading`, `hyphen-density`) already work in any language.

1. Bring the source first, in the issue, before writing any rules. Wikipedia's *Signs of AI
   writing* exists in German, Finnish, French, Hebrew, Russian, Thai and Chinese; those are
   sourceable from the same authority the English rules use. There is no equivalent page in
   Turkish, Japanese, Korean, Hindi or Arabic, so a pack in one of those needs a different
   published source: a publisher or newspaper style guide, a university writing guide, or an
   academic paper on machine-generated text in that language. A list assembled from your own
   reading cannot go in. That constraint applies to the maintainer too.
2. Write `src/rules/<lang>.ts` exporting the rules, each with an id prefixed by the language,
   so `zh/chatbot` and never `chatbot`.
3. Register it in `languageRules` in `src/index.ts`, and have your test call
   `checkLanguagePack("<lang>", pack)` and assert it returns no problems.
4. Add a fixture, `test/fixtures/sloppy.<lang>.md`, with one sentence per rule, and a test that
   the rules fire there and that the file is clean when the language is not switched on.
5. Measure the pack before it ships. The human corpus for the language is every
   `bench/typography/<lang>-*.md` file and every file under `bench/packs/human/<lang>/`, with
   its source recorded in `bench/packs/MANIFEST.md`. The machine corpus is the files under
   `bench/packs/machine/<lang>/`, unedited model output with the prompt recorded beside them.
   Run `node bench/packs.mjs` to write `bench/PACKS.md`. A rule ships only if it produces zero
   findings on every human file of its language and at least one on the machine corpus, and each
   corpus holds at least 500 words (human) or 300 words (machine), so a rate is never read off a
   handful of sentences. Detectors
   are known to misjudge writing by people who are not writing in their first language ([Liang et
   al., 2023](https://www.cell.com/patterns/fulltext/S2666-3899(23)00130-7)), which is why the
   human side of that limit is the one that cannot be relaxed.
   The shipping limit is enforced by `test/packs.test.mjs` for every registered pack. The French
   pack ships one provisional rule; Spanish and German ship none. The patterns measured for those
   three languages, with their counts and verdicts, are in [bench/packs/CANDIDATES.md](bench/packs/CANDIDATES.md),
   which also says how to measure a new one.
6. Add the rows to the rule table in `README.md`, with sources.

A pack switches on from the language of the file: a file declared, or detected from its front
matter or prose, in that language runs the pack on the whole text, and an English file runs it on
any paragraph detected as that language. `--languages <tag>` or `"languages"` in `.slop.json` still
runs a pack on every file. An English-only file never sees a finding from another language's list.

## Severity

- `error`: a tell that is always wrong in shipped text and has no honest use (em dashes in prose, chatbot residue). Fails the run.
- `warning`: a construction a careful writer would rewrite. Counts 1 towards the score.
- `info`: a tidy-up. Counts 0.3.

## What a review here looks like

This is a promise about how your pull request is treated, written down so you can hold it to it.

**You get a real review, quickly.** Not a rubber stamp and not a queue. Every outside pull request so
far has been reviewed the same day it arrived.

**Your work gets checked, not admired.** If you send a test, it gets run against something broken to
confirm it fails; if you send a fix, the bug gets reproduced before and after. When that happens the
review says exactly what was tried, and the throwaway code used to check it is handed to you, because
it is more useful in your hands than in a maintainer's terminal.

**A change request comes with the answer**, not just the objection: the file, the shape, and why the
first idea does not work. If something is refused, the reason is given plainly and whatever part of
your work was used still gets the credit.

**A review written against a stale commit is a mistake on this side.** If you push while a review is
being written, say so; the review will be redone against your head rather than leaving you to argue
with a comment about a bug you already fixed.

**Your name goes in three places when it merges**, and stays there: the changelog entry, the README
beside the thing you built, and the commit itself, because merges are squashed with your authorship
intact and never rewritten under someone else's name.

**You are told when it ships.** A merge is a promise; a release is the thing you can show somebody. You
get a message on your pull request with the version and the install command when your code is live.

**And you get thanked, in words.** Reviewing your code carefully is respect, but it is not the same as
saying thank you, and both are owed.

## Pull requests

- One change per pull request, with a test that fails before and passes after.
- Say in the description what a user sees differently; the template asks for it.
- Keep the package dependency-free unless the issue discussing the dependency was accepted first.
- No em dashes in shipped text (README, help, output). The linter runs on this repository's own Markdown in CI, so it will tell you.
- Contributors are credited in the changelog entry for the release that ships their change.

## Releasing

Maintainers only. One command; the workflow does the rest.

1. In one commit, write the `## X.Y.Z (unreleased)` entry in `CHANGELOG.md` and set the version to `X.Y.Z` in `package.json`, `package-lock.json`, `CITATION.cff`, the `version` input default in `action.yml`, `.claude-plugin/plugin.json`, `textlint-rule-ai-slop/package.json` (its own version, and its `ai-slop-linter` dependency written as `^X.Y.Z`) and `vscode/package.json`. Merge that commit and let CI finish. The gate described below compares each of those files against the CHANGELOG heading on every push, so a commit that moves the heading by itself turns CI red and blocks the release it was meant to start. `npm run release` then finds those files already carrying the target version, which is expected and not an error.
2. On a clean, green `main`: `npm run release -- X.Y.Z` (or `patch`, `minor`, `major`; add `--dry-run` to see the plan). It dates the entry, sets the version in `package.json`, `CITATION.cff`, the `version` input default in `action.yml` and `.claude-plugin/plugin.json`, runs the tests, commits, tags `vX.Y.Z`, pushes, and then moves the major tag (`v0` today) to the release and force-pushes it, so `uses: Bubblegunn/ai-slop-linter@v0` follows the newest release in that major. The major tag moves from this command and not from the workflow because release tags are admin-only by ruleset; a workflow token could not move it.
3. Watch the `release` workflow: it publishes to npm with provenance, creates the GitHub release from the CHANGELOG entry, and installs the published version from the registry on three operating systems.

CI runs `scripts/release-gate.mjs` on every push: the version must agree across those files and `npm pack` may ship only the paths in `scripts/pack-allowlist.txt` (regenerate with `node scripts/release-gate.mjs --update` when the package layout changes on purpose).

The workflow uses npm trusted publishing and holds no token. Before the first tagged release the maintainer configures the trusted publisher on npmjs.com: package settings, Trusted publishing, GitHub Actions, repository `Bubblegunn/ai-slop-linter`, workflow `release.yml`, "Allow npm publish" ticked.

Download now (ZIP)

  • In this repositoryOne other file it gives its agents.

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.