{"slug": "i-wrote-a-linter-for-ai-writing-tells-it-gave-one-of-my-own-readmes-a-c", "title": "I wrote a linter for AI-writing tells. It gave one of my own READMEs a C.", "summary": "A developer built ai-slop-linter, a zero-dependency Node 20+ CLI and browser tool that flags stylistic tells of AI-generated writing in READMEs and commit messages. The linter ships twenty rules — eighteen drawn from Wikipedia's \"Signs of AI writing\" guideline and two house rules — across three severity levels, and it gave one of the developer's own READMEs a C grade.", "body_md": "A pull request description came back from my coding agent and I read it the way the reviewer would. An em dash in the first sentence. A bold label on every bullet. `Let me know if you need anything else` at the bottom. I deleted all of it by hand, the same pass as the day before. Readers have learned these tells. Once they see one, they stop reading, whoever typed it.\n\nSo I wrote the pass down as a linter.\n\n```\nnpx ai-slop-linter README.md          # one file, exit 1 if it has errors\nnpx ai-slop-linter --commit           # the last commit message\nnpx ai-slop-linter README.md --fix    # apply the safe fixes in place\n```\n\nZero dependencies, Node 20 or newer, MIT. Source: [github.com/Bubblegunn/ai-slop-linter](https://github.com/Bubblegunn/ai-slop-linter).\n\nTo read the rules against your own text before installing anything, paste it into [bubblegunn.github.io/ai-slop-linter](https://bubblegunn.github.io/ai-slop-linter/). The page runs this engine in your own browser: there is no request in it that could carry your text anywhere, and it loads nothing from a third party. Two tests assert that, and the deploy builds the page from the repository rather than from anything uploaded by hand.\n\nThe test folder has a 259-word file written to trip every rule once. The first eleven of its fifty findings, at version 0.1.4:\n\n```\ntest/fixtures/sloppy.md  F (score 194.2, 259 words, 50 findings)\n     5:1   info    title-case-heading   Title Case heading; sentence case reads as written by a person\n     7:1   warning announcing           \"Let's dive into\": make the point instead of announcing it\n     7:26  error   dash                 em dash\n     7:32  warning inflated             \"is a testament to\": say what happened; let the reader judge the importance\n     7:37  warning ai-vocabulary        \"testament\": a word models reach for; use the plain one\n     7:72  warning ai-vocabulary        \"In today's fast-paced\": a word models reach for; use the plain one\n     7:83  info    hyphen-density       7.7 hyphenated compounds per 100 words; drop the hyphen after the noun (\"the report is high quality\")\n     7:115 warning inflated             \"stands as a\": say what happened; let the reader judge the importance\n     7:127 warning inflated             \"pivotal moment\": say what happened; let the reader judge the importance\n     7:127 warning ai-vocabulary        \"pivotal\": a word models reach for; use the plain one\n     7:156 warning ing-tail             \", highlighting\": cut the tail or make it its own sentence with a fact in it\n```\n\nEach finding has a line, a column, a severity, a rule id and a sentence saying what to do. The sentence matters more to me than the id. `say what happened; let the reader judge the importance` is something a person can act on. A probability is not. `--explain dash` prints why that pattern reads as a tell, a before and after, and when to ignore it.\n\nTwenty rules. Eighteen come from the Wikipedia guideline [Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), written by the editors who review machine-written edits. Each rule names its section: `dash` cites \"Em dashes\", `vague-source` cites \"Vague attributions\", `closer` cites \"Generic conclusions\". `npx ai-slop-linter --rules` prints the list with the source next to each id.\n\nThe other two are house rules and are marked as such in that output, so nobody mistakes my taste for the guideline: `reveal`, for `The real question is` and `At its core`, and `announcing`, for `Let's dive in` and `In this article we will`.\n\nThree severities. Errors should never ship: dashes, chatbot residue such as `I hope this helps`, knowledge-cutoff disclaimers. Warnings need a sentence rewritten: `not just X but Y`, forced groups of three, inflated importance, sales language, vague sources, bold labels in lists. Info is tidying: filler phrases, curly quotes, Title Case headings, too many hyphenated compounds.\n\nFenced 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. This article quotes every tell inside a code span for that reason, and it passes its own linter.\n\nA file's score is its weighted findings per 1,000 words: an error counts 3, a warning 1, an info 0.3. The grade follows the score: A under 3, B under 8, C under 15, D under 30, F above that. The fixture scores 194.2, an F by a wide margin, as designed.\n\nThe run exits 1 when any file has an error or a score above `--max-score`, which defaults to 10. A repository can adopt it in CI without a cleanup commit first and tighten the threshold later. A `.slop.json` at the root holds the defaults; this repository's own sets `maxScore` to 3 and lints its own Markdown in CI.\n\n`--fix` does only what cannot change meaning. An em dash becomes a comma or a full stop, or disappears after punctuation. Curly quotes become straight. `in order to` becomes `to`. On the fixture that is seven fixes. The other forty-three findings stay, because a negative parallelism needs a rewritten sentence, and a tool that rewrites your sentences is a different tool with a different failure mode. The fixer runs to a fixed point: fixing a fixed file changes nothing.\n\nThe Action lints the pull request description and every changed Markdown file, and annotates the diff at the line:\n\n```\n- uses: Bubblegunn/ai-slop-linter@v0\n  # with:\n  #   max-score: \"5\"\n  #   warn-only: \"true\"\n```\n\nThe commit hook is one line, `ai-slop-linter --commit-msg \"$1\"`, and refuses a message with an error-severity tell. `git commit --no-verify` skips it once. `--pr 42` reads a pull request body through `gh` and lints it like a file.\n\n`--init` writes a `.slop.json` and whichever of those you pick. Inside it, `overrides` set rules per path, so documentation can be stricter than commit messages, and `--baseline` records what a repository already has so CI fails only on new findings. There is a commitlint plugin and a VS Code task for the same output.\n\nThere is also a skill, installed with `npx skills add Bubblegunn/ai-slop-linter`, that tells a coding agent to lint its own prose before handing it over. That is where most of these tells get written in the first place.\n\nBefore posting I ran it on the five READMEs I maintain, this one included, and the eleven essays on my site, at version 0.1.4 on 5 September 2026, with `--warn`. The documents keep growing, so the version and the date are part of the claim; the same table is in the repository and is re-run rather than edited. The current release is 0.1.5, and `src/` is byte for byte what 0.1.4 shipped, so these numbers are what you get today as well:\n\n| text | words | grade | findings | \n|---|---|---|---|\n| [proactive-gate](https://github.com/Bubblegunn/proactive-gate) README | 4,871 | A (0) | none | \n| [workproof](https://github.com/Bubblegunn/workproof) README | 4,121 | A (0) | none | \n| [ai-slop-linter](https://github.com/Bubblegunn/ai-slop-linter) README | 3,744 | A (0) | none | \n| [surviving-lines](https://github.com/Bubblegunn/surviving-lines) README | 2,002 | A (0) | none; the first run scored A (2.8) for 2 bold labels in a list | \n| [product-engineer](https://github.com/Bubblegunn/product-engineer) README | 1,755 | A (0) | none; the first run scored C (8.7) for 7 bold labels in the rule list | \n| 8 of 11 portfolio essays | 854 to 1,849 each | A (0) | none | \n| the other 3 essays | 866 to 1,601 | A (0.4 to 1.2) | `state-of-the-art` once,`elevated` once,`in order to` twice | \n\nThe C was real. The product-engineer README listed its seven rules as `**Name:** text` bullets, which is exactly the pattern `bold-label` flags. I wrote those bullets myself and thought they read fine. The linter disagreed, with a line number. Later the same day that README was rewritten to show the tool before explaining it, and the rule list became plain sentences on the way; it was not edited to please the linter, and the row keeps the first score so the table stays a record of runs rather than a trophy.\n\nThe vocabulary rules are English word lists, so outside English they simply never fire. I had assumed the typography rules were the portable half: a dash is a dash, a curly quote is a curly quote. Before publishing I checked, by running all twenty rules against correct published typography in thirteen languages, one public-domain text each, all typeset before any language model existed.\n\n`dash` carries the highest severity in this tool. Here is what it did per 1,000 words:\n\n| language | `dash` findings per 1,000 words | \n|---|---|\n| Polish | 73.5 | \n| Hungarian | 52.1 | \n| Russian | 24.0 | \n| French | 22.0, see below | \n| German | 6.7 | \n| English (human baseline) | 1.0 | \n\nOne caveat belongs here rather than in a footnote, because I would want it if I were reading this. Polish, Hungarian, Russian and German are what the files measure as they stand. The French text reaches us with its em dashes typed as two hyphens attached to the following word, which the rule does not match, so it scores 0 as transcribed and 22.0 once the dashes the printed edition set are restored. The repository prints both columns. The restored figure is the one a French writer meets today, because nobody types a dash as two hyphens in a README.\n\nThe Polish, Hungarian and Russian texts graded F for punctuating their own language correctly. The em dash is ordinary in French, opens dialogue in Spanish, and in Russian stands where the verb would go. `curly-quotes` did the same to Chinese at 21.7 per 1,000 words, because those code points are Chinese quotation marks and the straight apostrophes nested inside them are correct, not a paste artifact.\n\nSo a repository now declares what it writes in, `\"language\": \"fr\"` in `.slop.json` or `--language fr`, and `dash` stands down. The run prints which rules did not run, so a quiet result is never mistaken for a clean one. English stays the default, so nothing changes for anyone already using it. It is configuration rather than detection on purpose: a README with English headings over French prose defeats a guess in both directions.\n\nI tried a density threshold first, so the rule could stay on everywhere and only fire when dashes were unusually dense. It does not work, and the corpus says why: correct German prose sits at 6.7 findings per 1,000 words and the machine corpus sits at 5.7. The distributions overlap. No global number separates a German writer from a model.\n\nThat leaves a real cost, and it belongs here rather than in a footnote: **a machine-written French README will no longer be flagged for its dashes.** I would rather lose that than grade a French writer's correct punctuation an F.\n\nThe corpus, the table and the argument are in the repository at `bench/TYPOGRAPHY.md` and `docs/typography-across-languages.md`, and CI re-runs the table so it cannot drift from the code.\n\nThe objection I would raise first is false positives, so I measured them. Two corpora live in the repository. The human one is 5,924 words of public-domain prose written before any language model existed: Austen from 1813, Douglass from 1845, Darwin from 1859, and PEP 8 and PEP 257 from 2001, which are the nearest public-domain match to the register this tool aims at. The machine one is 1,045 words of unedited model output, written for the corpus.\n\n| corpus | file | words | grade | findings | \n|---|---|---|---|---|\n| human | Austen | 1,120 | A | 0 | \n| human | Darwin | 1,199 | B | 2 | \n| human | Douglass | 1,202 | B | 3 | \n| human | PEP 257 | 1,174 | A | 1 | \n| human | PEP 8 | 1,234 | B | 2 | \n| machine | article intro | 175 | F | 22 | \n| machine | commit messages | 225 | F | 13 | \n| machine | docs page | 225 | F | 10 | \n| machine | pull request | 161 | F | 7 | \n| machine | README section | 259 | F | 31 | \n\nEight findings on the human side, and all eight are printed in the repository rather than tuned away: six are real dashes in nineteenth-century prose and `--` in a 2001 style guide, one is `not just X but Y` written by Frederick Douglass, one is `it's worth noting` written by Guido van Rossum. They are the standing cost of those rules, and the reason the output is a line number and not a verdict.\n\nMeasuring changed two rules rather than confirming them. `curly-quotes` first graded the Austen excerpt D on 92 findings, all of them the same rule: it was measuring typography, since the machine corpus had none. It now fires only on a file that mixes curly and straight marks, which is the paste signature. `not-x-but-y` could not match `isn't just X, it's Y`, the shape people actually write, so its separation was zero; it is now 14.5 times.\n\n`npm run bench` regenerates the per-rule table, and CI fails when the committed file does not match a fresh run. Seven thousand words cannot support an accuracy percentage, and the file says so.\n\n`delve` gets the same finding as a model. Passing says the listed tells are absent and nothing about who typed.\nWhat is left on the roadmap is a rule set for a second language, which somebody has already claimed. A probability that a text was machine-written is deliberately not on it: seven commercial detectors once marked 61% of human-written TOEFL essays as machine-written ([Liang et al., Patterns, 2023](https://doi.org/10.1016/j.patter.2023.100779)), and a tool that guesses authorship gets people accused. This one shows the tells and leaves the judgement where it belongs.\n\nWhich tell do you see most often that is not among the twenty? Name it and I will write the rule.", "url": "https://wpnews.pro/news/i-wrote-a-linter-for-ai-writing-tells-it-gave-one-of-my-own-readmes-a-c", "canonical_source": "https://dev.to/efe_genc/i-wrote-a-linter-for-ai-writing-tells-it-gave-one-of-my-own-readmes-a-c-1mmf", "published_at": "2026-09-12 21:27:15+00:00", "updated_at": "2026-09-12 22:25:19.198270+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-products"], "entities": ["ai-slop-linter", "Wikipedia", "GitHub", "Node.js", "Bubblegunn"], "alternates": {"html": "https://wpnews.pro/news/i-wrote-a-linter-for-ai-writing-tells-it-gave-one-of-my-own-readmes-a-c", "markdown": "https://wpnews.pro/news/i-wrote-a-linter-for-ai-writing-tells-it-gave-one-of-my-own-readmes-a-c.md", "text": "https://wpnews.pro/news/i-wrote-a-linter-for-ai-writing-tells-it-gave-one-of-my-own-readmes-a-c.txt", "jsonld": "https://wpnews.pro/news/i-wrote-a-linter-for-ai-writing-tells-it-gave-one-of-my-own-readmes-a-c.jsonld"}}