Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ These bullets are SOLID, DDD, clean architecture, clean code, and YAGNI applied
- Add no knob nobody sets. A documented `const` beats a config field with one caller.
- Delete before you add, and put the proof that a branch is dead in the commit message.
- Ship the smallest correct diff.
- Every flag, subcommand and option carries clap help text, and anything reachable only through the README or a config file gets a pointer in the `--help` footer. A new command is not done until `--help` describes it.
- Every flag, subcommand and option carries clap help text, and anything reachable only through the docs or a config file gets a pointer in the `--help` footer. A new command is not done until `--help` describes it.
- Speak the domain vocabulary in code, messages, docs, and commits: Rule, RuleDef, Diagnostic, Tier, Group, Panel, LintContext, NodeIndex, ProseDoc, Baseline, suppression directive, custom rule, Lang.
- Coining a synonym for one of those names is a defect. SLOP034 flags the same habit in prose.

Expand All @@ -64,7 +64,7 @@ These bullets are SOLID, DDD, clean architecture, clean code, and YAGNI applied
- Evidence stays out of the source. Corpus rates, measurements, issue numbers, and rejected alternatives go in the commit message, the PR body, `bench/candidates.toml`, or the bench reports.
- Delete a comment you come across that restates the code or carries evidence that belongs elsewhere.
- Mark a deliberate shortcut with a comment naming its ceiling and upgrade path.
- A rule message may name an AI tell. It never claims a whole file is AI-written, and it never needs a model or a private key to say so. The README non-goals stay true.
- A rule message may name an AI tell. It never claims a whole file is AI-written, and it never needs a model or a private key to say so. The README and `docs/why.md` non-goals stay true.

## Testing

Expand Down Expand Up @@ -105,5 +105,5 @@ These bullets are SOLID, DDD, clean architecture, clean code, and YAGNI applied
- Write conventional commit subjects (`feat:`, `fix:`, `perf:`, `refactor:`, `docs:`, `test:`, `ci:`, `chore:`).
- Self-review the whole patch and run the three CI commands plus the dogfood run before pushing.
- Open a PR and let CI decide. Merge commits, no squash.
- Update the README rule table and `CHANGELOG.md` in the same PR that adds or retires a rule.
- Update the `docs/rules.md` rule table and `CHANGELOG.md` in the same PR that adds or retires a rule.
- Touch `Cargo.toml` dependencies only with a PR-body line saying what std or an existing crate could not do.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ migration notes live here.
300 lines" or "`__init__.py` must be empty" no longer need a Rust module.
Existing entries are unchanged.

### Changed

- **The README is a short pitch; reference material moved to `docs/`.** Usage,
configuration, the rule table, suppression, prose linting, benchmarks and
non-goals each have a page, indexed from `docs/README.md`. SARIF `helpUri`
now points every rule at `docs/rules.md`, and `bench/score_corpus.py`
renames `--readme` to `--rules-doc` (default `docs/rules.md`).

### Fixed

- **SLOP018 keeps ASCII `--` unflagged, now pinned by a fixture and a test**
Expand Down
704 changes: 53 additions & 651 deletions README.md

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions bench/corpus/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
from .metrics import pct, rule_verdict
from .report import has_human, is_verified

