slopless 0.2.21 → 0.2.23
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/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](/actions/workflows/ci.yml)
|
|
10
10
|
[](https://socket.dev/npm/package/slopless)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Give it to your writing agent and it stops handing you AI-slop prose. Slopless is a deterministic linter - a textlint preset and a zero-config CLI - that flags the LLM tells (hollow framing, fake contrasts, hedging, em-dash tics, vacuous closers, and many more) so the agent rewrites until the text reads human. No model calls, no API key.
|
|
13
13
|
|
|
14
14
|
## What it catches
|
|
15
15
|
|
|
@@ -59,9 +59,12 @@ Loop:
|
|
|
59
59
|
5. Let the agent run Slopless, rewrite, and rerun until the JSON output has no findings.
|
|
60
60
|
6. Profit.
|
|
61
61
|
|
|
62
|
-
##
|
|
62
|
+
## Set up the CLI
|
|
63
|
+
|
|
64
|
+
The CLI bundles textlint, so it needs no separate textlint install and no `.textlintrc`. This is the recommended path for writing agents and one-off checks.
|
|
63
65
|
|
|
64
66
|
```bash
|
|
67
|
+
npm install -D slopless
|
|
65
68
|
npx slopless "docs/**/*.md"
|
|
66
69
|
```
|
|
67
70
|
|
|
@@ -76,6 +79,30 @@ mkdir -p .slopless/findings
|
|
|
76
79
|
npx slopless "docs/**/*.md" > ".slopless/findings/$(date +%Y-%m-%d-%H%M%S)--review.json"
|
|
77
80
|
```
|
|
78
81
|
|
|
82
|
+
## Set up the textlint preset
|
|
83
|
+
|
|
84
|
+
If you already run [textlint](https://textlint.github.io/), add slopless as a preset instead. It runs through your existing textlint, alongside your other rules and `.textlintrc`, and supports textlint's output formatters (the CLI is always JSON).
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm install -D slopless textlint
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Add `preset-slopless` to your `.textlintrc.json`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"rules": {
|
|
95
|
+
"preset-slopless": true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npx textlint "docs/**/*.md"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Findings use the same `slopless/<rule>` ids as the CLI. Turn off individual rules with `"preset-slopless": { "cliches": false }`. To honor the `<!-- textlint-disable -->` blocks below, also `npm install -D textlint-filter-rule-comments` and add `"filters": { "comments": true }` to the config.
|
|
105
|
+
|
|
79
106
|
## Agent Use
|
|
80
107
|
|
|
81
108
|
Agents should run help first:
|
|
@@ -133,10 +160,6 @@ npm audit signatures slopless
|
|
|
133
160
|
|
|
134
161
|
This confirms the tarball you installed was built in the published source repository's Actions environment, from the commit the GitHub Release points at.
|
|
135
162
|
|
|
136
|
-
## Star history
|
|
137
|
-
|
|
138
|
-
[](https://star-history.com/#seochecks-ai/slopless&Date)
|
|
139
|
-
|
|
140
163
|
## Credits
|
|
141
164
|
|
|
142
165
|
[Graham Rowe](https://github.com/grahamrowe82/antislop), thanks for giving me new ideas for classes of slop to detect. Your lib is now fully incorporated with your permission.
|
|
@@ -1,285 +1,7 @@
|
|
|
1
|
-
import { hasConcreteInventorySubjects } from "../../../shared/matchers/concrete-evidence.js";
|
|
2
|
-
import { splitSentences } from "../../../shared/text/sentences.js";
|
|
3
|
-
import { splitWhitespace } from "../../../shared/text/whitespace.js";
|
|
4
1
|
import { oneToOneRule } from "../../private/textlint-rule-builders.js";
|
|
5
|
-
|
|
6
|
-
const MAX_PAYOFF_WORDS = 28;
|
|
7
|
-
const SUBJECT_WORDS = new Set([
|
|
8
|
-
"i",
|
|
9
|
-
"you",
|
|
10
|
-
"we",
|
|
11
|
-
"they",
|
|
12
|
-
"he",
|
|
13
|
-
"she",
|
|
14
|
-
"it",
|
|
15
|
-
"this",
|
|
16
|
-
"that",
|
|
17
|
-
"these",
|
|
18
|
-
"those"
|
|
19
|
-
]);
|
|
20
|
-
const OBJECT_WORDS = new Set([
|
|
21
|
-
"me",
|
|
22
|
-
"him",
|
|
23
|
-
"her",
|
|
24
|
-
"us",
|
|
25
|
-
"them",
|
|
26
|
-
"it",
|
|
27
|
-
"the",
|
|
28
|
-
"a",
|
|
29
|
-
"an",
|
|
30
|
-
"my",
|
|
31
|
-
"your",
|
|
32
|
-
"our",
|
|
33
|
-
"their"
|
|
34
|
-
]);
|
|
35
|
-
const FINITE_VERBS = new Set([
|
|
36
|
-
"is",
|
|
37
|
-
"are",
|
|
38
|
-
"was",
|
|
39
|
-
"were",
|
|
40
|
-
"am",
|
|
41
|
-
"be",
|
|
42
|
-
"been",
|
|
43
|
-
"being",
|
|
44
|
-
"have",
|
|
45
|
-
"has",
|
|
46
|
-
"had",
|
|
47
|
-
"do",
|
|
48
|
-
"does",
|
|
49
|
-
"did",
|
|
50
|
-
"can",
|
|
51
|
-
"could",
|
|
52
|
-
"will",
|
|
53
|
-
"would",
|
|
54
|
-
"should",
|
|
55
|
-
"may",
|
|
56
|
-
"might",
|
|
57
|
-
"must",
|
|
58
|
-
"shall"
|
|
59
|
-
]);
|
|
60
|
-
const FRAGMENT_LEADS = new Set([
|
|
61
|
-
"too",
|
|
62
|
-
"most",
|
|
63
|
-
"more",
|
|
64
|
-
"less",
|
|
65
|
-
"deeply",
|
|
66
|
-
"completely",
|
|
67
|
-
"possibly",
|
|
68
|
-
"probably",
|
|
69
|
-
"maybe",
|
|
70
|
-
"weird",
|
|
71
|
-
"strange",
|
|
72
|
-
"odd",
|
|
73
|
-
"pure",
|
|
74
|
-
"total",
|
|
75
|
-
"incredibly"
|
|
76
|
-
]);
|
|
77
|
-
const PAYOFF_STARTS = ["more like ", "most ", "then ", "instead "];
|
|
78
|
-
const IMPERATIVE_STARTS = new Set([
|
|
79
|
-
"feed",
|
|
80
|
-
"leave",
|
|
81
|
-
"notice",
|
|
82
|
-
"stop",
|
|
83
|
-
"start",
|
|
84
|
-
"take",
|
|
85
|
-
"keep",
|
|
86
|
-
"get",
|
|
87
|
-
"look",
|
|
88
|
-
"think",
|
|
89
|
-
"try",
|
|
90
|
-
"make",
|
|
91
|
-
"let",
|
|
92
|
-
"give",
|
|
93
|
-
"accept",
|
|
94
|
-
"hold",
|
|
95
|
-
"reduce"
|
|
96
|
-
]);
|
|
97
|
-
const SIMPLE_PAST_VERBS = new Set([
|
|
98
|
-
"ran",
|
|
99
|
-
"went",
|
|
100
|
-
"came",
|
|
101
|
-
"felt",
|
|
102
|
-
"heard",
|
|
103
|
-
"found",
|
|
104
|
-
"made",
|
|
105
|
-
"took",
|
|
106
|
-
"kept",
|
|
107
|
-
"left",
|
|
108
|
-
"thought",
|
|
109
|
-
"knew",
|
|
110
|
-
"got",
|
|
111
|
-
"put",
|
|
112
|
-
"said",
|
|
113
|
-
"told",
|
|
114
|
-
"held",
|
|
115
|
-
"stood",
|
|
116
|
-
"sat",
|
|
117
|
-
"became",
|
|
118
|
-
"wrote",
|
|
119
|
-
"spoke",
|
|
120
|
-
"won",
|
|
121
|
-
"lost",
|
|
122
|
-
"paid",
|
|
123
|
-
"met",
|
|
124
|
-
"read",
|
|
125
|
-
"saw",
|
|
126
|
-
"grew",
|
|
127
|
-
"fell",
|
|
128
|
-
"broke"
|
|
129
|
-
]);
|
|
130
|
-
function isAlphanumeric(character) {
|
|
131
|
-
const lower = character.toLocaleLowerCase("en");
|
|
132
|
-
const upper = character.toLocaleUpperCase("en");
|
|
133
|
-
return (character >= "0" && character <= "9") || lower !== upper;
|
|
134
|
-
}
|
|
135
|
-
function cleanWord(word) {
|
|
136
|
-
const normalized = word.toLocaleLowerCase("en").replaceAll("\u2019", "'");
|
|
137
|
-
let start = 0;
|
|
138
|
-
let end = normalized.length;
|
|
139
|
-
while (start < end &&
|
|
140
|
-
normalized[start] !== "'" &&
|
|
141
|
-
!isAlphanumeric(normalized[start] ?? "")) {
|
|
142
|
-
start += 1;
|
|
143
|
-
}
|
|
144
|
-
while (end > start &&
|
|
145
|
-
normalized[end - 1] !== "'" &&
|
|
146
|
-
!isAlphanumeric(normalized[end - 1] ?? "")) {
|
|
147
|
-
end -= 1;
|
|
148
|
-
}
|
|
149
|
-
return normalized.slice(start, end);
|
|
150
|
-
}
|
|
151
|
-
function sentenceWords(sentence) {
|
|
152
|
-
return splitWhitespace(sentence).map(cleanWord);
|
|
153
|
-
}
|
|
154
|
-
function isFunctionWord(word) {
|
|
155
|
-
return (word === "the" ||
|
|
156
|
-
word === "a" ||
|
|
157
|
-
word === "an" ||
|
|
158
|
-
word === "and" ||
|
|
159
|
-
word === "or" ||
|
|
160
|
-
word === "but" ||
|
|
161
|
-
word === "to" ||
|
|
162
|
-
word === "of" ||
|
|
163
|
-
word === "in" ||
|
|
164
|
-
word === "on" ||
|
|
165
|
-
word === "at" ||
|
|
166
|
-
word === "for");
|
|
167
|
-
}
|
|
168
|
-
function looksLikeModifierPhrase(first, second) {
|
|
169
|
-
return (first.endsWith("ly") ||
|
|
170
|
-
second.endsWith("ive") ||
|
|
171
|
-
second.endsWith("ous") ||
|
|
172
|
-
second.endsWith("al") ||
|
|
173
|
-
second.endsWith("ful") ||
|
|
174
|
-
second.endsWith("less"));
|
|
175
|
-
}
|
|
176
|
-
function looksLikeSubjectDrop(first, second) {
|
|
177
|
-
return (((first.endsWith("ed") && !first.endsWith("eed")) ||
|
|
178
|
-
first.endsWith("ing")) &&
|
|
179
|
-
!isFunctionWord(second));
|
|
180
|
-
}
|
|
181
|
-
function looksLikeSimpleClause(first, second) {
|
|
182
|
-
return (!isFunctionWord(first) &&
|
|
183
|
-
(second.endsWith("ed") || SIMPLE_PAST_VERBS.has(second)));
|
|
184
|
-
}
|
|
185
|
-
function looksLikeBriefImperative(first, second) {
|
|
186
|
-
return (IMPERATIVE_STARTS.has(first) &&
|
|
187
|
-
(OBJECT_WORDS.has(second) || second.endsWith("er") || second.endsWith("ly")));
|
|
188
|
-
}
|
|
189
|
-
function classifyFragment(sentence) {
|
|
190
|
-
const words = sentenceWords(sentence.text);
|
|
191
|
-
if (words.length < 2 || words.length > MAX_FRAGMENT_WORDS) {
|
|
192
|
-
return undefined;
|
|
193
|
-
}
|
|
194
|
-
if (words.some((word) => word === "")) {
|
|
195
|
-
return undefined;
|
|
196
|
-
}
|
|
197
|
-
if (words.some((word) => SUBJECT_WORDS.has(word))) {
|
|
198
|
-
return undefined;
|
|
199
|
-
}
|
|
200
|
-
if (words.some((word) => FINITE_VERBS.has(word))) {
|
|
201
|
-
return undefined;
|
|
202
|
-
}
|
|
203
|
-
const first = words[0];
|
|
204
|
-
const second = words[1];
|
|
205
|
-
if (first === undefined || second === undefined) {
|
|
206
|
-
return undefined;
|
|
207
|
-
}
|
|
208
|
-
if (FRAGMENT_LEADS.has(first) || looksLikeModifierPhrase(first, second)) {
|
|
209
|
-
return "modifier-fragment";
|
|
210
|
-
}
|
|
211
|
-
if (looksLikeSimpleClause(first, second) ||
|
|
212
|
-
looksLikeBriefImperative(first, second)) {
|
|
213
|
-
return undefined;
|
|
214
|
-
}
|
|
215
|
-
if (looksLikeSubjectDrop(first, second)) {
|
|
216
|
-
return "subject-drop";
|
|
217
|
-
}
|
|
218
|
-
return "noun-fragment";
|
|
219
|
-
}
|
|
220
|
-
function looksLikePayoffSentence(sentence) {
|
|
221
|
-
const words = sentenceWords(sentence.text);
|
|
222
|
-
if (words.length <= 2 || words.length > MAX_PAYOFF_WORDS) {
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
const lowered = sentence.text.toLocaleLowerCase("en");
|
|
226
|
-
if (!PAYOFF_STARTS.some((start) => lowered.startsWith(start))) {
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
return !words.some((word) => SUBJECT_WORDS.has(word));
|
|
230
|
-
}
|
|
231
|
-
function findFragmentStacks(text) {
|
|
232
|
-
const sentences = splitSentences(text);
|
|
233
|
-
const classifications = sentences.map(classifyFragment);
|
|
234
|
-
const matches = [];
|
|
235
|
-
let sentenceIndex = 0;
|
|
236
|
-
while (sentenceIndex < sentences.length) {
|
|
237
|
-
const firstType = classifications[sentenceIndex];
|
|
238
|
-
const firstSentence = sentences[sentenceIndex];
|
|
239
|
-
if (firstType === undefined || firstSentence === undefined) {
|
|
240
|
-
sentenceIndex += 1;
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
const runSentences = [firstSentence];
|
|
244
|
-
const fragmentTypes = [firstType];
|
|
245
|
-
let cursor = sentenceIndex + 1;
|
|
246
|
-
while (cursor < sentences.length) {
|
|
247
|
-
const sentence = sentences[cursor];
|
|
248
|
-
const nextType = classifications[cursor];
|
|
249
|
-
if (sentence === undefined) {
|
|
250
|
-
break;
|
|
251
|
-
}
|
|
252
|
-
if (nextType !== undefined) {
|
|
253
|
-
runSentences.push(sentence);
|
|
254
|
-
fragmentTypes.push(nextType);
|
|
255
|
-
cursor += 1;
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
if (looksLikePayoffSentence(sentence)) {
|
|
259
|
-
runSentences.push(sentence);
|
|
260
|
-
cursor += 1;
|
|
261
|
-
}
|
|
262
|
-
break;
|
|
263
|
-
}
|
|
264
|
-
if (fragmentTypes.length >= 2 &&
|
|
265
|
-
runSentences.length >= 3 &&
|
|
266
|
-
!hasConcreteInventorySubjects(runSentences.map((sentence) => sentence.text))) {
|
|
267
|
-
const last = runSentences[runSentences.length - 1];
|
|
268
|
-
if (last !== undefined) {
|
|
269
|
-
matches.push({
|
|
270
|
-
end: last.end,
|
|
271
|
-
fragmentTypes,
|
|
272
|
-
sentences: runSentences.map((sentence) => sentence.text),
|
|
273
|
-
start: firstSentence.start
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
sentenceIndex = Math.max(cursor, sentenceIndex + 1);
|
|
278
|
-
}
|
|
279
|
-
return matches;
|
|
280
|
-
}
|
|
2
|
+
import { findFragmentMatches } from "./private/fragment-stack-detector.js";
|
|
281
3
|
const rule = oneToOneRule({
|
|
282
|
-
detect: (unit) =>
|
|
4
|
+
detect: (unit) => findFragmentMatches(unit.text).map((match) => ({
|
|
283
5
|
evidence: match.sentences.join(" "),
|
|
284
6
|
label: match.fragmentTypes.join(","),
|
|
285
7
|
range: { start: match.start, end: match.end }
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { hasConcreteInventorySubjects } from "../../../../shared/matchers/concrete-evidence.js";
|
|
2
|
+
import { splitSentences } from "../../../../shared/text/sentences.js";
|
|
3
|
+
import { splitWhitespace } from "../../../../shared/text/whitespace.js";
|
|
4
|
+
const MAX_FRAGMENT_WORDS = 6;
|
|
5
|
+
const MAX_NO_FRAGMENT_WORDS = 5;
|
|
6
|
+
const MAX_PAYOFF_WORDS = 28;
|
|
7
|
+
const SUBJECT_WORDS = new Set([
|
|
8
|
+
"i",
|
|
9
|
+
"you",
|
|
10
|
+
"we",
|
|
11
|
+
"they",
|
|
12
|
+
"he",
|
|
13
|
+
"she",
|
|
14
|
+
"it",
|
|
15
|
+
"this",
|
|
16
|
+
"that",
|
|
17
|
+
"these",
|
|
18
|
+
"those"
|
|
19
|
+
]);
|
|
20
|
+
const OBJECT_WORDS = new Set([
|
|
21
|
+
"me",
|
|
22
|
+
"him",
|
|
23
|
+
"her",
|
|
24
|
+
"us",
|
|
25
|
+
"them",
|
|
26
|
+
"it",
|
|
27
|
+
"the",
|
|
28
|
+
"a",
|
|
29
|
+
"an",
|
|
30
|
+
"my",
|
|
31
|
+
"your",
|
|
32
|
+
"our",
|
|
33
|
+
"their"
|
|
34
|
+
]);
|
|
35
|
+
const FINITE_VERBS = new Set([
|
|
36
|
+
"is",
|
|
37
|
+
"are",
|
|
38
|
+
"was",
|
|
39
|
+
"were",
|
|
40
|
+
"am",
|
|
41
|
+
"be",
|
|
42
|
+
"been",
|
|
43
|
+
"being",
|
|
44
|
+
"have",
|
|
45
|
+
"has",
|
|
46
|
+
"had",
|
|
47
|
+
"do",
|
|
48
|
+
"does",
|
|
49
|
+
"did",
|
|
50
|
+
"can",
|
|
51
|
+
"could",
|
|
52
|
+
"will",
|
|
53
|
+
"would",
|
|
54
|
+
"should",
|
|
55
|
+
"may",
|
|
56
|
+
"might",
|
|
57
|
+
"must",
|
|
58
|
+
"shall"
|
|
59
|
+
]);
|
|
60
|
+
const FRAGMENT_LEADS = new Set([
|
|
61
|
+
"too",
|
|
62
|
+
"most",
|
|
63
|
+
"more",
|
|
64
|
+
"less",
|
|
65
|
+
"deeply",
|
|
66
|
+
"completely",
|
|
67
|
+
"possibly",
|
|
68
|
+
"probably",
|
|
69
|
+
"maybe",
|
|
70
|
+
"weird",
|
|
71
|
+
"strange",
|
|
72
|
+
"odd",
|
|
73
|
+
"pure",
|
|
74
|
+
"total",
|
|
75
|
+
"incredibly"
|
|
76
|
+
]);
|
|
77
|
+
const PAYOFF_STARTS = ["more like ", "most ", "then ", "instead "];
|
|
78
|
+
const IMPERATIVE_STARTS = new Set([
|
|
79
|
+
"feed",
|
|
80
|
+
"leave",
|
|
81
|
+
"notice",
|
|
82
|
+
"stop",
|
|
83
|
+
"start",
|
|
84
|
+
"take",
|
|
85
|
+
"keep",
|
|
86
|
+
"get",
|
|
87
|
+
"look",
|
|
88
|
+
"think",
|
|
89
|
+
"try",
|
|
90
|
+
"make",
|
|
91
|
+
"let",
|
|
92
|
+
"give",
|
|
93
|
+
"accept",
|
|
94
|
+
"hold",
|
|
95
|
+
"reduce"
|
|
96
|
+
]);
|
|
97
|
+
const SIMPLE_PAST_VERBS = new Set([
|
|
98
|
+
"ran",
|
|
99
|
+
"went",
|
|
100
|
+
"came",
|
|
101
|
+
"felt",
|
|
102
|
+
"heard",
|
|
103
|
+
"found",
|
|
104
|
+
"made",
|
|
105
|
+
"took",
|
|
106
|
+
"kept",
|
|
107
|
+
"left",
|
|
108
|
+
"thought",
|
|
109
|
+
"knew",
|
|
110
|
+
"got",
|
|
111
|
+
"put",
|
|
112
|
+
"said",
|
|
113
|
+
"told",
|
|
114
|
+
"held",
|
|
115
|
+
"stood",
|
|
116
|
+
"sat",
|
|
117
|
+
"became",
|
|
118
|
+
"wrote",
|
|
119
|
+
"spoke",
|
|
120
|
+
"won",
|
|
121
|
+
"lost",
|
|
122
|
+
"paid",
|
|
123
|
+
"met",
|
|
124
|
+
"read",
|
|
125
|
+
"saw",
|
|
126
|
+
"grew",
|
|
127
|
+
"fell",
|
|
128
|
+
"broke"
|
|
129
|
+
]);
|
|
130
|
+
function isAlphanumeric(character) {
|
|
131
|
+
const lower = character.toLocaleLowerCase("en");
|
|
132
|
+
const upper = character.toLocaleUpperCase("en");
|
|
133
|
+
return (character >= "0" && character <= "9") || lower !== upper;
|
|
134
|
+
}
|
|
135
|
+
function cleanWord(word) {
|
|
136
|
+
const normalized = word.toLocaleLowerCase("en").replaceAll("\u2019", "'");
|
|
137
|
+
let start = 0;
|
|
138
|
+
let end = normalized.length;
|
|
139
|
+
while (start < end &&
|
|
140
|
+
normalized[start] !== "'" &&
|
|
141
|
+
!isAlphanumeric(normalized[start] ?? "")) {
|
|
142
|
+
start += 1;
|
|
143
|
+
}
|
|
144
|
+
while (end > start &&
|
|
145
|
+
normalized[end - 1] !== "'" &&
|
|
146
|
+
!isAlphanumeric(normalized[end - 1] ?? "")) {
|
|
147
|
+
end -= 1;
|
|
148
|
+
}
|
|
149
|
+
return normalized.slice(start, end);
|
|
150
|
+
}
|
|
151
|
+
function sentenceWords(sentence) {
|
|
152
|
+
return splitWhitespace(sentence).map(cleanWord);
|
|
153
|
+
}
|
|
154
|
+
function isFunctionWord(word) {
|
|
155
|
+
return (word === "the" ||
|
|
156
|
+
word === "a" ||
|
|
157
|
+
word === "an" ||
|
|
158
|
+
word === "and" ||
|
|
159
|
+
word === "or" ||
|
|
160
|
+
word === "but" ||
|
|
161
|
+
word === "to" ||
|
|
162
|
+
word === "of" ||
|
|
163
|
+
word === "in" ||
|
|
164
|
+
word === "on" ||
|
|
165
|
+
word === "at" ||
|
|
166
|
+
word === "for");
|
|
167
|
+
}
|
|
168
|
+
function looksLikeModifierPhrase(first, second) {
|
|
169
|
+
return (first.endsWith("ly") ||
|
|
170
|
+
second.endsWith("ive") ||
|
|
171
|
+
second.endsWith("ous") ||
|
|
172
|
+
second.endsWith("al") ||
|
|
173
|
+
second.endsWith("ful") ||
|
|
174
|
+
second.endsWith("less"));
|
|
175
|
+
}
|
|
176
|
+
function looksLikeSubjectDrop(first, second) {
|
|
177
|
+
return (((first.endsWith("ed") && !first.endsWith("eed")) ||
|
|
178
|
+
first.endsWith("ing")) &&
|
|
179
|
+
!isFunctionWord(second));
|
|
180
|
+
}
|
|
181
|
+
function looksLikeSimpleClause(first, second) {
|
|
182
|
+
return (!isFunctionWord(first) &&
|
|
183
|
+
(second.endsWith("ed") || SIMPLE_PAST_VERBS.has(second)));
|
|
184
|
+
}
|
|
185
|
+
function looksLikeBriefImperative(first, second) {
|
|
186
|
+
return (IMPERATIVE_STARTS.has(first) &&
|
|
187
|
+
(OBJECT_WORDS.has(second) || second.endsWith("er") || second.endsWith("ly")));
|
|
188
|
+
}
|
|
189
|
+
function classifyFragment(sentence) {
|
|
190
|
+
const words = sentenceWords(sentence.text);
|
|
191
|
+
if (words.length < 2 || words.length > MAX_FRAGMENT_WORDS) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
if (words.some((word) => word === "")) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if (words.some((word) => SUBJECT_WORDS.has(word))) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
if (words.some((word) => FINITE_VERBS.has(word))) {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
const first = words[0];
|
|
204
|
+
const second = words[1];
|
|
205
|
+
if (first === undefined || second === undefined) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
if (FRAGMENT_LEADS.has(first) || looksLikeModifierPhrase(first, second)) {
|
|
209
|
+
return "modifier-fragment";
|
|
210
|
+
}
|
|
211
|
+
if (looksLikeSimpleClause(first, second) ||
|
|
212
|
+
looksLikeBriefImperative(first, second)) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
if (looksLikeSubjectDrop(first, second)) {
|
|
216
|
+
return "subject-drop";
|
|
217
|
+
}
|
|
218
|
+
return "noun-fragment";
|
|
219
|
+
}
|
|
220
|
+
function looksLikePayoffSentence(sentence) {
|
|
221
|
+
const words = sentenceWords(sentence.text);
|
|
222
|
+
if (words.length <= 2 || words.length > MAX_PAYOFF_WORDS) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
const lowered = sentence.text.toLocaleLowerCase("en");
|
|
226
|
+
if (!PAYOFF_STARTS.some((start) => lowered.startsWith(start))) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return !words.some((word) => SUBJECT_WORDS.has(word));
|
|
230
|
+
}
|
|
231
|
+
function isNoFragment(sentence) {
|
|
232
|
+
const words = sentenceWords(sentence.text);
|
|
233
|
+
return (words.length >= 2 &&
|
|
234
|
+
words.length <= MAX_NO_FRAGMENT_WORDS &&
|
|
235
|
+
words[0] === "no" &&
|
|
236
|
+
!words.some((word) => FINITE_VERBS.has(word)) &&
|
|
237
|
+
classifyFragment(sentence) === "noun-fragment");
|
|
238
|
+
}
|
|
239
|
+
function findNoFragmentPairs(text) {
|
|
240
|
+
const sentences = splitSentences(text);
|
|
241
|
+
const matches = [];
|
|
242
|
+
for (let index = 0; index < sentences.length - 1; index += 1) {
|
|
243
|
+
const first = sentences[index];
|
|
244
|
+
const second = sentences[index + 1];
|
|
245
|
+
if (first === undefined || second === undefined) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const pair = [first.text, second.text];
|
|
249
|
+
if (!isNoFragment(first) ||
|
|
250
|
+
!isNoFragment(second) ||
|
|
251
|
+
hasConcreteInventorySubjects(pair)) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
matches.push({
|
|
255
|
+
end: second.end,
|
|
256
|
+
fragmentTypes: ["no-fragment", "no-fragment"],
|
|
257
|
+
sentences: pair,
|
|
258
|
+
start: first.start
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return matches;
|
|
262
|
+
}
|
|
263
|
+
function findFragmentStacks(text) {
|
|
264
|
+
const sentences = splitSentences(text);
|
|
265
|
+
const classifications = sentences.map(classifyFragment);
|
|
266
|
+
const matches = [];
|
|
267
|
+
let sentenceIndex = 0;
|
|
268
|
+
while (sentenceIndex < sentences.length) {
|
|
269
|
+
const firstType = classifications[sentenceIndex];
|
|
270
|
+
const firstSentence = sentences[sentenceIndex];
|
|
271
|
+
if (firstType === undefined || firstSentence === undefined) {
|
|
272
|
+
sentenceIndex += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const runSentences = [firstSentence];
|
|
276
|
+
const fragmentTypes = [firstType];
|
|
277
|
+
let cursor = sentenceIndex + 1;
|
|
278
|
+
while (cursor < sentences.length) {
|
|
279
|
+
const sentence = sentences[cursor];
|
|
280
|
+
const nextType = classifications[cursor];
|
|
281
|
+
if (sentence === undefined) {
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
if (nextType !== undefined) {
|
|
285
|
+
runSentences.push(sentence);
|
|
286
|
+
fragmentTypes.push(nextType);
|
|
287
|
+
cursor += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (looksLikePayoffSentence(sentence)) {
|
|
291
|
+
runSentences.push(sentence);
|
|
292
|
+
cursor += 1;
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
if (fragmentTypes.length >= 2 &&
|
|
297
|
+
runSentences.length >= 3 &&
|
|
298
|
+
!hasConcreteInventorySubjects(runSentences.map((sentence) => sentence.text))) {
|
|
299
|
+
const last = runSentences[runSentences.length - 1];
|
|
300
|
+
if (last !== undefined) {
|
|
301
|
+
matches.push({
|
|
302
|
+
end: last.end,
|
|
303
|
+
fragmentTypes,
|
|
304
|
+
sentences: runSentences.map((sentence) => sentence.text),
|
|
305
|
+
start: firstSentence.start
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
sentenceIndex = Math.max(cursor, sentenceIndex + 1);
|
|
310
|
+
}
|
|
311
|
+
return matches;
|
|
312
|
+
}
|
|
313
|
+
export function findFragmentMatches(text) {
|
|
314
|
+
return [...findNoFragmentPairs(text), ...findFragmentStacks(text)];
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=fragment-stack-detector.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slopless",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.23",
|
|
4
4
|
"description": "Deterministic textlint rules and CLI for catching prose slop in English Markdown.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"textlint",
|
|
@@ -124,7 +124,6 @@
|
|
|
124
124
|
"textlint-util-to-string": "3.3.4"
|
|
125
125
|
},
|
|
126
126
|
"devDependencies": {
|
|
127
|
-
"@double-great/stylelint-a11y": "3.4.13",
|
|
128
127
|
"@eslint-community/eslint-plugin-eslint-comments": "4.7.1",
|
|
129
128
|
"@types/node": "22.19.19",
|
|
130
129
|
"@typescript-eslint/eslint-plugin": "8.59.4",
|
|
@@ -132,15 +131,9 @@
|
|
|
132
131
|
"cspell": "10.0.0",
|
|
133
132
|
"eslint": "10.4.0",
|
|
134
133
|
"eslint-plugin-import-x": "4.16.2",
|
|
135
|
-
"eslint-plugin-regexp": "3.1.0",
|
|
136
134
|
"eslint-plugin-sonarjs": "4.0.3",
|
|
137
135
|
"eslint-plugin-unicorn": "64.0.0",
|
|
138
|
-
"g3ts-eslint-plugin-style-policy": "0.1.3",
|
|
139
|
-
"jscpd": "4.2.3",
|
|
140
136
|
"prettier": "3.8.3",
|
|
141
|
-
"stylelint": "17.12.0",
|
|
142
|
-
"stylelint-config-standard": "40.0.0",
|
|
143
|
-
"stylelint-config-tailwindcss": "1.0.1",
|
|
144
137
|
"syncpack": "15.3.1",
|
|
145
138
|
"type-coverage": "2.29.7",
|
|
146
139
|
"typescript": "6.0.3"
|
|
@@ -150,9 +143,8 @@
|
|
|
150
143
|
"format": "prettier --write .",
|
|
151
144
|
"format:check": "prettier --check .",
|
|
152
145
|
"lint": "eslint --max-warnings 0 .",
|
|
153
|
-
"lint:css": "stylelint --max-warnings 0 \"styles/**/*.css\"",
|
|
154
146
|
"spellcheck": "cspell .",
|
|
155
147
|
"typecov": "type-coverage --strict --at-least 100",
|
|
156
|
-
"validate": "pnpm run build && node scripts/verify-cli-version.mjs && pnpm run lint &&
|
|
148
|
+
"validate": "pnpm run build && node scripts/verify-cli-version.mjs && pnpm run lint && prettier --check . && cspell . && type-coverage --strict --at-least 100"
|
|
157
149
|
}
|
|
158
150
|
}
|