slopfix 0.0.0-stage → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "slopfix",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ }
package/src/format.js ADDED
@@ -0,0 +1,110 @@
1
+ // Output formats for the CLI: pretty (humans), compact (grep), json (tools), github (Actions annotations).
2
+
3
+ const useColor = !process.env.NO_COLOR && (process.env.FORCE_COLOR || process.stdout.isTTY);
4
+ const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
5
+ export const c = {
6
+ red: paint(31),
7
+ yellow: paint(33),
8
+ blue: paint(34),
9
+ green: paint(32),
10
+ dim: paint(2),
11
+ bold: paint(1),
12
+ underline: paint(4),
13
+ };
14
+
15
+ const SEVERITY_COLOR = { error: c.red, warning: c.yellow, info: c.blue };
16
+
17
+ function scoreColor(score) {
18
+ if (score < 10) return c.green;
19
+ if (score < 30) return c.yellow;
20
+ return c.red;
21
+ }
22
+
23
+ function truncate(s, n) {
24
+ const flat = s.replace(/\s+/g, ' ');
25
+ return flat.length > n ? flat.slice(0, n - 1) + '…' : flat;
26
+ }
27
+
28
+ export function pretty(results, { quiet = false } = {}) {
29
+ const out = [];
30
+ const totals = { error: 0, warning: 0, info: 0, fixable: 0 };
31
+
32
+ for (const r of results) {
33
+ const issues = r.issues.filter((i) => !quiet || i.severity !== 'info');
34
+ for (const i of r.issues) {
35
+ totals[i.severity]++;
36
+ if (i.fix) totals.fixable++;
37
+ }
38
+ const header = `${c.underline(r.file)} ${c.dim(`${r.language} · ${r.words} words ·`)} ${scoreColor(r.score)(`slop score ${r.score}/100 (${r.label})`)}`;
39
+ if (issues.length === 0) {
40
+ out.push(header, '');
41
+ continue;
42
+ }
43
+ out.push(header);
44
+ const posWidth = Math.max(...issues.map((i) => `${i.line}:${i.column}`.length));
45
+ for (const i of issues) {
46
+ const pos = `${i.line}:${i.column}`.padEnd(posWidth);
47
+ const sev = SEVERITY_COLOR[i.severity](i.severity.padEnd(7));
48
+ const fixNote = i.fix ? c.green(` → ${i.fix.label || `"${i.fix.text.trim() || i.fix.text}"`}`) : '';
49
+ out.push(` ${c.dim(pos)} ${sev} ${c.bold(truncate(i.quote, 50))}${fixNote} ${c.dim(i.message)} ${c.dim(i.ruleId)}`);
50
+ }
51
+ out.push('');
52
+ }
53
+
54
+ const problems = totals.error + totals.warning + totals.info;
55
+ if (problems === 0) {
56
+ out.push(c.green('✔ No AI writing tells found.'));
57
+ } else {
58
+ const parts = [`${totals.error} errors`, `${totals.warning} warnings`, `${totals.info} info`];
59
+ let line = `${totals.error ? c.red('✖') : c.yellow('!')} ${problems} tells (${parts.join(', ')})`;
60
+ if (totals.fixable) line += c.dim(` · ${totals.fixable} fixable with --fix`);
61
+ out.push(line);
62
+ }
63
+ return out.join('\n');
64
+ }
65
+
66
+ export function compact(results) {
67
+ const out = [];
68
+ for (const r of results) {
69
+ for (const i of r.issues) out.push(`${r.file}:${i.line}:${i.column}: ${i.severity} ${i.message} "${truncate(i.quote, 60)}" [${i.ruleId}]`);
70
+ }
71
+ return out.join('\n');
72
+ }
73
+
74
+ export function json(results) {
75
+ return JSON.stringify(
76
+ results.map((r) => ({ ...r, issues: r.issues.map(({ index, length, ...rest }) => ({ ...rest, offset: index, length })) })),
77
+ null,
78
+ 2,
79
+ );
80
+ }
81
+
82
+ function escapeData(s) {
83
+ return String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
84
+ }
85
+
86
+ function escapeProperty(s) {
87
+ return escapeData(s).replace(/:/g, '%3A').replace(/,/g, '%2C');
88
+ }
89
+
90
+ const GITHUB_LEVEL = { error: 'error', warning: 'warning', info: 'notice' };
91
+
92
+ export function github(results) {
93
+ const out = [];
94
+ for (const r of results) {
95
+ for (const i of r.issues) {
96
+ const props = [
97
+ `file=${escapeProperty(r.file)}`,
98
+ `line=${i.line}`,
99
+ `col=${i.column}`,
100
+ `endLine=${i.endLine}`,
101
+ `endColumn=${i.endColumn}`,
102
+ `title=${escapeProperty(`slopfix (${i.ruleId})`)}`,
103
+ ].join(',');
104
+ out.push(`::${GITHUB_LEVEL[i.severity]} ${props}::${escapeData(`"${truncate(i.quote, 60)}" ${i.message}`)}`);
105
+ }
106
+ }
107
+ return out.join('\n');
108
+ }
109
+
110
+ export const formatters = { pretty, compact, json, github };
package/src/index.js ADDED
@@ -0,0 +1,182 @@
1
+ import common from './rules/common.js';
2
+ import en from './rules/en.js';
3
+ import fr from './rules/fr.js';
4
+ import { phraseRule } from './rules/helpers.js';
5
+ import { mask } from './mask.js';
6
+
7
+ export { mask } from './mask.js';
8
+
9
+ export const rules = [...common, ...en, ...fr];
10
+ export const languages = ['en', 'fr'];
11
+ export const weights = { error: 5, warning: 2, info: 1 };
12
+
13
+ const SEVERITIES = new Set(['error', 'warning', 'info']);
14
+
15
+ const STOPWORDS = {
16
+ en: ['the', 'and', 'is', 'of', 'to', 'in', 'that', 'it', 'for', 'with', 'you', 'are', 'this', 'not', 'be', 'on', 'your', 'can', 'what', 'how'],
17
+ fr: ['le', 'la', 'les', 'des', 'est', 'et', 'une', 'un', 'du', 'que', 'pour', 'dans', 'pas', 'vous', 'nous', 'avec', 'sur', 'qui', 'au', 'ce'],
18
+ };
19
+
20
+ export const defaults = {
21
+ lang: 'auto',
22
+ rules: {},
23
+ ban: [],
24
+ dashReplacement: ', ',
25
+ markdown: true,
26
+ };
27
+
28
+ /** Guesses the language of a text from its most common short words. Returns a language code. */
29
+ export function detectLanguage(text) {
30
+ const counts = Object.fromEntries(Object.keys(STOPWORDS).map((l) => [l, 0]));
31
+ const sets = Object.fromEntries(Object.entries(STOPWORDS).map(([l, w]) => [l, new Set(w)]));
32
+ for (const word of text.toLowerCase().match(/\p{L}+/gu) || []) {
33
+ for (const l in sets) if (sets[l].has(word)) counts[l]++;
34
+ }
35
+ return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
36
+ }
37
+
38
+ function normalizeOptions(options = {}) {
39
+ return { ...defaults, ...options, rules: { ...defaults.rules, ...(options.rules || {}) } };
40
+ }
41
+
42
+ function banRule(ban) {
43
+ if (!ban || ban.length === 0) return [];
44
+ const phrases = ban.map((b) => (typeof b === 'string' ? b : b.replacement !== undefined ? [b.phrase, b.replacement] : b.phrase));
45
+ return [
46
+ phraseRule({
47
+ id: 'custom/ban',
48
+ lang: '*',
49
+ category: 'custom',
50
+ severity: 'warning',
51
+ message: 'Banned by your slopfix config.',
52
+ phrases,
53
+ }),
54
+ ];
55
+ }
56
+
57
+ function activeRules(options, language) {
58
+ const out = [];
59
+ for (const rule of [...rules, ...banRule(options.ban)]) {
60
+ if (rule.lang !== '*' && options.lang !== 'all' && rule.lang !== language) continue;
61
+ const setting = options.rules[rule.id];
62
+ if (setting === 'off' || setting === false) continue;
63
+ out.push(SEVERITIES.has(setting) ? { ...rule, severity: setting } : rule);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function lineIndex(text) {
69
+ const starts = [0];
70
+ for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
71
+ return (offset) => {
72
+ let lo = 0;
73
+ let hi = starts.length - 1;
74
+ while (lo < hi) {
75
+ const mid = (lo + hi + 1) >> 1;
76
+ if (starts[mid] <= offset) lo = mid;
77
+ else hi = mid - 1;
78
+ }
79
+ return { line: lo + 1, column: offset - starts[lo] + 1 };
80
+ };
81
+ }
82
+
83
+ /** Converts weighted hits into a 0 to 100 score, where 0 is clean and 100 is pure slop. */
84
+ export function computeScore(issues, words) {
85
+ const weighted = issues.reduce((sum, i) => sum + weights[i.severity], 0);
86
+ const per100 = (weighted / Math.max(words, 50)) * 100;
87
+ // Saturating curve: one em dash per 100 words is about 12, a typical chatbot answer lands between 30 and 60.
88
+ return Math.round(100 * (1 - Math.exp(-per100 / 15)));
89
+ }
90
+
91
+ export function scoreLabel(score) {
92
+ if (score < 10) return 'clean';
93
+ if (score < 30) return 'a few tells';
94
+ if (score < 60) return 'sloppy';
95
+ return 'pure slop';
96
+ }
97
+
98
+ /**
99
+ * Finds AI writing tells in a text.
100
+ * @returns {{ language: string, words: number, score: number, label: string, issues: object[] }}
101
+ */
102
+ export function lint(text, options) {
103
+ const opts = normalizeOptions(options);
104
+ const masked = mask(text, { markdown: opts.markdown });
105
+ const language = opts.lang === 'auto' || opts.lang === 'all' ? detectLanguage(masked) : opts.lang;
106
+ const ctx = { options: opts, language };
107
+ const position = lineIndex(text);
108
+ const issues = [];
109
+
110
+ for (const rule of activeRules(opts, language)) {
111
+ for (const hit of rule.find(masked, ctx)) {
112
+ const start = position(hit.index);
113
+ const end = position(hit.index + hit.length);
114
+ issues.push({
115
+ ruleId: rule.id,
116
+ category: rule.category,
117
+ severity: rule.severity,
118
+ message: hit.message || rule.message,
119
+ quote: hit.quote ?? text.slice(hit.index, hit.index + hit.length),
120
+ index: hit.index,
121
+ length: hit.length,
122
+ line: start.line,
123
+ column: start.column,
124
+ endLine: end.line,
125
+ endColumn: end.column,
126
+ fix:
127
+ hit.fix === undefined || hit.fix === null
128
+ ? undefined
129
+ : { range: [hit.index, hit.index + (hit.fixLength ?? hit.length)], text: hit.fix, label: hit.fixLabel ?? (hit.fix === '' ? '(remove)' : undefined) },
130
+ });
131
+ }
132
+ }
133
+
134
+ // Longest span first at each position, then drop tells that sit entirely inside another one
135
+ // ("crucial" inside "plays a crucial role" is one tell, not two).
136
+ issues.sort((a, b) => a.index - b.index || b.length - a.length || a.ruleId.localeCompare(b.ruleId));
137
+ const kept = [];
138
+ for (const issue of issues) {
139
+ const outer = kept.find((k) => issue.index >= k.index && issue.index + issue.length <= k.index + k.length);
140
+ if (!outer) kept.push(issue);
141
+ }
142
+ issues.length = 0;
143
+ issues.push(...kept);
144
+ const words = (masked.match(/\p{L}+/gu) || []).length;
145
+ const score = computeScore(issues, words);
146
+ return { language, words, score, label: scoreLabel(score), issues };
147
+ }
148
+
149
+ function applyFixes(text, issues) {
150
+ const fixes = issues
151
+ .filter((i) => i.fix)
152
+ .map((i) => i.fix)
153
+ .sort((a, b) => a.range[0] - b.range[0]);
154
+ const kept = [];
155
+ let lastEnd = -1;
156
+ for (const f of fixes) {
157
+ if (f.range[0] < lastEnd) continue;
158
+ kept.push(f);
159
+ lastEnd = f.range[1];
160
+ }
161
+ let out = text;
162
+ for (const f of kept.reverse()) out = out.slice(0, f.range[0]) + f.text + out.slice(f.range[1]);
163
+ return { output: out, applied: kept.length };
164
+ }
165
+
166
+ /**
167
+ * Applies every safe autofix. Runs a few passes because one fix can reveal another.
168
+ * @returns {{ output: string, fixed: number, result: ReturnType<typeof lint> }}
169
+ */
170
+ export function fix(text, options) {
171
+ let output = text;
172
+ let fixed = 0;
173
+ for (let pass = 0; pass < 5; pass++) {
174
+ const { output: next, applied } = applyFixes(output, lint(output, options).issues);
175
+ if (applied === 0) break;
176
+ output = next;
177
+ fixed += applied;
178
+ }
179
+ return { output, fixed, result: lint(output, options) };
180
+ }
181
+
182
+ export default { lint, fix, rules, detectLanguage, computeScore, scoreLabel, mask, languages, defaults };
package/src/mask.js ADDED
@@ -0,0 +1,65 @@
1
+ // Hides the parts of a document that are not prose (code, URLs, HTML, front matter, disabled regions)
2
+ // by replacing them with spaces. Offsets stay identical, so issues map back to the original text.
3
+
4
+ const DISABLE = /<!--\s*slopfix-disable\s*-->/g;
5
+ const ENABLE = /<!--\s*slopfix-enable\s*-->/g;
6
+ const DISABLE_NEXT_LINE = /<!--\s*slopfix-disable-next-line\s*-->/g;
7
+
8
+ function blank(chars, start, end) {
9
+ for (let i = start; i < end && i < chars.length; i++) {
10
+ if (chars[i] !== '\n') chars[i] = ' ';
11
+ }
12
+ }
13
+
14
+ function blankAll(chars, text, re, group = 0) {
15
+ for (const m of text.matchAll(re)) {
16
+ const start = group ? m.index + m[0].indexOf(m[group]) : m.index;
17
+ const len = group ? m[group].length : m[0].length;
18
+ blank(chars, start, start + len);
19
+ }
20
+ }
21
+
22
+ export function disabledRanges(text) {
23
+ const ranges = [];
24
+ for (const m of text.matchAll(DISABLE)) {
25
+ ENABLE.lastIndex = m.index;
26
+ const end = ENABLE.exec(text);
27
+ ranges.push([m.index, end ? end.index + end[0].length : text.length]);
28
+ }
29
+ for (const m of text.matchAll(DISABLE_NEXT_LINE)) {
30
+ const lineEnd = text.indexOf('\n', m.index);
31
+ if (lineEnd === -1) continue;
32
+ const nextEnd = text.indexOf('\n', lineEnd + 1);
33
+ ranges.push([m.index, nextEnd === -1 ? text.length : nextEnd]);
34
+ }
35
+ return ranges;
36
+ }
37
+
38
+ export function mask(text, { markdown = true } = {}) {
39
+ const chars = text.split('');
40
+
41
+ for (const [start, end] of disabledRanges(text)) blank(chars, start, end);
42
+
43
+ if (markdown) {
44
+ // Front matter
45
+ const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(text);
46
+ if (fm) blank(chars, 0, fm[0].length);
47
+ // Fenced code blocks (``` or ~~~), unterminated fences run to the end
48
+ blankAll(chars, text, /^([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^\1?\2[ \t]*$|(?![\s\S]))/gm);
49
+ // Inline code, never across a blank line
50
+ blankAll(chars, text, /(`+)(?:(?!\1)(?!\n[ \t]*\n)[\s\S])+?\1/g);
51
+ // HTML comments and tags
52
+ blankAll(chars, text, /<!--[\s\S]*?-->/g);
53
+ blankAll(chars, text, /<\/?[a-zA-Z][^>\n]*>/g);
54
+ // Link and image destinations: keep the label, hide the URL
55
+ blankAll(chars, text, /\]\(([^)\s]+(?:\s+"[^"]*")?)\)/g, 1);
56
+ // Reference-style link definitions
57
+ blankAll(chars, text, /^[ \t]*\[[^\]]+\]:[ \t]*\S+.*$/gm);
58
+ }
59
+
60
+ // Bare URLs and emails in any format
61
+ blankAll(chars, text, /\bhttps?:\/\/[^\s<>()]+/g);
62
+ blankAll(chars, text, /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g);
63
+
64
+ return chars.join('');
65
+ }
@@ -0,0 +1,86 @@
1
+ import { regexRule } from './helpers.js';
2
+
3
+ // Rules that apply to every language.
4
+
5
+ const EM_DASH = '\u2014';
6
+ const EN_DASH = '\u2013';
7
+
8
+ function lineBefore(text, index) {
9
+ const start = text.lastIndexOf('\n', index - 1) + 1;
10
+ return text.slice(start, index);
11
+ }
12
+
13
+ function lineAfter(text, index) {
14
+ const end = text.indexOf('\n', index);
15
+ return text.slice(index, end === -1 ? text.length : end);
16
+ }
17
+
18
+ export default [
19
+ regexRule({
20
+ id: 'em-dash',
21
+ lang: '*',
22
+ category: 'punctuation',
23
+ severity: 'warning',
24
+ message: 'Em dash. The most recognizable AI writing tell.',
25
+ patterns: [new RegExp(`[ \\t]*${EM_DASH}[ \\t]*`, 'g')],
26
+ fix(m, text, ctx) {
27
+ // Leave dialogue dashes and list markers at the start of a line alone, and trailing dashes too.
28
+ if (lineBefore(text, m.index).trim() === '') return null;
29
+ if (lineAfter(text, m.index + m[0].length).trim() === '') return null;
30
+ return ctx.options.dashReplacement;
31
+ },
32
+ }),
33
+ regexRule({
34
+ id: 'spaced-en-dash',
35
+ lang: '*',
36
+ category: 'punctuation',
37
+ severity: 'warning',
38
+ message: 'Spaced en dash used as an em dash.',
39
+ // Only " - " style usage with the en dash. Ranges like 2020-2024 written with an en dash are fine.
40
+ patterns: [new RegExp(`(?<=\\S)[ \\t]+${EN_DASH}[ \\t]+(?=\\S)`, 'g')],
41
+ fix(m, text, ctx) {
42
+ if (lineBefore(text, m.index).trim() === '') return null;
43
+ return ctx.options.dashReplacement;
44
+ },
45
+ }),
46
+ regexRule({
47
+ id: 'emoji-bullet',
48
+ lang: '*',
49
+ category: 'formatting',
50
+ severity: 'warning',
51
+ message: 'Emoji used as a bullet or heading decoration.',
52
+ patterns: [/(?<=^[ \t]*(?:[-*+][ \t]+|\d+[.)][ \t]+|#{1,6}[ \t]+)?)\p{Extended_Pictographic}️?[ \t]*/gmu],
53
+ fix(m, text) {
54
+ // Only autofix when the emoji sits right after a list marker or heading marker.
55
+ const before = lineBefore(text, m.index);
56
+ return /^[ \t]*(?:[-*+]|\d+[.)]|#{1,6})[ \t]+$/.test(before) ? '' : null;
57
+ },
58
+ }),
59
+ {
60
+ id: 'bold-label-list',
61
+ lang: '*',
62
+ category: 'formatting',
63
+ severity: 'info',
64
+ message: 'Three or more list items in a row that start with a **Bold label:**. Classic chatbot formatting.',
65
+ *find(text) {
66
+ const lines = text.split('\n');
67
+ const re = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\p{Extended_Pictographic}️?[ \t]*)?)(\*\*[^*\n]{1,60}?\*\*)/u;
68
+ let offset = 0;
69
+ let runLength = 0;
70
+ let first = null;
71
+ for (let i = 0; i <= lines.length; i++) {
72
+ const line = lines[i];
73
+ const m = line === undefined ? null : re.exec(line);
74
+ if (m) {
75
+ // Point at the bold label of the first item only, so other rules on that line still report.
76
+ if (runLength === 0) first = { index: offset + m[1].length, length: m[2].length, quote: m[2] };
77
+ runLength++;
78
+ } else {
79
+ if (runLength >= 3) yield first;
80
+ runLength = 0;
81
+ }
82
+ if (line !== undefined) offset += line.length + 1;
83
+ }
84
+ },
85
+ },
86
+ ];