README_RULE_ROW = re.compile(
RULE_TABLE_ROW = re.compile(
r"^\| (SLOP\d{3}) \| (\w+) \| (.+?) \| ([ABC]), (on|off)\s*\| (.+?) \| (.+?) \| (.+?) \|$")


def parse_rules(readme_text):
"""The README rule table is the only place that records each rule's langs, natlangs and
def parse_rules(rules_md_text):
"""The docs/rules.md rule table is the only place that records each rule's langs, natlangs and
one-line description; `--list-rules` carries neither langs nor description."""
rules = {}
for line in readme_text.splitlines():
m = README_RULE_ROW.match(line)
for line in rules_md_text.splitlines():
m = RULE_TABLE_ROW.match(line)
if m:
code, group, name, tier, default, langs, natlangs, desc = m.groups()
rules[code] = {"group": group, "name": name, "tier": tier, "default": default,
Expand All @@ -32,20 +32,20 @@ def rule_codes(binary):
return {line.split()[0] for line in proc.stdout.splitlines() if line.startswith("SLOP")}


def load_rules(binary, readme_path):
"""README table vs `--list-rules`: a rule shipped but undocumented (or the reverse) would
def load_rules(binary, rules_path):
"""rules.md table vs `--list-rules`: a rule shipped but undocumented (or the reverse) would
silently drop out of every table downstream, so the mismatch is an error here -- and so
would an empty parse on either side, which a stale `--bin` or a reformatted table turns
into a vacuously matching pair of empty sets."""
with open(readme_path, encoding="utf-8") as fh:
with open(rules_path, encoding="utf-8") as fh:
rules = parse_rules(fh.read())
codes = rule_codes(binary)
if not rules or not codes:
raise SystemExit(f"{readme_path}: parsed {len(rules)} README row(s) and {len(codes)} "
raise SystemExit(f"{rules_path}: parsed {len(rules)} rule-table row(s) and {len(codes)} "
f"--list-rules code(s) from {binary!r}; expected both non-empty")
missing, extra = sorted(codes - set(rules)), sorted(set(rules) - codes)
if missing or extra:
raise SystemExit(f"{readme_path}: rule table and --list-rules disagree; "
raise SystemExit(f"{rules_path}: rule table and --list-rules disagree; "
f"undocumented {missing}, unshipped {extra}")
return rules

Expand Down
4 changes: 2 additions & 2 deletions bench/score_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def main():
ap.add_argument("--no-fetch", action="store_true",
help="score the cells <dir>/fetched.json lists; no network, no rewrite")
ap.add_argument("--json", help="write results.json (rules, cells, per-message tallies, flagged lines)")
ap.add_argument("--readme", default="README.md", help="README holding the rule table")
ap.add_argument("--rules-doc", default="docs/rules.md", help="Markdown file holding the rule table")
ap.add_argument("--self-check", action="store_true", help="assert the metric math and exit")
args = ap.parse_args()
if args.self_check:
Expand Down Expand Up @@ -180,7 +180,7 @@ def main():
else:
sys.stdout.write(out)
if args.json:
rules = load_rules(args.bin, args.readme)
rules = load_rules(args.bin, args.rules_doc)
payload = results_payload(datasets_built, revisions, selected, args, rules, not_fetched)
with open(args.json, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, sort_keys=True)
Expand Down
9 changes: 9 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# stopslop documentation

- [Why stopslop](why.md): what other linters miss, how stopslop differs, and the non-goals.
- [Usage](usage.md): install, command-line flags, output formats, baseline, git-aware selection, exit codes.
- [Rules](rules.md): rule groups, the full rule table, tiers, and fixes.
- [Prose linting](prose.md): Markdown, MDX, text, reST, and HTML, and the natural-language axis.
- [Configuration](configuration.md): `stopslop.toml`, path exemptions, user-defined rules, file rules, `--check-imports`.
- [Suppression](suppression.md): `ai-slop-ignore` directives.
- [Benchmarks](benchmarks.md): labelled-corpus measurements, the human-split bar, candidate tells.
110 changes: 110 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Benchmarks

[Docs index](README.md)

## Measured on labelled corpora

`just corpus` runs every rule against labelled human-vs-AI corpora and
rebuilds the report. For every rule, per code language and per natural language, it
prints the hit rate on the human split (a false-positive proxy), the hit rate
on the AI split (a recall proxy), precision at a 1:1 human/AI prior, and
findings per KLoC or per thousand words.

```bash
just corpus # fetch, score, analyse, render
just corpus-fetch # download and materialize the cells (the only step needing the network)
just corpus-score # lint them, rebuild bench/corpus_report.md, write results.json
just corpus-analyze # measure bench/candidates.toml, split rates by message, rank phrases
just corpus-html # render target/corpus/report.html
```

Each recipe is one command; run `just --list` to see them, or read the
[justfile](../justfile) and run the underlying `python3 bench/...` line by hand.
The corpus recipes split on the network boundary: `corpus-fetch` downloads and
materializes every cell and writes `target/corpus/fetched.json`; `corpus-score`
reads that manifest and never touches the network, so scoring a rule change
is offline and reproducible. Set `HF_TOKEN` (or `HF_API_TOKEN`) for the
Hugging Face datasets-server.
Datasets are never committed, and neither is the rendered HTML, because several
registered datasets forbid redistributing a derivative. The committed
[bench/corpus_report.md](../bench/corpus_report.md) is the last run's output.

### What counts as a human split

A split is read as human only when its text is dated 2019 or earlier, or was
written by identified people under controlled conditions. Everything else was
collected once coding assistants and chat models were in general use, and
nobody can guarantee its human side was written without one.

Splits that clear the bar are `pinned-2019` (a 2019 release tag, or a corpus
built from pre-2020 sources) or `verified-authors` (exam essays written under
supervision, professional annotators writing to a brief). Splits that do not
are `unverified`: they are still fetched, scored and reported in full, but
below a divider that says why, and they never enter any pooled number or
takeaway.

Every AI split carries the years of the models that wrote it, so a 2022 result
is never read as a statement about a 2025 model.

### Candidate tells

`bench/candidates.toml` holds proposed tells that are not rules: a regex, a
scope, and what the corpus said when `just corpus-analyze` measured it. A
candidate that separates the splits earns an issue and then a rule with
fixtures; one that does not stays in the file as the record of a measurement,
next to the tells already measured and dropped. Nothing in that file changes
what `stopslop` reports.

Datasets in the registry, none committed to this repo:

| Name | What it covers | Human split | Generators | License |
|---|---|---|---|---|
| Go 1.13 src | Go | pinned-2019 | n/a | BSD-3-Clause |
| CPython 3.8.0 Lib and Doc | Python and reStructuredText | pinned-2019 | n/a | PSF-2.0 |
| Rust 1.40.0 std | Rust | pinned-2019 | n/a | MIT or Apache-2.0 |
| TypeScript 3.7.2 src | TypeScript | pinned-2019 | n/a | Apache-2.0 |
| Ant Design 3.26.0 | TSX | pinned-2019 | n/a | MIT |
| naples-code | Python, human and AI functions paired in one corpus, docstring in a separate column | pinned-2019 | gpt-3.5-turbo, DeepSeek-Coder-Instruct-33B, Qwen2.5-Coder-Instruct-32B, 2023-24 | CC-BY-4.0 |
| The Rust Programming Language book | English Markdown | pinned-2019 | n/a | MIT or Apache-2.0 |
| React docs | English Markdown | pinned-2019 | n/a | CC-BY-4.0 |
| Kubernetes docs | English Markdown | pinned-2019 | n/a | CC-BY-4.0 |
| HC3 | English answers | pinned-2019 (ELI5, FiQA, WikiQA) | ChatGPT, 2022 | CC-BY-SA-4.0 |
| MAGE | English documents from ten corpora | pinned-2019 (SciGen dropped) | 27 models, 2019-2022 | Apache-2.0 on the card, CC-BY-4.0 in the repo |
| Ghostbuster data | English news and stories | pinned-2019 (Reuters, WritingPrompts) | gpt-3.5, claude, 2023 | CC-BY-3.0 |
| SemEval-2024 Task 8 | English text from PeerRead, arXiv, Reddit and WikiHow | pinned-2019 (Wikipedia dropped) | ChatGPT, GPT-3 davinci, Cohere, Dolly, 2023 | Apache-2.0 |
| GPT-2 output dataset | English web text (WebText) | pinned-2019 | GPT-2 1542M, 2019 | MIT |
| Beemo | English instruction responses | verified-authors (No Robots) | GPT-4o, Llama 3.1 70B, Mixtral, Gemma, Mistral 7B, Zephyr, 2023-24 | MIT (edits); prompts and human outputs CC-BY-NC-4.0 |
| Diplomatrix-BR | Brazilian Portuguese exam essays | verified-authors | GPT-4o, Claude 3, Gemini, Llama 3, Sabiá and others, 2024 | MIT |
| LeNER-Br | Brazilian Portuguese statutes and court rulings | pinned-2019 | n/a | MIT (packaging); the raw texts are public-domain Brazilian federal statutes and court rulings (Lei 9.610/98 Art. 8) |
| DroidCollection | code: Python, Go, JavaScript as .ts, Rust adversarial | unverified | open and API code models, 2024-25 | not stated on the card |
| SemEval-2026 Task 13 | code: Python, Go, JavaScript as .ts, hybrid and adversarial | unverified | Qwen2.5-Coder, DeepSeek-Coder, Llama 3.x, GPT-4o and others, 2024-25 | Apache-2.0 |
| CodeMirage | code: Python, Go, JavaScript as .ts, plus paraphrased | unverified | ten LLMs, 2025 | CC-BY-NC-ND-4.0 |
| AIGCodeSet | Python | unverified | CodeLlama, Codestral, Gemini 1.5, 2024 | CDLA-Permissive-2.0 |
| CoDET-M4 | Python, comments stripped by the publisher | unverified | GPT-4o, Llama 3, Qwen, 2024 | MIT |
| Rosetta Code | Python, Go, Rust, TypeScript | unverified | n/a | GFDL |
| H-AIRosettaMP | Python, Go, Rust, TypeScript, AI side translates a human Rosetta Code solution from another language | unverified | StarCoder2, 2024 (ai-translated) | MIT |
| FAIDSet | English text, plus a human-LLM collaborative split | unverified | GPT-4o, Gemini 2, Llama 3, DeepSeek, 2024-25 | MIT |
| PAN25 Voight-Kampff | English fiction, essay and news text, plus machine-paraphrased | unverified | 23 models incl. gpt-4o, o3-mini, gemini-2.0-flash, deepseek-r1, llama-3.3-70b, 2023-25 | research use only, no redistribution (Zenodo record terms) |
| AIDev | English pull-request descriptions, AI only | none | Claude Code, Codex, Cursor, Devin, Copilot, Jules, 2025 | CC-BY-4.0 |
| APT-Eval | English text polished to a stated degree | unverified | GPT-4o, Llama 3.1 70B, Llama 3 8B, DeepSeek-V3, 2024-25 | CC-BY-4.0 |
| WETBench | Portuguese Wikipedia paragraphs | unverified | GPT-4o mini, Gemini 2.0, Qwen2.5, Mistral, 2024 | CC-BY-NC-SA-4.0 |
| Essay-BR | Brazilian Portuguese student essays | unverified | n/a | MIT |

No dataset anywhere pairs human and AI-written TSX or real TypeScript: the
`typescript` cells drawn from DroidCollection, SemEval-2026 Task 13, and
CodeMirage are JavaScript the source dataset stored with a `.ts` extension,
and no registered dataset carries TSX at all, so TSX AI stays synthesized.
Across Google, DeepMind, Meta, OpenAI, Microsoft, GitHub, AI2, BigCode,
Salesforce, Amazon, IBM, NVIDIA, DeepSeek, Alibaba, and Mistral, the only
labelled human-vs-AI corpus any of them publishes is OpenAI's 2019 GPT-2
output dataset.

`just corpus-generate` (`uv run`, needs an Anthropic API key, costs money)
calls Claude to synthesize the AI split for a cell that has no public one. Its
output is raw model text, every synthesized file is marked as such on disk
under `synth-*`, and those five cells are the only source of plain AI Rust and
the only source of a TSX AI split.

A hit rate here is evidence that a tell appears in a corpus, on a human split
or an AI split, and it is never a claim that any single file was AI-written.

Loading
Loading