slopless 0.2.19 → 0.2.21
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 +22 -25
- package/dist/adapters/textlint/report.js +9 -0
- package/dist/presets/everything.js +5 -0
- package/dist/registries/phrases/builtin.js +23 -0
- package/dist/registries/phrases/families.js +14 -0
- package/dist/registries/phrases.js +4 -20
- package/dist/registries/words.js +2 -0
- package/dist/reporting/density.js +23 -0
- package/dist/reporting/reports.js +34 -0
- package/dist/rules/narrative-slop/perception-verb-density.js +16 -21
- package/dist/rules/orthography/fake-timestamps.js +53 -0
- package/dist/rules/phrases/data/cliches.json +39 -3
- package/dist/rules/phrases/data/corporate-speak.json +39 -1
- package/dist/rules/phrases/data/genre-cliches.json +53 -0
- package/dist/rules/phrases/data/self-help-cliches.json +62 -0
- package/dist/rules/phrases/data/seo-filler.json +34 -0
- package/dist/rules/phrases/data/skunked-terms.json +1 -10
- package/dist/rules/phrases/data/weasel-attribution.json +37 -0
- package/dist/rules/phrases/genre-cliches.js +17 -0
- package/dist/rules/phrases/humble-bragger.js +3 -5
- package/dist/rules/phrases/self-help-cliches.js +17 -0
- package/dist/rules/phrases/seo-filler.js +17 -0
- package/dist/rules/phrases/weasel-attribution.js +21 -0
- package/dist/rules/syntactic-patterns/contrast/private/negation-reframe-matcher.js +5 -0
- package/dist/rules/syntactic-patterns/generalization/softening-language.js +7 -1
- package/dist/rules/syntactic-patterns/generalization/universalizing-claims.js +5 -82
- package/dist/rules/syntactic-patterns/lead-ins/boilerplate-framing.js +16 -6
- package/dist/rules/syntactic-patterns/lead-ins/generic-signposting.js +3 -1
- package/dist/rules/syntactic-patterns/llm-artifacts/data/response-wrapper-patterns.json +0 -1
- package/dist/rules/syntactic-patterns/repetition/triple-repeat.js +28 -0
- package/dist/rules/words/actually-overuse.js +45 -0
- package/dist/rules/words/data/prohibited-words.json +0 -1
- package/dist/rules/words/llm-vocabulary-density.js +0 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,44 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/slopless)
|
|
4
4
|
[](https://www.npmjs.com/package/slopless)
|
|
5
|
-
[](https://bundlephobia.com/package/slopless)
|
|
6
5
|
[](https://packagephobia.com/result?p=slopless)
|
|
7
6
|
[](LICENSE)
|
|
8
7
|
[](package.json)
|
|
9
8
|
|
|
10
9
|
[](/actions/workflows/ci.yml)
|
|
11
|
-
[](https://scorecard.dev/viewer/?uri=github.com/seochecks-ai/slopless)
|
|
12
10
|
[](https://socket.dev/npm/package/slopless)
|
|
13
|
-
[](https://snyk.io/advisor/npm-package/slopless)
|
|
14
11
|
|
|
15
12
|
Catch AI and human slop in English Markdown without calling an LLM. Slopless ships 50+ deterministic textlint rules and a CLI that emits structured JSON findings.
|
|
16
13
|
|
|
17
14
|
## What it catches
|
|
18
15
|
|
|
19
|
-
Given this paragraph
|
|
16
|
+
Given this paragraph of confident-sounding slop:
|
|
20
17
|
|
|
21
18
|
```markdown
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
possibilities. But what does this really mean for you? In essence, the
|
|
27
|
-
future belongs to those who embrace these tools today. Not just to keep up,
|
|
28
|
-
not just to stay competitive, but to thrive.
|
|
19
|
+
Let me be honest: in a world where everyone is racing to ship, most teams
|
|
20
|
+
forget the basics. We do not sell software. We sell outcomes. Everyone knows
|
|
21
|
+
the best products feel effortless. At the end of the day, the future belongs
|
|
22
|
+
to the teams that slow down to think.
|
|
29
23
|
```
|
|
30
24
|
|
|
31
|
-
`npx slopless`
|
|
25
|
+
`npx slopless` flags six findings across five rules - structure and rhetoric, not just a word list:
|
|
32
26
|
|
|
33
27
|
```
|
|
34
|
-
[slopless/
|
|
35
|
-
[slopless/prohibited-phrases]
|
|
36
|
-
[slopless/
|
|
37
|
-
[slopless/
|
|
38
|
-
[slopless/
|
|
39
|
-
[slopless/prohibited-
|
|
40
|
-
[slopless/prohibited-phrases] the future belongs to
|
|
41
|
-
[slopless/negation-reframe] Not just X, not just Y, but Z
|
|
42
|
-
[slopless/flesch-kincaid] Flesch Reading Ease 56.05 (keep >= 61)
|
|
28
|
+
[slopless/boilerplate-framing] let me be honest
|
|
29
|
+
[slopless/prohibited-phrases] in a world where
|
|
30
|
+
[slopless/negation-reframe] We do not sell software. We sell outcomes.
|
|
31
|
+
[slopless/universalizing-claims] everyone knows
|
|
32
|
+
[slopless/cliches] at the end of the day
|
|
33
|
+
[slopless/prohibited-phrases] the future belongs to
|
|
43
34
|
```
|
|
44
35
|
|
|
45
36
|
Full JSON output is the default. See the [Rules](https://github.com/seochecks-ai/slopless/wiki/Rules) page for the complete inventory.
|
|
@@ -48,6 +39,7 @@ Full JSON output is the default. See the [Rules](https://github.com/seochecks-ai
|
|
|
48
39
|
|
|
49
40
|
```bash
|
|
50
41
|
npm install -D slopless
|
|
42
|
+
npx slopless --help
|
|
51
43
|
npx slopless install-skill codex
|
|
52
44
|
npx slopless install-skill claude
|
|
53
45
|
```
|
|
@@ -61,10 +53,11 @@ Use the Slopless skill. Check this Markdown, rewrite the prose, and keep iterati
|
|
|
61
53
|
Loop:
|
|
62
54
|
|
|
63
55
|
1. Install Slopless.
|
|
64
|
-
2.
|
|
65
|
-
3.
|
|
66
|
-
4.
|
|
67
|
-
5.
|
|
56
|
+
2. Read the core help (`npx slopless --help`).
|
|
57
|
+
3. Install the agent skill for Codex or Claude Code.
|
|
58
|
+
4. Tell the writing agent to use the skill.
|
|
59
|
+
5. Let the agent run Slopless, rewrite, and rerun until the JSON output has no findings.
|
|
60
|
+
6. Profit.
|
|
68
61
|
|
|
69
62
|
## Direct CLI Use
|
|
70
63
|
|
|
@@ -144,6 +137,10 @@ This confirms the tarball you installed was built in the published source reposi
|
|
|
144
137
|
|
|
145
138
|
[](https://star-history.com/#seochecks-ai/slopless&Date)
|
|
146
139
|
|
|
140
|
+
## Credits
|
|
141
|
+
|
|
142
|
+
[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.
|
|
143
|
+
|
|
147
144
|
---
|
|
148
145
|
|
|
149
146
|
Developed by [seochecks.ai](https://seochecks.ai) to keep content specific, useful, and recognizably human.
|
|
@@ -8,6 +8,15 @@ export function emitTextlintReport(context, unitsById, report) {
|
|
|
8
8
|
const padding = report.range.start === report.range.end
|
|
9
9
|
? context.locator.at(report.range.start)
|
|
10
10
|
: context.locator.range([report.range.start, report.range.end]);
|
|
11
|
+
// textlint reads a per-report severity only from a plain reported object, not from a
|
|
12
|
+
// RuleError instance (kernel: `ruleReportedObject.severity || error`). The reporter sets
|
|
13
|
+
// report.severity when a policy judges the level (1 = warning, 2 = error); everything else
|
|
14
|
+
// keeps the default error severity via RuleError.
|
|
15
|
+
const { severity } = report;
|
|
16
|
+
if (typeof severity === "number") {
|
|
17
|
+
context.report(unit.node, { message: report.message, padding, severity });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
11
20
|
context.report(unit.node, new context.RuleError(report.message, {
|
|
12
21
|
padding
|
|
13
22
|
}));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const everything = {
|
|
2
2
|
rules: {
|
|
3
|
+
"actually-overuse": true,
|
|
3
4
|
"affirmation-closers": true,
|
|
4
5
|
"artifact-placeholders": true,
|
|
5
6
|
"authority-padding": true,
|
|
@@ -50,6 +51,10 @@ export const everything = {
|
|
|
50
51
|
"sentence-case": true,
|
|
51
52
|
"semantic-thinness": true,
|
|
52
53
|
"significance-density": true,
|
|
54
|
+
"seo-filler": true,
|
|
55
|
+
"self-help-cliches": true,
|
|
56
|
+
"genre-cliches": true,
|
|
57
|
+
"weasel-attribution": true,
|
|
53
58
|
simplicity: true,
|
|
54
59
|
"skunked-terms": true,
|
|
55
60
|
"softening-language": true,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import cliches from "../../rules/phrases/cliches.js";
|
|
2
|
+
import corporateSpeak from "../../rules/phrases/corporate-speak.js";
|
|
3
|
+
import humbleBragger from "../../rules/phrases/humble-bragger.js";
|
|
4
|
+
import jargonFaker from "../../rules/phrases/jargon-faker.js";
|
|
5
|
+
import llmDisclaimer from "../../rules/phrases/llm-disclaimer.js";
|
|
6
|
+
import prohibitedPhrases from "../../rules/phrases/prohibited-phrases.js";
|
|
7
|
+
import redundancy from "../../rules/phrases/redundancy.js";
|
|
8
|
+
import skunkedTerms from "../../rules/phrases/skunked-terms.js";
|
|
9
|
+
import uncomparables from "../../rules/phrases/uncomparables.js";
|
|
10
|
+
import wordiness from "../../rules/phrases/wordiness.js";
|
|
11
|
+
export const phraseBuiltinRules = {
|
|
12
|
+
cliches,
|
|
13
|
+
"corporate-speak": corporateSpeak,
|
|
14
|
+
"humble-bragger": humbleBragger,
|
|
15
|
+
"jargon-faker": jargonFaker,
|
|
16
|
+
"llm-disclaimer": llmDisclaimer,
|
|
17
|
+
"prohibited-phrases": prohibitedPhrases,
|
|
18
|
+
redundancy,
|
|
19
|
+
"skunked-terms": skunkedTerms,
|
|
20
|
+
uncomparables,
|
|
21
|
+
wordiness
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=builtin.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import genreCliches from "../../rules/phrases/genre-cliches.js";
|
|
2
|
+
import seoFiller from "../../rules/phrases/seo-filler.js";
|
|
3
|
+
import selfHelpCliches from "../../rules/phrases/self-help-cliches.js";
|
|
4
|
+
import weaselAttribution from "../../rules/phrases/weasel-attribution.js";
|
|
5
|
+
// New slop families added from the corpus-driven expansion. Each is a phrase-list
|
|
6
|
+
// rule (findUnquotedPhraseMatches over a data JSON), screened for low false positives
|
|
7
|
+
// against a multi-topic human corpus.
|
|
8
|
+
export const phraseFamilyRules = {
|
|
9
|
+
"genre-cliches": genreCliches,
|
|
10
|
+
"seo-filler": seoFiller,
|
|
11
|
+
"self-help-cliches": selfHelpCliches,
|
|
12
|
+
"weasel-attribution": weaselAttribution
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=families.js.map
|
|
@@ -1,23 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import humbleBragger from "../rules/phrases/humble-bragger.js";
|
|
4
|
-
import jargonFaker from "../rules/phrases/jargon-faker.js";
|
|
5
|
-
import llmDisclaimer from "../rules/phrases/llm-disclaimer.js";
|
|
6
|
-
import prohibitedPhrases from "../rules/phrases/prohibited-phrases.js";
|
|
7
|
-
import redundancy from "../rules/phrases/redundancy.js";
|
|
8
|
-
import skunkedTerms from "../rules/phrases/skunked-terms.js";
|
|
9
|
-
import uncomparables from "../rules/phrases/uncomparables.js";
|
|
10
|
-
import wordiness from "../rules/phrases/wordiness.js";
|
|
1
|
+
import { phraseBuiltinRules } from "./phrases/builtin.js";
|
|
2
|
+
import { phraseFamilyRules } from "./phrases/families.js";
|
|
11
3
|
export const phraseRules = {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"humble-bragger": humbleBragger,
|
|
15
|
-
"jargon-faker": jargonFaker,
|
|
16
|
-
"llm-disclaimer": llmDisclaimer,
|
|
17
|
-
"prohibited-phrases": prohibitedPhrases,
|
|
18
|
-
redundancy,
|
|
19
|
-
"skunked-terms": skunkedTerms,
|
|
20
|
-
uncomparables,
|
|
21
|
-
wordiness
|
|
4
|
+
...phraseBuiltinRules,
|
|
5
|
+
...phraseFamilyRules
|
|
22
6
|
};
|
|
23
7
|
//# sourceMappingURL=phrases.js.map
|
package/dist/registries/words.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import actuallyOveruse from "../rules/words/actually-overuse.js";
|
|
1
2
|
import hedgeStacking from "../rules/words/hedge-stacking.js";
|
|
2
3
|
import llmVocabularyDensity from "../rules/words/llm-vocabulary-density.js";
|
|
3
4
|
import llmVocabulary from "../rules/words/llm-vocabulary.js";
|
|
4
5
|
import prohibitedWords from "../rules/words/prohibited-words.js";
|
|
5
6
|
import simplicity from "../rules/words/simplicity.js";
|
|
6
7
|
export const wordRules = {
|
|
8
|
+
"actually-overuse": actuallyOveruse,
|
|
7
9
|
"hedge-stacking": hedgeStacking,
|
|
8
10
|
"llm-vocabulary-density": llmVocabularyDensity,
|
|
9
11
|
"llm-vocabulary": llmVocabulary,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Density calculation used by the reporter to judge raw detections. Rules emit occurrences
|
|
2
|
+
// only; the reporter hands the occurrence count and the unit word count here, and this
|
|
3
|
+
// module decides whether the density is acceptable, a warning, or an error. It has no rule
|
|
4
|
+
// or textlint knowledge so it stays reusable and unit-testable on its own.
|
|
5
|
+
export const WARNING_SEVERITY = 1;
|
|
6
|
+
export const ERROR_SEVERITY = 2;
|
|
7
|
+
// Occurrences per `wordsPerUnit` words. Below the warning rate (or below the minimum count)
|
|
8
|
+
// there is no finding; above the error rate it is an error; otherwise a warning. A single
|
|
9
|
+
// occurrence never fires when minimumOccurrences is 2 or more.
|
|
10
|
+
export function rateVerdict(occurrences, words, thresholds) {
|
|
11
|
+
if (occurrences < thresholds.minimumOccurrences || words <= 0) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const perUnit = (occurrences * thresholds.wordsPerUnit) / words;
|
|
15
|
+
if (perUnit <= thresholds.warningPerUnit) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
perUnit,
|
|
20
|
+
severity: perUnit > thresholds.errorPerUnit ? ERROR_SEVERITY : WARNING_SEVERITY
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=density.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { splitSentences } from "../shared/text/sentences.js";
|
|
2
2
|
import { wordTokens } from "../shared/text/tokens.js";
|
|
3
|
+
import { rateVerdict } from "./density.js";
|
|
3
4
|
function detectionWithin(detection, span) {
|
|
4
5
|
return (detection.group !== undefined &&
|
|
5
6
|
detection.range.start >= span.start &&
|
|
@@ -89,6 +90,37 @@ export function densityReports(unit, detections, config) {
|
|
|
89
90
|
}
|
|
90
91
|
];
|
|
91
92
|
}
|
|
93
|
+
export function densityRateReports(units, detections, policy, formatMessage) {
|
|
94
|
+
const reports = [];
|
|
95
|
+
for (const unit of units) {
|
|
96
|
+
const unitDetections = detections.filter((detection) => detection.unitId === unit.id);
|
|
97
|
+
const first = unitDetections[0];
|
|
98
|
+
const verdict = rateVerdict(unitDetections.length, wordTokens(unit.text).length, {
|
|
99
|
+
errorPerUnit: policy.errorPerUnit,
|
|
100
|
+
minimumOccurrences: policy.minimumOccurrences,
|
|
101
|
+
warningPerUnit: policy.warningPerUnit,
|
|
102
|
+
wordsPerUnit: policy.wordsPerUnit
|
|
103
|
+
});
|
|
104
|
+
if (verdict === undefined || first === undefined) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const report = {
|
|
108
|
+
detections: unitDetections,
|
|
109
|
+
evidence: first.evidence,
|
|
110
|
+
message: "",
|
|
111
|
+
metric: {
|
|
112
|
+
count: unitDetections.length,
|
|
113
|
+
perUnit: Math.round(verdict.perUnit * 10) / 10
|
|
114
|
+
},
|
|
115
|
+
range: unit.sourceRangeFor(first.range),
|
|
116
|
+
ruleId: first.ruleId,
|
|
117
|
+
severity: verdict.severity,
|
|
118
|
+
unitId: unit.id
|
|
119
|
+
};
|
|
120
|
+
reports.push({ ...report, message: formatMessage(report) });
|
|
121
|
+
}
|
|
122
|
+
return reports;
|
|
123
|
+
}
|
|
92
124
|
export function reportsForPolicy(units, detections, policy, formatMessage) {
|
|
93
125
|
switch (policy.kind) {
|
|
94
126
|
case "one-to-one": {
|
|
@@ -137,6 +169,8 @@ export function reportsForPolicy(units, detections, policy, formatMessage) {
|
|
|
137
169
|
message: formatMessage(report)
|
|
138
170
|
}));
|
|
139
171
|
}
|
|
172
|
+
case "density-rate":
|
|
173
|
+
return densityRateReports(units, detections, policy, formatMessage);
|
|
140
174
|
}
|
|
141
175
|
}
|
|
142
176
|
//# sourceMappingURL=reports.js.map
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { defineTextlintRule } from "../../adapters/textlint/rule.js";
|
|
2
2
|
import { paragraphUnits } from "../../adapters/textlint/units.js";
|
|
3
3
|
import { wordTokens } from "../../shared/text/tokens.js";
|
|
4
|
+
// Dropped the tokens whose dominant sense is non-narrative and which drove the bulk of
|
|
5
|
+
// the human-corpus mis-fires: "see/sees/seeing" (generic: "you can see that"), the present
|
|
6
|
+
// "focus"/"focuses" ("focus on strategy"; narrative "focused"/"focusing" are kept),
|
|
7
|
+
// "peer/peers/peering" (the colleague noun and "peer review"),
|
|
8
|
+
// and the plural nouns "studies"/"notices" ("studies show", "post notices"). The narrative
|
|
9
|
+
// perception verbs - including observe/study/notice/scan, which genuine scene-slop stacks
|
|
10
|
+
// ("she scanned the room, noticed the door, studied his face") - are kept.
|
|
4
11
|
const PERCEPTION_VERBS = new Set([
|
|
5
|
-
"focus",
|
|
6
|
-
"focuses",
|
|
7
|
-
"focused",
|
|
8
|
-
"focusing",
|
|
9
12
|
"look",
|
|
10
13
|
"looks",
|
|
11
14
|
"looked",
|
|
@@ -26,34 +29,26 @@ const PERCEPTION_VERBS = new Set([
|
|
|
26
29
|
"glances",
|
|
27
30
|
"glanced",
|
|
28
31
|
"glancing",
|
|
29
|
-
"observe",
|
|
30
|
-
"observes",
|
|
31
|
-
"observed",
|
|
32
|
-
"observing",
|
|
33
|
-
"scan",
|
|
34
|
-
"scans",
|
|
35
|
-
"scanned",
|
|
36
|
-
"scanning",
|
|
37
|
-
"peer",
|
|
38
|
-
"peers",
|
|
39
32
|
"peered",
|
|
40
|
-
"peering",
|
|
41
33
|
"squint",
|
|
42
34
|
"squints",
|
|
43
35
|
"squinted",
|
|
44
36
|
"squinting",
|
|
37
|
+
"saw",
|
|
38
|
+
"focused",
|
|
39
|
+
"focusing",
|
|
40
|
+
"observe",
|
|
41
|
+
"observed",
|
|
42
|
+
"observing",
|
|
45
43
|
"study",
|
|
46
|
-
"studies",
|
|
47
44
|
"studied",
|
|
48
45
|
"studying",
|
|
49
46
|
"notice",
|
|
50
|
-
"notices",
|
|
51
47
|
"noticed",
|
|
52
48
|
"noticing",
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"seeing"
|
|
49
|
+
"scan",
|
|
50
|
+
"scanned",
|
|
51
|
+
"scanning"
|
|
57
52
|
]);
|
|
58
53
|
const PURPOSE_LOOK_PARTICLES = new Set(["for", "up", "under", "into"]);
|
|
59
54
|
const MAX_PARAGRAPH_TOKENS = 80;
|
|
@@ -3,6 +3,56 @@ import { oneToOneRule } from "../private/textlint-rule-builders.js";
|
|
|
3
3
|
function isAsciiDigit(character) {
|
|
4
4
|
return character !== undefined && character >= "0" && character <= "9";
|
|
5
5
|
}
|
|
6
|
+
// A timestamp accompanied by a real date (weekday, month, year, or a date label) is a
|
|
7
|
+
// genuine timestamp - an email header, a logged event, a scheduled time - not fabricated
|
|
8
|
+
// AI clock specificity. Skip those.
|
|
9
|
+
const DATE_WORDS = [
|
|
10
|
+
"january",
|
|
11
|
+
"february",
|
|
12
|
+
"march",
|
|
13
|
+
"april",
|
|
14
|
+
"june",
|
|
15
|
+
"july",
|
|
16
|
+
"august",
|
|
17
|
+
"september",
|
|
18
|
+
"october",
|
|
19
|
+
"november",
|
|
20
|
+
"december",
|
|
21
|
+
"monday",
|
|
22
|
+
"tuesday",
|
|
23
|
+
"wednesday",
|
|
24
|
+
"thursday",
|
|
25
|
+
"friday",
|
|
26
|
+
"saturday",
|
|
27
|
+
"sunday"
|
|
28
|
+
];
|
|
29
|
+
const DATE_LABELS = [
|
|
30
|
+
"date:",
|
|
31
|
+
"sent:",
|
|
32
|
+
"received:",
|
|
33
|
+
"posted",
|
|
34
|
+
"published",
|
|
35
|
+
"updated",
|
|
36
|
+
"timestamp"
|
|
37
|
+
];
|
|
38
|
+
function hasFourDigitYear(text) {
|
|
39
|
+
for (let i = 0; i + 3 < text.length; i += 1) {
|
|
40
|
+
const a = text[i];
|
|
41
|
+
const b = text[i + 1];
|
|
42
|
+
if (((a === "1" && b === "9") || (a === "2" && b === "0")) &&
|
|
43
|
+
isAsciiDigit(text[i + 2]) &&
|
|
44
|
+
isAsciiDigit(text[i + 3])) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
function hasDateContext(sentence) {
|
|
51
|
+
const lower = sentence.toLocaleLowerCase("en");
|
|
52
|
+
return (DATE_LABELS.some((label) => lower.includes(label)) ||
|
|
53
|
+
DATE_WORDS.some((word) => lower.includes(word)) ||
|
|
54
|
+
hasFourDigitYear(lower));
|
|
55
|
+
}
|
|
6
56
|
function isPeriodMarker(first, second) {
|
|
7
57
|
if (first === undefined || second === undefined) {
|
|
8
58
|
return false;
|
|
@@ -56,6 +106,9 @@ function findTimestampMatches(text) {
|
|
|
56
106
|
function findSentenceTimestampMatches(text) {
|
|
57
107
|
const matches = [];
|
|
58
108
|
for (const sentence of splitSentences(text)) {
|
|
109
|
+
if (hasDateContext(sentence.text)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
59
112
|
const timestampMatches = findTimestampMatches(sentence.text);
|
|
60
113
|
if (timestampMatches.length === 0) {
|
|
61
114
|
continue;
|
|
@@ -331,7 +331,6 @@
|
|
|
331
331
|
"in a pinch",
|
|
332
332
|
"in a word",
|
|
333
333
|
"in hot water",
|
|
334
|
-
"in the final analysis",
|
|
335
334
|
"in the gutter",
|
|
336
335
|
"in the last analysis",
|
|
337
336
|
"in the nick of time",
|
|
@@ -339,7 +338,6 @@
|
|
|
339
338
|
"in your dreams",
|
|
340
339
|
"innocent bystander",
|
|
341
340
|
"it ain't over till the fat lady sings",
|
|
342
|
-
"it goes without saying",
|
|
343
341
|
"it takes all kinds",
|
|
344
342
|
"it takes one to know one",
|
|
345
343
|
"it's a small world",
|
|
@@ -791,5 +789,43 @@
|
|
|
791
789
|
"you only live once",
|
|
792
790
|
"you're the boss",
|
|
793
791
|
"young and foolish",
|
|
794
|
-
"young and vibrant"
|
|
792
|
+
"young and vibrant",
|
|
793
|
+
"a watershed moment",
|
|
794
|
+
"as we move forward",
|
|
795
|
+
"at a pivotal moment",
|
|
796
|
+
"beacon of hope",
|
|
797
|
+
"contributing to the fabric of",
|
|
798
|
+
"discover the wonders of",
|
|
799
|
+
"drive engagement",
|
|
800
|
+
"embrace the journey",
|
|
801
|
+
"empower individuals to",
|
|
802
|
+
"everything happens for a reason",
|
|
803
|
+
"gain a comprehensive understanding",
|
|
804
|
+
"gain valuable insights",
|
|
805
|
+
"harness the power of",
|
|
806
|
+
"has something for everyone",
|
|
807
|
+
"has transformed the landscape of",
|
|
808
|
+
"immerse yourself in",
|
|
809
|
+
"in the not-too-distant future",
|
|
810
|
+
"leave no stone unturned",
|
|
811
|
+
"left an indelible mark",
|
|
812
|
+
"navigate the complexities of",
|
|
813
|
+
"nestled in the heart of",
|
|
814
|
+
"offers valuable insights",
|
|
815
|
+
"plays a pivotal role in shaping",
|
|
816
|
+
"resonate with audiences",
|
|
817
|
+
"revolutionized the way we",
|
|
818
|
+
"rich cultural tapestry",
|
|
819
|
+
"sheds light on",
|
|
820
|
+
"significant contributions to the field",
|
|
821
|
+
"step out of your comfort zone",
|
|
822
|
+
"the stakes have never been higher",
|
|
823
|
+
"there is no one-size-fits-all answer",
|
|
824
|
+
"this changes everything",
|
|
825
|
+
"thriving ecosystem of",
|
|
826
|
+
"treasure trove of",
|
|
827
|
+
"unlock the potential of",
|
|
828
|
+
"unlock your true potential",
|
|
829
|
+
"vibrant hub of",
|
|
830
|
+
"we stand at a crossroads"
|
|
795
831
|
]
|
|
@@ -62,5 +62,43 @@
|
|
|
62
62
|
"ecosystem of partners",
|
|
63
63
|
"innovate at the speed of",
|
|
64
64
|
"reimagine the future of",
|
|
65
|
-
"built for the enterprise"
|
|
65
|
+
"built for the enterprise",
|
|
66
|
+
"10x growth",
|
|
67
|
+
"30,000-foot view",
|
|
68
|
+
"agile methodology",
|
|
69
|
+
"brand storytelling",
|
|
70
|
+
"core competencies",
|
|
71
|
+
"cross-functional alignment",
|
|
72
|
+
"cross-pollination of ideas",
|
|
73
|
+
"data-driven decisions",
|
|
74
|
+
"digital transformation",
|
|
75
|
+
"ducks in a row",
|
|
76
|
+
"eating your own dog food",
|
|
77
|
+
"end-to-end solution",
|
|
78
|
+
"excited to share",
|
|
79
|
+
"future-proof your",
|
|
80
|
+
"headwinds",
|
|
81
|
+
"ideation session",
|
|
82
|
+
"keep all stakeholders aligned",
|
|
83
|
+
"learnings",
|
|
84
|
+
"leverage synergies",
|
|
85
|
+
"move at the speed of business",
|
|
86
|
+
"open the kimono",
|
|
87
|
+
"out-of-the-box thinking",
|
|
88
|
+
"revolutionizing the way",
|
|
89
|
+
"rightsizing",
|
|
90
|
+
"robust ecosystem",
|
|
91
|
+
"robust pipeline",
|
|
92
|
+
"run it up the flagpole",
|
|
93
|
+
"scale our operations",
|
|
94
|
+
"seamless integration",
|
|
95
|
+
"stakeholder buy-in",
|
|
96
|
+
"strategic alignment",
|
|
97
|
+
"streamline processes",
|
|
98
|
+
"tailwinds",
|
|
99
|
+
"talent pipeline",
|
|
100
|
+
"that's where we come in",
|
|
101
|
+
"thought leader",
|
|
102
|
+
"thought leadership",
|
|
103
|
+
"tiger team"
|
|
66
104
|
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[
|
|
2
|
+
"a culinary journey",
|
|
3
|
+
"a feast for the senses",
|
|
4
|
+
"a must for any itinerary",
|
|
5
|
+
"a true gem",
|
|
6
|
+
"after using this for a few weeks",
|
|
7
|
+
"best-kept secret",
|
|
8
|
+
"bucket list destination",
|
|
9
|
+
"bursting with flavor",
|
|
10
|
+
"bustling streets",
|
|
11
|
+
"charming village",
|
|
12
|
+
"city of contrasts",
|
|
13
|
+
"come hungry",
|
|
14
|
+
"completely transformed how i",
|
|
15
|
+
"crystal-clear waters",
|
|
16
|
+
"escape the crowds",
|
|
17
|
+
"even picky eaters will love it",
|
|
18
|
+
"exceeded my expectations",
|
|
19
|
+
"experience the magic of",
|
|
20
|
+
"final verdict",
|
|
21
|
+
"foodie paradise",
|
|
22
|
+
"great for weeknight dinners",
|
|
23
|
+
"hands-down the best",
|
|
24
|
+
"hidden gem",
|
|
25
|
+
"leave room for dessert",
|
|
26
|
+
"local cuisine",
|
|
27
|
+
"local gems",
|
|
28
|
+
"look no further",
|
|
29
|
+
"lose yourself in",
|
|
30
|
+
"my grandmother used to make this",
|
|
31
|
+
"nestled among",
|
|
32
|
+
"no product is perfect, but",
|
|
33
|
+
"off the beaten path",
|
|
34
|
+
"off the tourist trail",
|
|
35
|
+
"soak up the atmosphere",
|
|
36
|
+
"step back in time",
|
|
37
|
+
"stunning scenery",
|
|
38
|
+
"tantalizing flavors",
|
|
39
|
+
"the perfect blend of",
|
|
40
|
+
"the results speak for themselves",
|
|
41
|
+
"this dish brings back so many memories",
|
|
42
|
+
"this place has it all",
|
|
43
|
+
"this recipe is perfect for",
|
|
44
|
+
"trust me, you won't be disappointed",
|
|
45
|
+
"undiscovered paradise",
|
|
46
|
+
"unparalleled views",
|
|
47
|
+
"vibrant culture",
|
|
48
|
+
"what makes this recipe special is",
|
|
49
|
+
"whether you're a food lover or",
|
|
50
|
+
"world-class dining",
|
|
51
|
+
"you won't regret it",
|
|
52
|
+
"you'll love this"
|
|
53
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[
|
|
2
|
+
"a transformative experience",
|
|
3
|
+
"abundance mindset",
|
|
4
|
+
"align with your values",
|
|
5
|
+
"be authentically you",
|
|
6
|
+
"believe in yourself",
|
|
7
|
+
"break free from your limitations",
|
|
8
|
+
"choose yourself",
|
|
9
|
+
"create the life you want",
|
|
10
|
+
"deserve success",
|
|
11
|
+
"do the inner work",
|
|
12
|
+
"embrace change",
|
|
13
|
+
"embrace who you are",
|
|
14
|
+
"embrace your potential",
|
|
15
|
+
"find your purpose",
|
|
16
|
+
"growth mindset",
|
|
17
|
+
"heal your relationship with",
|
|
18
|
+
"honor your feelings",
|
|
19
|
+
"inner work",
|
|
20
|
+
"just stay positive",
|
|
21
|
+
"life is a journey, not a destination",
|
|
22
|
+
"live with intention",
|
|
23
|
+
"live your best life",
|
|
24
|
+
"live your purpose",
|
|
25
|
+
"manifest your dreams",
|
|
26
|
+
"not a destination but a journey",
|
|
27
|
+
"own your story",
|
|
28
|
+
"pour into yourself",
|
|
29
|
+
"practice gratitude",
|
|
30
|
+
"push past your limits",
|
|
31
|
+
"put in the work",
|
|
32
|
+
"radical self-love",
|
|
33
|
+
"reclaim your power",
|
|
34
|
+
"self-care is not selfish",
|
|
35
|
+
"set your intentions",
|
|
36
|
+
"show up for yourself",
|
|
37
|
+
"small changes can make a big difference",
|
|
38
|
+
"start where you are",
|
|
39
|
+
"stepping into your power",
|
|
40
|
+
"take control of your health",
|
|
41
|
+
"tap into your potential",
|
|
42
|
+
"the best version of yourself",
|
|
43
|
+
"the power within",
|
|
44
|
+
"this journey of self-discovery",
|
|
45
|
+
"thrive, not just survive",
|
|
46
|
+
"transformative power of",
|
|
47
|
+
"trust the process",
|
|
48
|
+
"unleash your potential",
|
|
49
|
+
"unlock your potential",
|
|
50
|
+
"where you are meant to be",
|
|
51
|
+
"you are capable of more than you think",
|
|
52
|
+
"you are enough",
|
|
53
|
+
"you are exactly where you need to be",
|
|
54
|
+
"you can't pour from an empty cup",
|
|
55
|
+
"you'll get through this",
|
|
56
|
+
"your authentic self",
|
|
57
|
+
"your best self",
|
|
58
|
+
"your best version",
|
|
59
|
+
"your higher self",
|
|
60
|
+
"your personal journey",
|
|
61
|
+
"your wellness journey"
|
|
62
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[
|
|
2
|
+
"as the world continues to evolve",
|
|
3
|
+
"as we look to the future",
|
|
4
|
+
"as we navigate",
|
|
5
|
+
"as we've explored",
|
|
6
|
+
"has a profound impact",
|
|
7
|
+
"in an era characterized by",
|
|
8
|
+
"in an ever-evolving landscape",
|
|
9
|
+
"in the digital age",
|
|
10
|
+
"in this comprehensive guide",
|
|
11
|
+
"in today's competitive landscape",
|
|
12
|
+
"in today's digital age",
|
|
13
|
+
"in today's fast-paced",
|
|
14
|
+
"in today's rapidly evolving landscape",
|
|
15
|
+
"it is no secret that",
|
|
16
|
+
"it is vital for",
|
|
17
|
+
"it's no secret that",
|
|
18
|
+
"join us as we",
|
|
19
|
+
"join us as we explore",
|
|
20
|
+
"let's deep dive",
|
|
21
|
+
"let's delve into",
|
|
22
|
+
"more than ever before",
|
|
23
|
+
"navigating the complexities of",
|
|
24
|
+
"navigating the world of",
|
|
25
|
+
"take a deep dive into",
|
|
26
|
+
"the ultimate guide to",
|
|
27
|
+
"this guide will cover",
|
|
28
|
+
"this has never been more important",
|
|
29
|
+
"we hope this article",
|
|
30
|
+
"we hope this guide",
|
|
31
|
+
"welcome to this article",
|
|
32
|
+
"welcome to this guide",
|
|
33
|
+
"whether you're a beginner or"
|
|
34
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[
|
|
2
|
+
"a growing body of evidence suggests",
|
|
3
|
+
"a growing body of research suggests",
|
|
4
|
+
"according to health professionals",
|
|
5
|
+
"according to medical experts",
|
|
6
|
+
"analysts suggest",
|
|
7
|
+
"countless studies have shown",
|
|
8
|
+
"doctors say",
|
|
9
|
+
"economists say",
|
|
10
|
+
"health experts say",
|
|
11
|
+
"industry experts say",
|
|
12
|
+
"industry insiders say",
|
|
13
|
+
"industry leaders agree",
|
|
14
|
+
"it has been demonstrated that",
|
|
15
|
+
"it is commonly understood that",
|
|
16
|
+
"it is generally understood that",
|
|
17
|
+
"it is universally accepted that",
|
|
18
|
+
"leading experts say",
|
|
19
|
+
"many experts agree",
|
|
20
|
+
"many experts believe",
|
|
21
|
+
"most experts agree",
|
|
22
|
+
"multiple studies have found",
|
|
23
|
+
"new research suggests",
|
|
24
|
+
"new studies show",
|
|
25
|
+
"numerous studies have shown",
|
|
26
|
+
"numerous studies suggest",
|
|
27
|
+
"nutritionists say",
|
|
28
|
+
"observers have suggested",
|
|
29
|
+
"psychologists say",
|
|
30
|
+
"recent research has found",
|
|
31
|
+
"science confirms",
|
|
32
|
+
"science tells us",
|
|
33
|
+
"some researchers argue",
|
|
34
|
+
"some scientists believe",
|
|
35
|
+
"some would argue",
|
|
36
|
+
"the scientific community agrees"
|
|
37
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import genreCliches from "./data/genre-cliches.json" with { type: "json" };
|
|
2
|
+
import { findUnquotedPhraseMatches } from "../../shared/matchers/phrases.js";
|
|
3
|
+
import { oneToOneRule } from "../private/textlint-rule-builders.js";
|
|
4
|
+
const rule = oneToOneRule({
|
|
5
|
+
detect: (unit) => findUnquotedPhraseMatches(unit.text, genreCliches).map((match) => ({
|
|
6
|
+
evidence: match.text,
|
|
7
|
+
label: match.text,
|
|
8
|
+
range: { start: match.start, end: match.end }
|
|
9
|
+
})),
|
|
10
|
+
family: "phrases",
|
|
11
|
+
formatMessage: (report) => `Genre cliche found: "${report.evidence}". Replace it with a concrete, specific detail.`,
|
|
12
|
+
ignoredAncestorTypes: ["Link", "LinkReference"],
|
|
13
|
+
ruleId: "phrases:genre-cliches",
|
|
14
|
+
unitKind: "str"
|
|
15
|
+
});
|
|
16
|
+
export default rule;
|
|
17
|
+
//# sourceMappingURL=genre-cliches.js.map
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { findPhraseMatches } from "../../shared/matchers/phrases.js";
|
|
2
2
|
import { oneToOneRule } from "../private/textlint-rule-builders.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"having worked with"
|
|
7
|
-
];
|
|
3
|
+
// "in my experience" was removed: it is a normal epistemic qualifier (common in Q&A
|
|
4
|
+
// and essays), not credentialing - it was a mis-fire, not a humble-brag.
|
|
5
|
+
const HUMBLE_BRAGGER_PHRASES = ["as someone who has", "having worked with"];
|
|
8
6
|
const rule = oneToOneRule({
|
|
9
7
|
detect: (unit) => findPhraseMatches(unit.text, HUMBLE_BRAGGER_PHRASES).map((match) => ({
|
|
10
8
|
evidence: match.text,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import selfHelpCliches from "./data/self-help-cliches.json" with { type: "json" };
|
|
2
|
+
import { findUnquotedPhraseMatches } from "../../shared/matchers/phrases.js";
|
|
3
|
+
import { oneToOneRule } from "../private/textlint-rule-builders.js";
|
|
4
|
+
const rule = oneToOneRule({
|
|
5
|
+
detect: (unit) => findUnquotedPhraseMatches(unit.text, selfHelpCliches).map((match) => ({
|
|
6
|
+
evidence: match.text,
|
|
7
|
+
label: match.text,
|
|
8
|
+
range: { start: match.start, end: match.end }
|
|
9
|
+
})),
|
|
10
|
+
family: "phrases",
|
|
11
|
+
formatMessage: (report) => `Self-help cliche found: "${report.evidence}". Replace it with a specific claim.`,
|
|
12
|
+
ignoredAncestorTypes: ["Link", "LinkReference"],
|
|
13
|
+
ruleId: "phrases:self-help-cliches",
|
|
14
|
+
unitKind: "str"
|
|
15
|
+
});
|
|
16
|
+
export default rule;
|
|
17
|
+
//# sourceMappingURL=self-help-cliches.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import seoFiller from "./data/seo-filler.json" with { type: "json" };
|
|
2
|
+
import { findUnquotedPhraseMatches } from "../../shared/matchers/phrases.js";
|
|
3
|
+
import { oneToOneRule } from "../private/textlint-rule-builders.js";
|
|
4
|
+
const rule = oneToOneRule({
|
|
5
|
+
detect: (unit) => findUnquotedPhraseMatches(unit.text, seoFiller).map((match) => ({
|
|
6
|
+
evidence: match.text,
|
|
7
|
+
label: match.text,
|
|
8
|
+
range: { start: match.start, end: match.end }
|
|
9
|
+
})),
|
|
10
|
+
family: "phrases",
|
|
11
|
+
formatMessage: (report) => `SEO filler found: "${report.evidence}". Cut the filler and lead with the specific point.`,
|
|
12
|
+
ignoredAncestorTypes: ["Link", "LinkReference"],
|
|
13
|
+
ruleId: "phrases:seo-filler",
|
|
14
|
+
unitKind: "str"
|
|
15
|
+
});
|
|
16
|
+
export default rule;
|
|
17
|
+
//# sourceMappingURL=seo-filler.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import weaselAttribution from "./data/weasel-attribution.json" with { type: "json" };
|
|
2
|
+
import { findUnquotedPhraseMatches } from "../../shared/matchers/phrases.js";
|
|
3
|
+
import { oneToOneRule } from "../private/textlint-rule-builders.js";
|
|
4
|
+
// Vague attribution: "studies show", "experts agree", "a growing body of evidence
|
|
5
|
+
// suggests" with no named source. Slop regardless of author; if the source is real,
|
|
6
|
+
// name it. The phrase list deliberately excludes attributions that are commonly used
|
|
7
|
+
// with a real citation alongside (those were screened out for false positives).
|
|
8
|
+
const rule = oneToOneRule({
|
|
9
|
+
detect: (unit) => findUnquotedPhraseMatches(unit.text, weaselAttribution).map((match) => ({
|
|
10
|
+
evidence: match.text,
|
|
11
|
+
label: match.text,
|
|
12
|
+
range: { start: match.start, end: match.end }
|
|
13
|
+
})),
|
|
14
|
+
family: "phrases",
|
|
15
|
+
formatMessage: (report) => `Vague attribution found: "${report.evidence}". Name the specific source or cut the claim.`,
|
|
16
|
+
ignoredAncestorTypes: ["Link", "LinkReference"],
|
|
17
|
+
ruleId: "phrases:weasel-attribution",
|
|
18
|
+
unitKind: "str"
|
|
19
|
+
});
|
|
20
|
+
export default rule;
|
|
21
|
+
//# sourceMappingURL=weasel-attribution.js.map
|
|
@@ -8,10 +8,15 @@ import { makeMeaningReframe, meaningReframe } from "./meaning-reframe.js";
|
|
|
8
8
|
import { hasConcreteCorrectionEvidence } from "../../../../shared/matchers/concrete-evidence.js";
|
|
9
9
|
import { splitSentences } from "../../../../shared/text/sentences.js";
|
|
10
10
|
import { wordTokens } from "../../../../shared/text/tokens.js";
|
|
11
|
+
// "not only X but Y" is an additive correlative (it affirms both X and Y), not the
|
|
12
|
+
// replacement reframe this rule targets ("it is not X, it is Y"). It was a dominant
|
|
13
|
+
// false positive ("not only makes rounds harder but can trigger a down round"), so the
|
|
14
|
+
// "only" follower is excluded here.
|
|
11
15
|
const INLINE_NON_CONTRAST_NEGATION_FOLLOWERS = new Set([
|
|
12
16
|
"all",
|
|
13
17
|
"any",
|
|
14
18
|
"every",
|
|
19
|
+
"only",
|
|
15
20
|
"too"
|
|
16
21
|
]);
|
|
17
22
|
function inlineNegationContrast(sentence) {
|
|
@@ -82,7 +82,13 @@ function matchSoftening(sentence) {
|
|
|
82
82
|
const qualifier = containsAny(normalized, QUALIFIER_PATTERNS);
|
|
83
83
|
const variability = containsAny(normalized, VARIABILITY_PATTERNS);
|
|
84
84
|
const reporting = containsAny(normalized, REPORTING_PATTERNS);
|
|
85
|
-
|
|
85
|
+
// "in some cases"/"in some people" already count as a variability hedge; strip the
|
|
86
|
+
// matched phrase so its embedded "some cases"/"some people" is not double-counted as a
|
|
87
|
+
// separate quantifier signal (that self-overlap was the dominant false positive).
|
|
88
|
+
const quantifierText = variability === undefined
|
|
89
|
+
? normalized
|
|
90
|
+
: normalized.split(variability).join(" ");
|
|
91
|
+
const quantifier = findQuantifierPair(quantifierText);
|
|
86
92
|
const signalCount = Number(modal !== undefined) +
|
|
87
93
|
Number(qualifier !== undefined) +
|
|
88
94
|
Number(variability !== undefined) +
|
|
@@ -9,6 +9,11 @@ const PREFIXES = [
|
|
|
9
9
|
"after all, ",
|
|
10
10
|
"in the end, "
|
|
11
11
|
];
|
|
12
|
+
// Real universalizing claims: a broad human-group subject + a desire/certainty verb
|
|
13
|
+
// ("most people want", "everyone knows", "for many parents ... hope"). The previous
|
|
14
|
+
// vague-quantifier + abstract-noun arm ("many reasons", "some things", "several
|
|
15
|
+
// challenges") was removed: those are ordinary counts, not universalizing claims, and
|
|
16
|
+
// were the dominant false positive.
|
|
12
17
|
const SUBJECT_PATTERNS = [
|
|
13
18
|
["everyone"],
|
|
14
19
|
["everybody"],
|
|
@@ -82,54 +87,6 @@ const GROUP_BEHAVIOR_GERUNDS = [
|
|
|
82
87
|
"wondering"
|
|
83
88
|
];
|
|
84
89
|
const BROAD_GROUP_LEADS = ["many", "most"];
|
|
85
|
-
const VAGUE_QUANTIFIERS = ["a lot of", "many", "several", "some", "various"];
|
|
86
|
-
const ABSTRACT_NOUNS = [
|
|
87
|
-
"areas",
|
|
88
|
-
"aspects",
|
|
89
|
-
"benefits",
|
|
90
|
-
"challenges",
|
|
91
|
-
"changes",
|
|
92
|
-
"concerns",
|
|
93
|
-
"factors",
|
|
94
|
-
"impacts",
|
|
95
|
-
"issues",
|
|
96
|
-
"outcomes",
|
|
97
|
-
"reasons",
|
|
98
|
-
"results",
|
|
99
|
-
"stakeholders",
|
|
100
|
-
"themes",
|
|
101
|
-
"things"
|
|
102
|
-
];
|
|
103
|
-
const ABSTRACT_MODIFIERS = [
|
|
104
|
-
"important",
|
|
105
|
-
"key",
|
|
106
|
-
"major",
|
|
107
|
-
"meaningful",
|
|
108
|
-
"notable",
|
|
109
|
-
"significant",
|
|
110
|
-
"various"
|
|
111
|
-
];
|
|
112
|
-
const EVIDENCE_MARKERS = [
|
|
113
|
-
"for example",
|
|
114
|
-
"including",
|
|
115
|
-
"such as",
|
|
116
|
-
"because",
|
|
117
|
-
"from ",
|
|
118
|
-
"after ",
|
|
119
|
-
"before ",
|
|
120
|
-
"when "
|
|
121
|
-
];
|
|
122
|
-
function hasDigit(text) {
|
|
123
|
-
for (const character of text) {
|
|
124
|
-
if (character >= "0" && character <= "9") {
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return false;
|
|
129
|
-
}
|
|
130
|
-
function hasEvidenceMarker(text) {
|
|
131
|
-
return (hasDigit(text) || EVIDENCE_MARKERS.some((marker) => text.includes(marker)));
|
|
132
|
-
}
|
|
133
90
|
function matchGroupBehavior(words) {
|
|
134
91
|
const [first, subject, third, gerund] = words;
|
|
135
92
|
if (first !== undefined &&
|
|
@@ -161,40 +118,6 @@ function matchUniversalizing(sentence) {
|
|
|
161
118
|
return `${subject.join(" ")} ${verb}`;
|
|
162
119
|
}
|
|
163
120
|
}
|
|
164
|
-
const vagueQuantifier = matchVagueQuantifierAbstractNoun(words, cleaned);
|
|
165
|
-
if (vagueQuantifier !== undefined) {
|
|
166
|
-
return vagueQuantifier;
|
|
167
|
-
}
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
function matchVagueQuantifierAbstractNoun(words, text) {
|
|
171
|
-
if (hasEvidenceMarker(text)) {
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
for (let index = 0; index < words.length; index += 1) {
|
|
175
|
-
const single = words[index];
|
|
176
|
-
const twoWord = `${words[index] ?? ""} ${words[index + 1] ?? ""}`;
|
|
177
|
-
const quantifier = VAGUE_QUANTIFIERS.includes(twoWord)
|
|
178
|
-
? twoWord
|
|
179
|
-
: VAGUE_QUANTIFIERS.find((candidate) => candidate === single);
|
|
180
|
-
if (quantifier === undefined) {
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
const nounStart = quantifier.includes(" ") ? index + 2 : index + 1;
|
|
184
|
-
const first = words[nounStart];
|
|
185
|
-
const second = words[nounStart + 1];
|
|
186
|
-
const noun = first !== undefined && ABSTRACT_NOUNS.includes(first)
|
|
187
|
-
? first
|
|
188
|
-
: second !== undefined &&
|
|
189
|
-
first !== undefined &&
|
|
190
|
-
ABSTRACT_MODIFIERS.includes(first) &&
|
|
191
|
-
ABSTRACT_NOUNS.includes(second)
|
|
192
|
-
? second
|
|
193
|
-
: undefined;
|
|
194
|
-
if (noun !== undefined) {
|
|
195
|
-
return `${quantifier} ${noun}`;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
121
|
return undefined;
|
|
199
122
|
}
|
|
200
123
|
const rule = oneToOneRule({
|
|
@@ -62,9 +62,17 @@ const FILLER_OPENERS = [
|
|
|
62
62
|
"let's be real",
|
|
63
63
|
"here's the deal",
|
|
64
64
|
"here's what's really going on",
|
|
65
|
-
"when all is said and done",
|
|
66
65
|
"the fact of the matter is",
|
|
67
|
-
"the bottom line is"
|
|
66
|
+
"the bottom line is",
|
|
67
|
+
"here's the truth",
|
|
68
|
+
"here's where it gets interesting",
|
|
69
|
+
"in the grand scheme of things",
|
|
70
|
+
"it is worth considering that",
|
|
71
|
+
"it's fair to say",
|
|
72
|
+
"let's be honest",
|
|
73
|
+
"there are no easy answers",
|
|
74
|
+
"this is the important part",
|
|
75
|
+
"we are at an inflection point"
|
|
68
76
|
];
|
|
69
77
|
function matchEnumerationPreface(words) {
|
|
70
78
|
if (words.some((word) => VAGUE_INTROS.includes(word)) &&
|
|
@@ -88,11 +96,16 @@ function matchStarterFrame(words) {
|
|
|
88
96
|
function matchBoilerplateFraming(sentence) {
|
|
89
97
|
const stripped = cleanSentence(sentence, PREFIXES);
|
|
90
98
|
const words = tokens(stripped);
|
|
99
|
+
const lowered = stripped
|
|
100
|
+
.toLocaleLowerCase("en")
|
|
101
|
+
.replaceAll(String.fromCharCode(0x2019), "'");
|
|
91
102
|
const matches = [];
|
|
92
103
|
if (tokensContainInOrder(words, [["following"], PREVIEW_OBJECTS, PREVIEW_VERBS])) {
|
|
93
104
|
matches.push("following + explore");
|
|
94
105
|
}
|
|
95
|
-
|
|
106
|
+
// Opener only: "When it comes to X, ..." is a weak filler lead-in. Mid-sentence
|
|
107
|
+
// ("... tells us little when it comes to Y") is ordinary usage, not boilerplate.
|
|
108
|
+
if (lowered.startsWith("when it comes to")) {
|
|
96
109
|
matches.push("when it comes to");
|
|
97
110
|
}
|
|
98
111
|
if (tokensContainInOrder(words, [
|
|
@@ -111,9 +124,6 @@ function matchBoilerplateFraming(sentence) {
|
|
|
111
124
|
if (starter !== undefined) {
|
|
112
125
|
matches.push(starter);
|
|
113
126
|
}
|
|
114
|
-
const lowered = stripped
|
|
115
|
-
.toLocaleLowerCase("en")
|
|
116
|
-
.replaceAll(String.fromCharCode(0x2019), "'");
|
|
117
127
|
const filler = FILLER_OPENERS.find((opener) => lowered.startsWith(opener));
|
|
118
128
|
if (filler !== undefined) {
|
|
119
129
|
matches.push(filler);
|
|
@@ -3,7 +3,9 @@ import { cleanSentence, containsAny, tokens } from "../../../shared/matchers/pro
|
|
|
3
3
|
import { oneToOneRule } from "../../private/textlint-rule-builders.js";
|
|
4
4
|
import { matchDiscourseEvaluationFrame } from "./private/discourse-evaluation.js";
|
|
5
5
|
const PREFIXES = ["however, ", "but ", "and ", "so "];
|
|
6
|
-
|
|
6
|
+
// "as such" was removed: it is a normal anaphoric connective ("a registered adviser; as
|
|
7
|
+
// such, it must...") and was the dominant false positive, not a signposting frame.
|
|
8
|
+
const TRANSITION_PATTERNS = ["that being said"];
|
|
7
9
|
const CONSULTATION_PATTERNS = [
|
|
8
10
|
"it's always best to consult",
|
|
9
11
|
"it is always best to consult",
|
|
@@ -156,6 +156,33 @@ const COMMON_OPENERS = new Set([
|
|
|
156
156
|
"must",
|
|
157
157
|
"shall"
|
|
158
158
|
]);
|
|
159
|
+
// Enumerators and document-structure openers: list markers ("2.", "b)") and
|
|
160
|
+
// reference words that head TOC entries, headings, and procedural steps. Three
|
|
161
|
+
// sentences opening this way is structure, not repetitive prose, and was a
|
|
162
|
+
// dominant source of false positives in the human-corpus audit.
|
|
163
|
+
const STRUCTURAL_OPENERS = new Set([
|
|
164
|
+
"chapter",
|
|
165
|
+
"section",
|
|
166
|
+
"part",
|
|
167
|
+
"appendix",
|
|
168
|
+
"figure",
|
|
169
|
+
"table",
|
|
170
|
+
"page",
|
|
171
|
+
"step",
|
|
172
|
+
"verse",
|
|
173
|
+
"item"
|
|
174
|
+
]);
|
|
175
|
+
function isStructuralOpener(opener) {
|
|
176
|
+
if (STRUCTURAL_OPENERS.has(opener) || opener.length === 1) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
for (const character of opener) {
|
|
180
|
+
if (character < "0" || character > "9") {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
159
186
|
function isWordCharacter(character) {
|
|
160
187
|
return ((character >= "a" && character <= "z") ||
|
|
161
188
|
(character >= "0" && character <= "9") ||
|
|
@@ -213,6 +240,7 @@ function findTripleRepeats(text) {
|
|
|
213
240
|
if (firstOpener === undefined ||
|
|
214
241
|
firstOpener === "" ||
|
|
215
242
|
COMMON_OPENERS.has(firstOpener) ||
|
|
243
|
+
isStructuralOpener(firstOpener) ||
|
|
216
244
|
firstOpener !== secondOpener ||
|
|
217
245
|
secondOpener !== thirdOpener) {
|
|
218
246
|
continue;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineTextlintRule } from "../../adapters/textlint/rule.js";
|
|
2
|
+
import { documentUnit } from "../../adapters/textlint/units.js";
|
|
3
|
+
import { ERROR_SEVERITY } from "../../reporting/density.js";
|
|
4
|
+
import { wordTokens } from "../../shared/text/tokens.js";
|
|
5
|
+
// The rule only detects occurrences of "actually" - one detection per use, no counting,
|
|
6
|
+
// rate, or severity. The density-rate report policy below lets the reporter judge the
|
|
7
|
+
// occurrences against the document word count and decide none / warning / error.
|
|
8
|
+
// "actually" is a normal contrastive adverb at low frequency; AI overuse shows up as
|
|
9
|
+
// density, which is the reporter's concern, not the detector's. See src/reporting/density.ts.
|
|
10
|
+
const TARGET = "actually";
|
|
11
|
+
const RULE_ID = "words:actually-overuse";
|
|
12
|
+
const rule = defineTextlintRule({
|
|
13
|
+
detector: {
|
|
14
|
+
detect: ({ units }) => units.flatMap((unit) => wordTokens(unit.text)
|
|
15
|
+
.filter((token) => token.normalized === TARGET)
|
|
16
|
+
.map((token) => ({
|
|
17
|
+
evidence: TARGET,
|
|
18
|
+
label: TARGET,
|
|
19
|
+
range: { end: token.end, start: token.start },
|
|
20
|
+
ruleId: RULE_ID,
|
|
21
|
+
unitId: unit.id
|
|
22
|
+
}))),
|
|
23
|
+
family: "words",
|
|
24
|
+
id: RULE_ID
|
|
25
|
+
},
|
|
26
|
+
formatMessage: (report) => {
|
|
27
|
+
const count = report.metric?.["count"] ?? report.detections.length;
|
|
28
|
+
const perUnit = report.metric?.["perUnit"] ?? 0;
|
|
29
|
+
const threshold = report.severity === ERROR_SEVERITY
|
|
30
|
+
? "above the 2-per-1,000-word error threshold"
|
|
31
|
+
: "above the 1-per-1,000-word warning threshold";
|
|
32
|
+
return `"actually" used ${count} times (${perUnit} per 1,000 words), ${threshold}. Cut the filler uses; keep at most about one per 1,000 words.`;
|
|
33
|
+
},
|
|
34
|
+
reportPolicy: {
|
|
35
|
+
errorPerUnit: 2,
|
|
36
|
+
kind: "density-rate",
|
|
37
|
+
minimumOccurrences: 2,
|
|
38
|
+
scope: "document",
|
|
39
|
+
warningPerUnit: 1,
|
|
40
|
+
wordsPerUnit: 1000
|
|
41
|
+
},
|
|
42
|
+
units: (document) => [documentUnit(document)]
|
|
43
|
+
});
|
|
44
|
+
export default rule;
|
|
45
|
+
//# sourceMappingURL=actually-overuse.js.map
|