slopfix 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.fr.md +56 -0
- package/README.md +222 -2
- package/bin/slopfix.js +233 -0
- package/index.d.ts +93 -0
- package/package.json +56 -4
- package/src/format.js +110 -0
- package/src/index.js +182 -0
- package/src/mask.js +65 -0
- package/src/rules/common.js +86 -0
- package/src/rules/en.js +221 -0
- package/src/rules/fr.js +206 -0
- package/src/rules/helpers.js +93 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zwarmex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.fr.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# slopfix
|
|
4
|
+
|
|
5
|
+
**Votre texte sonne comme du ChatGPT. slopfix trouve pourquoi, et le corrige.**
|
|
6
|
+
|
|
7
|
+
Un linter sans dépendance pour les tics d'écriture IA : tirets cadratins, `ce n'est pas X, c'est Y`, `plongeons dans`, `j'espère que cela vous aide !`, emojis en guise de puces et plus de 200 autres. Français et anglais. CLI, librairie, GitHub Action, skill pour agents IA et [playground dans le navigateur](https://zwarmex.github.io/slopfix/).
|
|
8
|
+
|
|
9
|
+
[Essayer dans le navigateur](https://zwarmex.github.io/slopfix/) · [English](README.md)
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx slopfix article.md
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Pourquoi
|
|
18
|
+
|
|
19
|
+
Tout le monde repère un texte généré en deux secondes, et arrête de lire. Les recruteurs zappent la lettre de motivation, les lecteurs quittent l'article, les mainteneurs ferment la pull request. Les tics sont toujours les mêmes, et la plupart sont mécaniques :
|
|
20
|
+
|
|
21
|
+
<!-- slopfix-disable -->
|
|
22
|
+
|
|
23
|
+
| Tic | Exemple | slopfix |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| Tirets cadratins partout | `rapide (tiret) et simple (tiret) par nature` | signale, remplace par `, ` |
|
|
26
|
+
| Parallélisme négatif | « Ce n'est pas un outil, c'est une révolution. » | signale |
|
|
27
|
+
| Formules de remplissage | « Il est important de noter que... » | supprime, remet la majuscule |
|
|
28
|
+
| Mots surutilisés | incontournable, pierre angulaire, s'avère être | signale, remplace les cas sûrs |
|
|
29
|
+
| Restes de chatbot | « Bien sûr ! », « J'espère que cela vous aide ! » | supprime ou signale |
|
|
30
|
+
| Grandiloquence | « change la donne », « joue un rôle crucial », « plongeons dans » | signale |
|
|
31
|
+
| Mise en forme de chatbot | emojis en puces, listes de **Libellé gras :** | supprime les emojis, signale les listes |
|
|
32
|
+
|
|
33
|
+
<!-- slopfix-enable -->
|
|
34
|
+
|
|
35
|
+
slopfix est déterministe. Il n'appelle aucun LLM, ne devine pas qui a écrit le texte et n'envoie rien nulle part. Il montre les tics, donne à chaque fichier un **score de slop** de 0 (propre) à 100 (pur slop), et corrige ce qui peut l'être sans risque.
|
|
36
|
+
|
|
37
|
+
## Utilisation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx slopfix docs/ # analyser un dossier
|
|
41
|
+
npx slopfix article.md --fix # corriger le fichier
|
|
42
|
+
npx slopfix . --max-score 20 # échouer seulement au-dessus de 20
|
|
43
|
+
npx slopfix . --lang fr # forcer le français
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
La langue est détectée automatiquement pour chaque fichier. Les blocs de code, le code inline, les URLs, le HTML et le front matter sont ignorés.
|
|
47
|
+
|
|
48
|
+
Configuration, GitHub Action, pre-commit et API : tout est dans le [README anglais](README.md).
|
|
49
|
+
|
|
50
|
+
## Contribuer
|
|
51
|
+
|
|
52
|
+
Ajouter une expression française, c'est une ligne dans [`src/rules/fr.js`](src/rules/fr.js). Vous voyez passer un tic IA tous les jours sur LinkedIn ? Ouvrez une pull request. Voir [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
53
|
+
|
|
54
|
+
## Licence
|
|
55
|
+
|
|
56
|
+
MIT
|
package/README.md
CHANGED
|
@@ -1,3 +1,223 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# slopfix
|
|
4
|
+
|
|
5
|
+
**Your text reads like ChatGPT wrote it. slopfix finds why, and fixes it.**
|
|
6
|
+
|
|
7
|
+
A zero dependency linter for AI writing tells: em dashes, `it's not X, it's Y`, `delve`, `I hope this helps!`, emoji bullets and 200+ more. English and French. CLI, library, GitHub Action, agent skill and a [browser playground](https://zwarmex.github.io/slopfix/).
|
|
8
|
+
|
|
9
|
+
[](https://github.com/Zwarmex/slopfix/actions/workflows/ci.yml)
|
|
10
|
+
[](https://www.npmjs.com/package/slopfix)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
[Try it in your browser](https://zwarmex.github.io/slopfix/) · [Français](README.fr.md)
|
|
15
|
+
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx slopfix README.md
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
README.md en · 130 words · slop score 94/100 (pure slop)
|
|
24
|
+
3:1 error Certainly! → (remove) Sycophantic chatbot opener.
|
|
25
|
+
5:1 warning In today's fast-paced world, → (remove) Filler phrase. Delete it and say the thing.
|
|
26
|
+
5:91 warning FlowSync is not just another app, it's "It's not X, it's Y" construction.
|
|
27
|
+
7:45 warning (em dash) → "," Em dash. The most recognizable AI writing tell.
|
|
28
|
+
7:117 warning delve into → "dig into" Word that is heavily overused by language models.
|
|
29
|
+
15:78 error I hope this helps! → (remove) Chatbot sign-off.
|
|
30
|
+
|
|
31
|
+
✖ 24 tells (4 errors, 16 warnings, 4 info) · 13 fixable with --fix
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Why
|
|
35
|
+
|
|
36
|
+
People now spot generated text in two seconds, and they stop reading. Recruiters skip the cover letter, readers skip the blog post, maintainers close the pull request. The tells are always the same, and most of them are mechanical:
|
|
37
|
+
|
|
38
|
+
<!-- slopfix-disable -->
|
|
39
|
+
|
|
40
|
+
| Tell | Example | slopfix |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| Em dashes everywhere | `fast (em dash) and simple (em dash) by design` | flags, fixes to `, ` |
|
|
43
|
+
| Negative parallelism | "It's not a tool, it's a revolution." | flags |
|
|
44
|
+
| Filler openers | "It's important to note that..." | removes, re-capitalizes |
|
|
45
|
+
| Overused words | delve, utilize, tapestry, a myriad of | flags, replaces the safe ones |
|
|
46
|
+
| Chatbot leftovers | "Certainly!", "I hope this helps!", "As an AI language model" | removes or flags |
|
|
47
|
+
| Hype | "game-changer", "unlock the full potential", "stands as a testament" | flags |
|
|
48
|
+
| Chatbot formatting | emoji bullets, lists of **Bold label:** items | removes emojis, flags lists |
|
|
49
|
+
|
|
50
|
+
<!-- slopfix-enable -->
|
|
51
|
+
|
|
52
|
+
slopfix is deterministic. It does not call an LLM, it does not guess who wrote the text and it does not send anything anywhere. It shows the tells, gives each file a **slop score** from 0 (clean) to 100 (pure slop), and fixes what is safe to fix.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# run once
|
|
58
|
+
npx slopfix docs/
|
|
59
|
+
|
|
60
|
+
# or install
|
|
61
|
+
npm install --save-dev slopfix
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Node 18 or newer. No dependencies.
|
|
65
|
+
|
|
66
|
+
## CLI
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
slopfix README.md # lint a file
|
|
70
|
+
slopfix docs blog # lint folders (md, mdx, markdown, txt)
|
|
71
|
+
slopfix docs --fix # fix every safe tell in place
|
|
72
|
+
slopfix . --max-score 20 # only fail when a file scores above 20
|
|
73
|
+
slopfix . --format github # annotations for GitHub Actions
|
|
74
|
+
pbpaste | slopfix --fix | pbcopy # clean your clipboard (macOS)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Option | Description |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `--fix` | Rewrite files with every safe fix. With stdin, print the fixed text. |
|
|
80
|
+
| `--lang <code>` | `en`, `fr`, `auto` (default, per file) or `all` |
|
|
81
|
+
| `--format <name>` | `pretty` (default), `compact`, `json`, `github` |
|
|
82
|
+
| `--max-score <n>` | Fail only when a file's score is above `n` |
|
|
83
|
+
| `--quiet` | Hide info level tells |
|
|
84
|
+
| `--dash <text>` | What replaces an em dash with `--fix` (default `", "`) |
|
|
85
|
+
| `--ext <list>` | Extensions to scan in folders |
|
|
86
|
+
| `--plain` | Treat input as plain text instead of Markdown |
|
|
87
|
+
| `--list-rules` | Print every rule |
|
|
88
|
+
|
|
89
|
+
Exit code is `0` when clean, `1` when tells are found, `2` on usage errors.
|
|
90
|
+
|
|
91
|
+
Code blocks, inline code, URLs, HTML tags and front matter are skipped, so your docs can talk about `delve` without being flagged.
|
|
92
|
+
|
|
93
|
+
## Configuration
|
|
94
|
+
|
|
95
|
+
Create `.slopfixrc.json` (or a `slopfix` key in `package.json`):
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"lang": "auto",
|
|
100
|
+
"dashReplacement": " - ",
|
|
101
|
+
"maxScore": 25,
|
|
102
|
+
"rules": {
|
|
103
|
+
"en/weasel-vocabulary": "off",
|
|
104
|
+
"em-dash": "error"
|
|
105
|
+
},
|
|
106
|
+
"ban": [
|
|
107
|
+
"synergy",
|
|
108
|
+
{ "phrase": "blazingly fast", "replacement": "fast" }
|
|
109
|
+
],
|
|
110
|
+
"ignore": ["CHANGELOG.md", "docs/legacy/**"]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Silence a passage inside a Markdown file:
|
|
115
|
+
|
|
116
|
+
```md
|
|
117
|
+
<!-- slopfix-disable -->
|
|
118
|
+
This paragraph can delve as much as it wants.
|
|
119
|
+
<!-- slopfix-enable -->
|
|
120
|
+
|
|
121
|
+
<!-- slopfix-disable-next-line -->
|
|
122
|
+
So can this line.
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## GitHub Action
|
|
126
|
+
|
|
127
|
+
Annotates the tells right on the pull request diff.
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
# .github/workflows/slopfix.yml
|
|
131
|
+
name: slopfix
|
|
132
|
+
on: pull_request
|
|
133
|
+
jobs:
|
|
134
|
+
slopfix:
|
|
135
|
+
runs-on: ubuntu-latest
|
|
136
|
+
steps:
|
|
137
|
+
- uses: actions/checkout@v7
|
|
138
|
+
- uses: actions/setup-node@v7
|
|
139
|
+
with:
|
|
140
|
+
node-version: 22
|
|
141
|
+
- uses: Zwarmex/slopfix@v0
|
|
142
|
+
with:
|
|
143
|
+
paths: docs README.md
|
|
144
|
+
max-score: 25
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## pre-commit
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
repos:
|
|
151
|
+
- repo: https://github.com/Zwarmex/slopfix
|
|
152
|
+
rev: v0.1.0
|
|
153
|
+
hooks:
|
|
154
|
+
- id: slopfix
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Library
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
import { lint, fix } from 'slopfix';
|
|
161
|
+
|
|
162
|
+
const result = lint('Certainly! We delve into the data.');
|
|
163
|
+
result.score; // 0 to 100
|
|
164
|
+
result.issues; // [{ ruleId, severity, message, line, column, quote, fix }, ...]
|
|
165
|
+
|
|
166
|
+
fix('Certainly! We delve into the data.').output;
|
|
167
|
+
// "We dig into the data."
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Types are included. It runs in Node and in the browser (the playground uses the same code).
|
|
171
|
+
|
|
172
|
+
## Make your AI agent check itself
|
|
173
|
+
|
|
174
|
+
Copy [`skill/SKILL.md`](skill/SKILL.md) into your agent's skills folder (for example `.claude/skills/slopfix/SKILL.md`). Agents that support skills (Claude Code, Codex, Cursor and others) will run slopfix on the prose they write before handing it to you. You can also add one line to your `AGENTS.md`:
|
|
175
|
+
|
|
176
|
+
```md
|
|
177
|
+
Before you finish, run `npx slopfix --quiet` on every Markdown file you changed and fix what it reports.
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Rules
|
|
181
|
+
|
|
182
|
+
Run `slopfix --list-rules` for the full list.
|
|
183
|
+
|
|
184
|
+
| Rule | Lang | Default | Autofix |
|
|
185
|
+
| --- | --- | --- | --- |
|
|
186
|
+
| `em-dash` | all | warning | yes |
|
|
187
|
+
| `spaced-en-dash` | all | warning | yes |
|
|
188
|
+
| `emoji-bullet` | all | warning | yes |
|
|
189
|
+
| `bold-label-list` | all | info | no |
|
|
190
|
+
| `en/chatbot-artifact`, `fr/chatbot-artifact` | en, fr | error | no |
|
|
191
|
+
| `en/sycophantic-opener`, `fr/sycophantic-opener` | en, fr | error | yes |
|
|
192
|
+
| `en/chatbot-sign-off`, `fr/chatbot-sign-off` | en, fr | error | yes |
|
|
193
|
+
| `en/filler-opener`, `fr/filler-opener` | en, fr | warning | yes |
|
|
194
|
+
| `en/hype-intro`, `fr/hype-intro` | en, fr | warning | no |
|
|
195
|
+
| `en/ai-vocabulary`, `fr/ai-vocabulary` | en, fr | warning | partly |
|
|
196
|
+
| `en/weasel-vocabulary`, `fr/weasel-vocabulary` | en, fr | info | no |
|
|
197
|
+
| `en/not-x-but-y`, `fr/not-x-but-y` | en, fr | warning | no |
|
|
198
|
+
| `en/whether-youre`, `fr/que-vous-soyez` | en, fr | info | no |
|
|
199
|
+
|
|
200
|
+
Rewrites that need judgment (negative parallelism, hype) are reported but never rewritten for you. A linter that silently rewrites your argument is worse than slop.
|
|
201
|
+
|
|
202
|
+
## The slop score
|
|
203
|
+
|
|
204
|
+
Each tell has a weight (error 5, warning 2, info 1). The score takes the weighted density per 100 words and maps it onto a 0 to 100 curve. Rough guide:
|
|
205
|
+
|
|
206
|
+
| Score | Label |
|
|
207
|
+
| --- | --- |
|
|
208
|
+
| 0 to 9 | clean |
|
|
209
|
+
| 10 to 29 | a few tells |
|
|
210
|
+
| 30 to 59 | sloppy |
|
|
211
|
+
| 60 to 100 | pure slop |
|
|
212
|
+
|
|
213
|
+
It is a writing quality signal, not an AI detector. Humans write `crucial` too. A high score means readers will *think* a model wrote it, which is the problem you want to fix.
|
|
214
|
+
|
|
215
|
+
## Contributing
|
|
216
|
+
|
|
217
|
+
The best contributions are new phrases and new languages. Adding a phrase is one line in [`src/rules/en.js`](src/rules/en.js) or [`src/rules/fr.js`](src/rules/fr.js), adding a language is one new file. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
218
|
+
|
|
219
|
+
Wanted: Spanish, German, Portuguese, Italian, Dutch. Open a pull request with the tells you see every day in your language.
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT
|
package/bin/slopfix.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join, relative, extname, resolve } from 'node:path';
|
|
4
|
+
import { lint, fix, rules } from '../src/index.js';
|
|
5
|
+
import { formatters, c } from '../src/format.js';
|
|
6
|
+
|
|
7
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
8
|
+
|
|
9
|
+
const HELP = `
|
|
10
|
+
${c.bold('slopfix')} ${pkg.version} Find and fix AI writing tells in your docs, posts and READMEs.
|
|
11
|
+
|
|
12
|
+
${c.bold('Usage')}
|
|
13
|
+
slopfix [files or folders...] [options]
|
|
14
|
+
cat post.md | slopfix [options]
|
|
15
|
+
|
|
16
|
+
${c.bold('Options')}
|
|
17
|
+
--fix Rewrite files with every safe fix (stdin: print the fixed text)
|
|
18
|
+
--lang <code> en, fr, auto (default) or all
|
|
19
|
+
--format <name> pretty (default), compact, json, github
|
|
20
|
+
--max-score <n> Only fail when a file's slop score is above n (0 to 100)
|
|
21
|
+
--quiet Hide info level tells
|
|
22
|
+
--dash <text> What replaces an em dash with --fix (default ", ")
|
|
23
|
+
--ext <list> Extensions to scan in folders (default md,mdx,markdown,txt)
|
|
24
|
+
--plain Treat input as plain text, not Markdown
|
|
25
|
+
--config <path> Config file (default .slopfixrc.json if present)
|
|
26
|
+
--list-rules Print every rule and exit
|
|
27
|
+
-v, --version Print the version
|
|
28
|
+
-h, --help Print this help
|
|
29
|
+
|
|
30
|
+
${c.bold('Examples')}
|
|
31
|
+
npx slopfix README.md
|
|
32
|
+
npx slopfix docs --fix
|
|
33
|
+
npx slopfix . --max-score 20 --format github
|
|
34
|
+
pbpaste | npx slopfix --fix | pbcopy
|
|
35
|
+
|
|
36
|
+
${c.bold('Exit codes')}
|
|
37
|
+
0 no errors or warnings (or every file under --max-score), 1 tells found, 2 usage error
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'vendor', 'coverage', '.next', '.nuxt', '.cache', '.venv', 'venv']);
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const args = { files: [], fix: false, format: 'pretty', quiet: false, listRules: false, plain: false };
|
|
44
|
+
const takesValue = new Set(['--lang', '--format', '--max-score', '--dash', '--ext', '--config']);
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
let a = argv[i];
|
|
47
|
+
let value;
|
|
48
|
+
if (a.startsWith('--') && a.includes('=')) [a, value] = [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)];
|
|
49
|
+
if (takesValue.has(a) && value === undefined) {
|
|
50
|
+
value = argv[++i];
|
|
51
|
+
if (value === undefined) throw new Error(`${a} needs a value`);
|
|
52
|
+
}
|
|
53
|
+
switch (a) {
|
|
54
|
+
case '-h':
|
|
55
|
+
case '--help':
|
|
56
|
+
args.help = true;
|
|
57
|
+
break;
|
|
58
|
+
case '-v':
|
|
59
|
+
case '--version':
|
|
60
|
+
args.version = true;
|
|
61
|
+
break;
|
|
62
|
+
case '--fix':
|
|
63
|
+
args.fix = true;
|
|
64
|
+
break;
|
|
65
|
+
case '--quiet':
|
|
66
|
+
args.quiet = true;
|
|
67
|
+
break;
|
|
68
|
+
case '--plain':
|
|
69
|
+
args.plain = true;
|
|
70
|
+
break;
|
|
71
|
+
case '--list-rules':
|
|
72
|
+
args.listRules = true;
|
|
73
|
+
break;
|
|
74
|
+
case '--lang':
|
|
75
|
+
args.lang = value;
|
|
76
|
+
break;
|
|
77
|
+
case '--format':
|
|
78
|
+
if (!formatters[value]) throw new Error(`Unknown format "${value}". Use ${Object.keys(formatters).join(', ')}.`);
|
|
79
|
+
args.format = value;
|
|
80
|
+
break;
|
|
81
|
+
case '--max-score':
|
|
82
|
+
args.maxScore = Number(value);
|
|
83
|
+
if (!Number.isFinite(args.maxScore)) throw new Error('--max-score needs a number');
|
|
84
|
+
break;
|
|
85
|
+
case '--dash':
|
|
86
|
+
args.dash = value;
|
|
87
|
+
break;
|
|
88
|
+
case '--ext':
|
|
89
|
+
args.ext = value.split(',').map((e) => e.trim().replace(/^\./, ''));
|
|
90
|
+
break;
|
|
91
|
+
case '--config':
|
|
92
|
+
args.config = value;
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
if (a.startsWith('-') && a !== '-') throw new Error(`Unknown option ${a}`);
|
|
96
|
+
args.files.push(a);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return args;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function loadConfig(path) {
|
|
103
|
+
const file = path || ['.slopfixrc.json', '.slopfixrc'].find((f) => existsSync(f));
|
|
104
|
+
if (file) {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new Error(`Could not read config ${file}: ${e.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (existsSync('package.json')) {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(readFileSync('package.json', 'utf8')).slopfix || {};
|
|
114
|
+
} catch {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function globToRegex(glob) {
|
|
122
|
+
const re = glob
|
|
123
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
124
|
+
.replace(/\*\*\/?/g, '\u0000')
|
|
125
|
+
.replace(/\*/g, '[^/]*')
|
|
126
|
+
.replace(/\?/g, '[^/]')
|
|
127
|
+
.replace(/\u0000/g, '.*');
|
|
128
|
+
return new RegExp(`(^|/)${re}($|/)`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function collect(paths, extensions, ignore) {
|
|
132
|
+
const files = [];
|
|
133
|
+
const isIgnored = (p) => ignore.some((re) => re.test(p.split('\\').join('/')));
|
|
134
|
+
const walk = (dir) => {
|
|
135
|
+
for (const name of readdirSync(dir).sort()) {
|
|
136
|
+
if (IGNORED_DIRS.has(name)) continue;
|
|
137
|
+
const full = join(dir, name);
|
|
138
|
+
const rel = relative(process.cwd(), full);
|
|
139
|
+
if (isIgnored(rel)) continue;
|
|
140
|
+
const st = statSync(full);
|
|
141
|
+
if (st.isDirectory()) walk(full);
|
|
142
|
+
else if (extensions.includes(extname(name).slice(1).toLowerCase())) files.push(full);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
for (const p of paths) {
|
|
146
|
+
if (!existsSync(p)) throw new Error(`No such file or folder: ${p}`);
|
|
147
|
+
if (statSync(p).isDirectory()) walk(p);
|
|
148
|
+
else files.push(p);
|
|
149
|
+
}
|
|
150
|
+
return files;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readStdin() {
|
|
154
|
+
return readFileSync(0, 'utf8');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function listRules() {
|
|
158
|
+
const rows = rules.map((r) => `${r.id.padEnd(26)} ${r.lang.padEnd(3)} ${r.severity.padEnd(8)} ${r.message}`);
|
|
159
|
+
return [`${'RULE'.padEnd(26)} ${'LANG'.padEnd(3)} SEVERITY ${'DESCRIPTION'}`, ...rows].join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function main() {
|
|
163
|
+
let args;
|
|
164
|
+
try {
|
|
165
|
+
args = parseArgs(process.argv.slice(2));
|
|
166
|
+
} catch (e) {
|
|
167
|
+
console.error(c.red(`slopfix: ${e.message}`));
|
|
168
|
+
return 2;
|
|
169
|
+
}
|
|
170
|
+
if (args.help) return console.log(HELP), 0;
|
|
171
|
+
if (args.version) return console.log(pkg.version), 0;
|
|
172
|
+
if (args.listRules) return console.log(listRules()), 0;
|
|
173
|
+
|
|
174
|
+
const config = loadConfig(args.config);
|
|
175
|
+
const options = {
|
|
176
|
+
lang: args.lang || config.lang || 'auto',
|
|
177
|
+
rules: config.rules || {},
|
|
178
|
+
ban: config.ban || [],
|
|
179
|
+
dashReplacement: args.dash ?? config.dashReplacement ?? ', ',
|
|
180
|
+
markdown: !args.plain && config.markdown !== false,
|
|
181
|
+
};
|
|
182
|
+
const maxScore = args.maxScore ?? config.maxScore;
|
|
183
|
+
const extensions = args.ext || config.extensions || ['md', 'mdx', 'markdown', 'txt'];
|
|
184
|
+
const ignore = (config.ignore || []).map(globToRegex);
|
|
185
|
+
|
|
186
|
+
const fromStdin = args.files.length === 0 || (args.files.length === 1 && args.files[0] === '-');
|
|
187
|
+
if (fromStdin && process.stdin.isTTY) {
|
|
188
|
+
console.log(HELP);
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const results = [];
|
|
193
|
+
try {
|
|
194
|
+
if (fromStdin) {
|
|
195
|
+
const text = readStdin();
|
|
196
|
+
if (args.fix) {
|
|
197
|
+
process.stdout.write(fix(text, options).output);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
results.push({ file: '<stdin>', ...lint(text, options) });
|
|
201
|
+
} else {
|
|
202
|
+
for (const file of collect(args.files, extensions, ignore)) {
|
|
203
|
+
const text = readFileSync(file, 'utf8');
|
|
204
|
+
const name = relative(process.cwd(), resolve(file)).split('\\').join('/');
|
|
205
|
+
if (args.fix) {
|
|
206
|
+
const { output, fixed, result } = fix(text, options);
|
|
207
|
+
if (fixed > 0 && output !== text) writeFileSync(file, output);
|
|
208
|
+
results.push({ file: name, fixed, ...result });
|
|
209
|
+
} else {
|
|
210
|
+
results.push({ file: name, ...lint(text, options) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
console.error(c.red(`slopfix: ${e.message}`));
|
|
216
|
+
return 2;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const shown = args.quiet ? results.map((r) => ({ ...r, issues: r.issues.filter((i) => i.severity !== 'info') })) : results;
|
|
220
|
+
const output = formatters[args.format](shown);
|
|
221
|
+
if (output) console.log(output);
|
|
222
|
+
if (args.fix && args.format === 'pretty') {
|
|
223
|
+
const total = results.reduce((n, r) => n + (r.fixed || 0), 0);
|
|
224
|
+
console.log(c.green(`\u2714 Fixed ${total} tells in ${results.filter((r) => r.fixed).length} files.`));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const failed = results.some((r) =>
|
|
228
|
+
maxScore !== undefined ? r.score > maxScore : r.issues.some((i) => i.severity === 'error' || i.severity === 'warning'),
|
|
229
|
+
);
|
|
230
|
+
return failed ? 1 : 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
process.exitCode = main();
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export type Severity = 'error' | 'warning' | 'info';
|
|
2
|
+
export type Language = 'en' | 'fr';
|
|
3
|
+
|
|
4
|
+
export interface Options {
|
|
5
|
+
/** 'auto' (default) detects the language, 'all' runs every language's rules. */
|
|
6
|
+
lang?: Language | 'auto' | 'all';
|
|
7
|
+
/** Turn a rule off or change its severity, e.g. { 'em-dash': 'off', 'en/weasel-vocabulary': 'warning' }. */
|
|
8
|
+
rules?: Record<string, Severity | 'off' | false>;
|
|
9
|
+
/** Extra phrases to flag. A replacement makes them fixable. */
|
|
10
|
+
ban?: Array<string | { phrase: string; replacement?: string }>;
|
|
11
|
+
/** What replaces an em dash when fixing. Default ', '. */
|
|
12
|
+
dashReplacement?: string;
|
|
13
|
+
/** Skip code, URLs, HTML and front matter. Default true. */
|
|
14
|
+
markdown?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Fix {
|
|
18
|
+
range: [number, number];
|
|
19
|
+
text: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Issue {
|
|
24
|
+
ruleId: string;
|
|
25
|
+
category: string;
|
|
26
|
+
severity: Severity;
|
|
27
|
+
message: string;
|
|
28
|
+
quote: string;
|
|
29
|
+
index: number;
|
|
30
|
+
length: number;
|
|
31
|
+
line: number;
|
|
32
|
+
column: number;
|
|
33
|
+
endLine: number;
|
|
34
|
+
endColumn: number;
|
|
35
|
+
fix?: Fix;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LintResult {
|
|
39
|
+
language: Language;
|
|
40
|
+
words: number;
|
|
41
|
+
/** 0 is clean, 100 is pure slop. */
|
|
42
|
+
score: number;
|
|
43
|
+
label: 'clean' | 'a few tells' | 'sloppy' | 'pure slop';
|
|
44
|
+
issues: Issue[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FixResult {
|
|
48
|
+
output: string;
|
|
49
|
+
fixed: number;
|
|
50
|
+
result: LintResult;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Hit {
|
|
54
|
+
index: number;
|
|
55
|
+
length: number;
|
|
56
|
+
fix?: string | null;
|
|
57
|
+
fixLabel?: string;
|
|
58
|
+
message?: string;
|
|
59
|
+
quote?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Rule {
|
|
63
|
+
id: string;
|
|
64
|
+
lang: Language | '*';
|
|
65
|
+
category: string;
|
|
66
|
+
severity: Severity;
|
|
67
|
+
message: string;
|
|
68
|
+
find(text: string, ctx: { options: Required<Options>; language: Language }): Iterable<Hit>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function lint(text: string, options?: Options): LintResult;
|
|
72
|
+
export function fix(text: string, options?: Options): FixResult;
|
|
73
|
+
export function detectLanguage(text: string): Language;
|
|
74
|
+
export function computeScore(issues: Issue[], words: number): number;
|
|
75
|
+
export function scoreLabel(score: number): LintResult['label'];
|
|
76
|
+
export function mask(text: string, options?: { markdown?: boolean }): string;
|
|
77
|
+
export const rules: Rule[];
|
|
78
|
+
export const languages: Language[];
|
|
79
|
+
export const weights: Record<Severity, number>;
|
|
80
|
+
export const defaults: Required<Options>;
|
|
81
|
+
|
|
82
|
+
declare const slopfix: {
|
|
83
|
+
lint: typeof lint;
|
|
84
|
+
fix: typeof fix;
|
|
85
|
+
rules: typeof rules;
|
|
86
|
+
detectLanguage: typeof detectLanguage;
|
|
87
|
+
computeScore: typeof computeScore;
|
|
88
|
+
scoreLabel: typeof scoreLabel;
|
|
89
|
+
mask: typeof mask;
|
|
90
|
+
languages: typeof languages;
|
|
91
|
+
defaults: typeof defaults;
|
|
92
|
+
};
|
|
93
|
+
export default slopfix;
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slopfix",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Find and fix AI writing tells (em dashes, \"it's not X, it's Y\", delve, chatbot leftovers) in English and French. Zero dependencies. CLI, library, GitHub Action.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"slopfix": "bin/slopfix.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"index.d.ts"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test",
|
|
25
|
+
"lint:docs": "node bin/slopfix.js README.md README.fr.md CONTRIBUTING.md CHANGELOG.md skill/SKILL.md",
|
|
26
|
+
"demo": "node examples/make-examples.js && node bin/slopfix.js examples"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"ai",
|
|
30
|
+
"llm",
|
|
31
|
+
"chatgpt",
|
|
32
|
+
"claude",
|
|
33
|
+
"slop",
|
|
34
|
+
"ai-slop",
|
|
35
|
+
"ai-writing",
|
|
36
|
+
"humanizer",
|
|
37
|
+
"linter",
|
|
38
|
+
"prose",
|
|
39
|
+
"writing",
|
|
40
|
+
"markdown",
|
|
41
|
+
"em-dash",
|
|
42
|
+
"cli",
|
|
43
|
+
"github-action"
|
|
44
|
+
],
|
|
45
|
+
"author": "Zwarmex",
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/Zwarmex/slopfix.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://zwarmex.github.io/slopfix/",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/Zwarmex/slopfix/issues"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18"
|
|
57
|
+
}
|
|
58
|
+
}
|