@mrclrchtr/supi-flow 0.6.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/README.md +175 -0
- package/package.json +32 -0
- package/prompts/supi-coding-retro.md +18 -0
- package/skills/supi-flow-apply/SKILL.md +119 -0
- package/skills/supi-flow-archive/SKILL.md +101 -0
- package/skills/supi-flow-brainstorm/SKILL.md +117 -0
- package/skills/supi-flow-debug/SKILL.md +151 -0
- package/skills/supi-flow-plan/SKILL.md +117 -0
- package/skills/supi-flow-slop-detect/SKILL.md +393 -0
- package/skills/supi-flow-slop-detect/references/vocabulary.json +161 -0
- package/skills/supi-flow-slop-detect/scripts/slop-helpers.ts +301 -0
- package/skills/supi-flow-slop-detect/scripts/slop-scan-structural.ts +269 -0
- package/skills/supi-flow-slop-detect/scripts/slop-scan-vocab.ts +161 -0
- package/skills/supi-flow-slop-detect/scripts/slop-scan.ts +209 -0
- package/src/cli.ts +80 -0
- package/src/index.ts +167 -0
- package/src/tools/flow-tools.ts +337 -0
- package/src/tools/tndm-cli.ts +246 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tier1": [
|
|
3
|
+
{
|
|
4
|
+
"term": "delve",
|
|
5
|
+
"score": 3,
|
|
6
|
+
"context": "\"delve into\"",
|
|
7
|
+
"replacement": "explore, examine, look at"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"term": "tapestry",
|
|
11
|
+
"score": 3,
|
|
12
|
+
"context": "\"rich tapestry\"",
|
|
13
|
+
"replacement": "mix, combination, variety"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"term": "realm",
|
|
17
|
+
"score": 3,
|
|
18
|
+
"context": "\"in the realm of\"",
|
|
19
|
+
"replacement": "in, within, regarding"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"term": "embark",
|
|
23
|
+
"score": 3,
|
|
24
|
+
"context": "\"embark on a journey\"",
|
|
25
|
+
"replacement": "start, begin"
|
|
26
|
+
},
|
|
27
|
+
{ "term": "beacon", "score": 3, "context": "\"a beacon of\"", "replacement": "example, model" },
|
|
28
|
+
{
|
|
29
|
+
"term": "spearheaded",
|
|
30
|
+
"score": 3,
|
|
31
|
+
"context": "formal attribution",
|
|
32
|
+
"replacement": "led, started"
|
|
33
|
+
},
|
|
34
|
+
{ "term": "leverage", "score": 3, "context": "business jargon", "replacement": "use, apply" },
|
|
35
|
+
{
|
|
36
|
+
"term": "robust",
|
|
37
|
+
"score": 3,
|
|
38
|
+
"context": "quality signal",
|
|
39
|
+
"replacement": "solid, strong, reliable"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"term": "seamless",
|
|
43
|
+
"score": 3,
|
|
44
|
+
"context": "integration claim",
|
|
45
|
+
"replacement": "smooth, easy, simple"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"term": "pivotal",
|
|
49
|
+
"score": 3,
|
|
50
|
+
"context": "importance marker",
|
|
51
|
+
"replacement": "key, important"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"term": "multifaceted",
|
|
55
|
+
"score": 3,
|
|
56
|
+
"context": "complexity signal",
|
|
57
|
+
"replacement": "complex, varied"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"term": "comprehensive",
|
|
61
|
+
"score": 3,
|
|
62
|
+
"context": "scope claim",
|
|
63
|
+
"replacement": "thorough, complete"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"term": "nuanced",
|
|
67
|
+
"score": 3,
|
|
68
|
+
"context": "sophistication signal",
|
|
69
|
+
"replacement": "subtle, detailed"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"term": "meticulous",
|
|
73
|
+
"score": 3,
|
|
74
|
+
"context": "care signal",
|
|
75
|
+
"replacement": "careful, detailed"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"term": "intricate",
|
|
79
|
+
"score": 3,
|
|
80
|
+
"context": "complexity marker",
|
|
81
|
+
"replacement": "detailed, complex"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"term": "showcasing",
|
|
85
|
+
"score": 3,
|
|
86
|
+
"context": "display verb",
|
|
87
|
+
"replacement": "showing, displaying"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"term": "streamline",
|
|
91
|
+
"score": 3,
|
|
92
|
+
"context": "optimization verb",
|
|
93
|
+
"replacement": "simplify, improve"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"term": "facilitate",
|
|
97
|
+
"score": 3,
|
|
98
|
+
"context": "enablement verb",
|
|
99
|
+
"replacement": "enable, help, allow"
|
|
100
|
+
},
|
|
101
|
+
{ "term": "utilize", "score": 3, "context": "formal \"use\"", "replacement": "use" }
|
|
102
|
+
],
|
|
103
|
+
"tier2": [
|
|
104
|
+
{ "term": "moreover", "score": 2, "category": "Transition overuse" },
|
|
105
|
+
{ "term": "furthermore", "score": 2, "category": "Transition overuse" },
|
|
106
|
+
{ "term": "indeed", "score": 2, "category": "Transition overuse" },
|
|
107
|
+
{ "term": "notably", "score": 2, "category": "Transition overuse" },
|
|
108
|
+
{ "term": "subsequently", "score": 2, "category": "Transition overuse" },
|
|
109
|
+
{ "term": "significantly", "score": 2, "category": "Intensity clustering" },
|
|
110
|
+
{ "term": "substantially", "score": 2, "category": "Intensity clustering" },
|
|
111
|
+
{ "term": "fundamentally", "score": 2, "category": "Intensity clustering" },
|
|
112
|
+
{ "term": "profoundly", "score": 2, "category": "Intensity clustering" },
|
|
113
|
+
{ "term": "potentially", "score": 2, "category": "Hedging stacks" },
|
|
114
|
+
{ "term": "typically", "score": 2, "category": "Hedging stacks" },
|
|
115
|
+
{ "term": "might", "score": 2, "category": "Hedging stacks" },
|
|
116
|
+
{ "term": "perhaps", "score": 2, "category": "Hedging stacks" },
|
|
117
|
+
{ "term": "revolutionize", "score": 2, "category": "Action inflation" },
|
|
118
|
+
{ "term": "transform", "score": 2, "category": "Action inflation" },
|
|
119
|
+
{ "term": "unlock", "score": 2, "category": "Action inflation" },
|
|
120
|
+
{ "term": "unleash", "score": 2, "category": "Action inflation" },
|
|
121
|
+
{ "term": "elevate", "score": 2, "category": "Action inflation" },
|
|
122
|
+
{ "term": "crucial", "score": 2, "category": "Empty emphasis" },
|
|
123
|
+
{ "term": "vital", "score": 2, "category": "Empty emphasis" },
|
|
124
|
+
{ "term": "essential", "score": 2, "category": "Empty emphasis" },
|
|
125
|
+
{ "term": "paramount", "score": 2, "category": "Empty emphasis" }
|
|
126
|
+
],
|
|
127
|
+
"tier3": [
|
|
128
|
+
{
|
|
129
|
+
"term": "in today's fast-paced world",
|
|
130
|
+
"score": 4,
|
|
131
|
+
"replacement": "Delete — start with the point"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"term": "it's worth noting that",
|
|
135
|
+
"score": 3,
|
|
136
|
+
"replacement": "Delete — just state the thing"
|
|
137
|
+
},
|
|
138
|
+
{ "term": "at its core", "score": 2, "replacement": "\"Fundamentally\" or delete" },
|
|
139
|
+
{
|
|
140
|
+
"term": "cannot be overstated",
|
|
141
|
+
"score": 3,
|
|
142
|
+
"replacement": "\"is important because [reason]\""
|
|
143
|
+
},
|
|
144
|
+
{ "term": "navigate the complexities", "score": 4, "replacement": "handle, work through" },
|
|
145
|
+
{ "term": "unlock the potential", "score": 4, "replacement": "enable, make possible" },
|
|
146
|
+
{ "term": "a testament to", "score": 3, "replacement": "shows, demonstrates" },
|
|
147
|
+
{ "term": "treasure trove of", "score": 3, "replacement": "collection, set" },
|
|
148
|
+
{ "term": "game changer", "score": 3, "replacement": "Delete — be specific" },
|
|
149
|
+
{ "term": "ever-evolving landscape", "score": 4, "replacement": "Delete — be specific" },
|
|
150
|
+
{ "term": "look no further", "score": 4, "replacement": "Delete — state the answer" },
|
|
151
|
+
{ "term": "hustle and bustle", "score": 3, "replacement": "Delete — filler" }
|
|
152
|
+
],
|
|
153
|
+
"tier4": [
|
|
154
|
+
{ "term": "I'd be happy to", "score": 2, "issue": "Servile opener" },
|
|
155
|
+
{ "term": "Great question!", "score": 2, "issue": "Empty validation" },
|
|
156
|
+
{ "term": "Absolutely!", "score": 2, "issue": "Over-agreement" },
|
|
157
|
+
{ "term": "That's a wonderful point", "score": 2, "issue": "Flattery" },
|
|
158
|
+
{ "term": "I'm glad you asked", "score": 2, "issue": "Filler" },
|
|
159
|
+
{ "term": "You're absolutely right", "score": 2, "issue": "Sycophancy" }
|
|
160
|
+
]
|
|
161
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for slop detection scripts.
|
|
3
|
+
*
|
|
4
|
+
* Cross-platform Node.js/TypeScript — runs wherever pi runs.
|
|
5
|
+
* Use via: pnpm exec jiti <script>.ts <file>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
export type DocProfile = "skill" | "technical" | "prose";
|
|
11
|
+
|
|
12
|
+
interface ArrowConnectorStats {
|
|
13
|
+
total: number;
|
|
14
|
+
technical: number;
|
|
15
|
+
prose: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ARROW_CONNECTOR_PATTERN = /->|→/g;
|
|
19
|
+
const TECHNICAL_TOKEN_PATTERN = /[A-Za-z0-9_./-]+/g;
|
|
20
|
+
const ARROW_PROSE_START_WORDS = new Set([
|
|
21
|
+
"a",
|
|
22
|
+
"an",
|
|
23
|
+
"are",
|
|
24
|
+
"can",
|
|
25
|
+
"does",
|
|
26
|
+
"helps",
|
|
27
|
+
"improves",
|
|
28
|
+
"is",
|
|
29
|
+
"it",
|
|
30
|
+
"lets",
|
|
31
|
+
"makes",
|
|
32
|
+
"means",
|
|
33
|
+
"shows",
|
|
34
|
+
"that",
|
|
35
|
+
"the",
|
|
36
|
+
"their",
|
|
37
|
+
"there",
|
|
38
|
+
"these",
|
|
39
|
+
"this",
|
|
40
|
+
"those",
|
|
41
|
+
"we",
|
|
42
|
+
"you",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** Read a file as UTF-8 string. */
|
|
46
|
+
export function readFile(path: string): string {
|
|
47
|
+
return readFileSync(path, "utf-8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Strip fenced code blocks from markdown content. */
|
|
51
|
+
export function stripCodeBlocks(content: string): string {
|
|
52
|
+
return content.replace(/```[\s\S]*?```/g, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Strip inline code spans from markdown content. */
|
|
56
|
+
export function stripInlineCode(content: string): string {
|
|
57
|
+
return content.replace(/`[^`]+`/g, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Detect the document profile used for structural scoring. */
|
|
61
|
+
export function detectDocProfile(filePath: string): DocProfile {
|
|
62
|
+
if (/[\\/]skills[\\/].*[\\/]SKILL\.md$/i.test(filePath)) return "skill";
|
|
63
|
+
if (/(?:^|[\\/])README\.md$/i.test(filePath) || /[\\/]docs[\\/].*\.md$/i.test(filePath)) {
|
|
64
|
+
return "technical";
|
|
65
|
+
}
|
|
66
|
+
return "prose";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Count non-empty lines. */
|
|
70
|
+
export function countNonEmpty(content: string): number {
|
|
71
|
+
return content.split("\n").filter((l) => l.trim().length > 0).length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Count words in text. */
|
|
75
|
+
export function countWords(text: string): number {
|
|
76
|
+
return text.split(/[\s\n]+/).filter((w) => w.length > 0).length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Count sentences in text (naive: split on sentence-ending punctuation). */
|
|
80
|
+
export function countSentences(text: string): number {
|
|
81
|
+
return text.split(/[.!?]+/).filter((s) => s.trim().length > 0).length || 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Count paragraphs (blocks separated by blank lines). */
|
|
85
|
+
export function countParagraphs(text: string): number {
|
|
86
|
+
return text.split(/\n\s*\n/).filter((p) => p.trim().length > 0).length || 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Count em dashes in text. */
|
|
90
|
+
export function countEmDashes(text: string): number {
|
|
91
|
+
return (text.match(/—/g) || []).length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Count semicolons in text. */
|
|
95
|
+
export function countSemicolons(text: string): number {
|
|
96
|
+
return (text.match(/;/g) || []).length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Count colons in text. */
|
|
100
|
+
export function countColons(text: string): number {
|
|
101
|
+
return (text.match(/:/g) || []).length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isTechnicalArrowContext(leftTokens: string[], rightTokens: string[]): boolean {
|
|
105
|
+
if (leftTokens.length === 0 || rightTokens.length === 0) return false;
|
|
106
|
+
|
|
107
|
+
const leftFirst = leftTokens[0]?.toLowerCase() ?? "";
|
|
108
|
+
const rightFirst = rightTokens[0]?.toLowerCase() ?? "";
|
|
109
|
+
|
|
110
|
+
if (ARROW_PROSE_START_WORDS.has(leftFirst) || ARROW_PROSE_START_WORDS.has(rightFirst)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return leftTokens.length <= 3 && rightTokens.length <= 3;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Analyze arrow connectors in prose and split technical notation from prose shorthand. */
|
|
118
|
+
export function analyzeArrowConnectors(content: string): ArrowConnectorStats {
|
|
119
|
+
const prose = stripInlineCode(stripCodeBlocks(content));
|
|
120
|
+
const lines = prose.split("\n");
|
|
121
|
+
|
|
122
|
+
let total = 0;
|
|
123
|
+
let technical = 0;
|
|
124
|
+
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
const matches = [...line.matchAll(ARROW_CONNECTOR_PATTERN)];
|
|
127
|
+
total += matches.length;
|
|
128
|
+
|
|
129
|
+
for (const match of matches) {
|
|
130
|
+
const index = match.index ?? -1;
|
|
131
|
+
if (index < 0) continue;
|
|
132
|
+
|
|
133
|
+
const leftTokens = [...line.slice(0, index).matchAll(TECHNICAL_TOKEN_PATTERN)]
|
|
134
|
+
.map((token) => token[0])
|
|
135
|
+
.slice(-3);
|
|
136
|
+
const rightTokens = [...line.slice(index + match[0].length).matchAll(TECHNICAL_TOKEN_PATTERN)]
|
|
137
|
+
.map((token) => token[0])
|
|
138
|
+
.slice(0, 3);
|
|
139
|
+
|
|
140
|
+
if (isTechnicalArrowContext(leftTokens, rightTokens)) {
|
|
141
|
+
technical++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
total,
|
|
148
|
+
technical,
|
|
149
|
+
prose: Math.max(0, total - technical),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Count plus-sign conjunctions in prose (excluding code blocks). */
|
|
154
|
+
export function countPlusSigns(content: string): number {
|
|
155
|
+
const prose = stripInlineCode(stripCodeBlocks(content));
|
|
156
|
+
return (prose.match(/\s\+\s/g) || []).length;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Count bullet list items (lines starting with -, *, +). */
|
|
160
|
+
export function countBulletLines(content: string): number {
|
|
161
|
+
return (content.match(/^[ \t]*[-*+]\s/gm) || []).length;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Count participial phrase tail-loading patterns. */
|
|
165
|
+
export function countParticipialTails(text: string): number {
|
|
166
|
+
// Pattern: [main clause], [present participle] [detail].
|
|
167
|
+
const pattern =
|
|
168
|
+
/,\s*(enabling|making|creating|providing|leading|marking|contributing|resulting|allowing|using|bringing|taking|giving|setting)\s+\w+/gi;
|
|
169
|
+
return (text.match(pattern) || []).length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Count correlative conjunction pairs in proximity. */
|
|
173
|
+
export function countCorrelativePairs(text: string): number {
|
|
174
|
+
const patterns = [
|
|
175
|
+
/not\s+only\s+\w+\s+but\s+also/gi,
|
|
176
|
+
/whether\s+\w+\s+or\s+\w+/gi,
|
|
177
|
+
/not\s+just\s+\w+\s+but/gi,
|
|
178
|
+
/both\s+\w+\s+and\s+\w+/gi,
|
|
179
|
+
/either\s+\w+\s+or\s+\w+/gi,
|
|
180
|
+
/neither\s+\w+\s+nor\s+\w+/gi,
|
|
181
|
+
];
|
|
182
|
+
return patterns.reduce((sum, re) => sum + (text.match(re) || []).length, 0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Count "From X to Y" range constructions. */
|
|
186
|
+
export function countFromToRanges(text: string): number {
|
|
187
|
+
return (text.match(/\bfrom\s+\w+.*?\bto\s+\w+/gi) || []).length;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Get first and last paragraph from markdown (for conclusion mirroring check). */
|
|
191
|
+
export function getFirstAndLastParagraph(content: string): [string, string] {
|
|
192
|
+
const paragraphs = content
|
|
193
|
+
.split(/\n\s*\n/)
|
|
194
|
+
.map((p) => p.trim())
|
|
195
|
+
.filter((p) => p.length > 0 && !p.startsWith("---") && !p.startsWith("```"));
|
|
196
|
+
|
|
197
|
+
if (paragraphs.length < 2) {
|
|
198
|
+
return [paragraphs[0] || "", ""];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return [paragraphs[0] || "", paragraphs[paragraphs.length - 1] || ""];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Check if two paragraphs are near-paraphrases (simple word-overlap heuristic). */
|
|
205
|
+
export function isNearParaphrase(a: string, b: string, threshold = 0.6): boolean {
|
|
206
|
+
const wordsA = new Set(
|
|
207
|
+
a
|
|
208
|
+
.toLowerCase()
|
|
209
|
+
.split(/[\s,.;:!?()]+/)
|
|
210
|
+
.filter((w) => w.length > 3),
|
|
211
|
+
);
|
|
212
|
+
const wordsB = new Set(
|
|
213
|
+
b
|
|
214
|
+
.toLowerCase()
|
|
215
|
+
.split(/[\s,.;:!?()]+/)
|
|
216
|
+
.filter((w) => w.length > 3),
|
|
217
|
+
);
|
|
218
|
+
if (wordsA.size === 0 || wordsB.size === 0) return false;
|
|
219
|
+
|
|
220
|
+
let overlap = 0;
|
|
221
|
+
for (const w of wordsA) {
|
|
222
|
+
if (wordsB.has(w)) overlap++;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const scoreA = overlap / wordsA.size;
|
|
226
|
+
const scoreB = overlap / wordsB.size;
|
|
227
|
+
return Math.max(scoreA, scoreB) > threshold;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Compute bullet-to-prose ratio (as fraction 0-1). */
|
|
231
|
+
export function computeBulletRatio(content: string): number {
|
|
232
|
+
const totalLines = countNonEmpty(content);
|
|
233
|
+
if (totalLines === 0) return 0;
|
|
234
|
+
const bulletLines = countBulletLines(content);
|
|
235
|
+
return bulletLines / totalLines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Detect intro-body-conclusion structure where the closing paragraph mirrors
|
|
239
|
+
* the opening (the "five-paragraph essay" pattern common in AI-generated prose). */
|
|
240
|
+
export function detectIntroBodyConclusion(content: string): boolean {
|
|
241
|
+
const paragraphs = content
|
|
242
|
+
.split(/\n\s*\n/)
|
|
243
|
+
.map((p) => p.trim())
|
|
244
|
+
.filter((p) => p.length > 0 && !p.startsWith("```"));
|
|
245
|
+
|
|
246
|
+
if (paragraphs.length < 5) return false;
|
|
247
|
+
|
|
248
|
+
const firstLen = countWords(paragraphs[0]);
|
|
249
|
+
const lastLen = countWords(paragraphs[paragraphs.length - 1]);
|
|
250
|
+
const bodyLens = paragraphs.slice(1, -1).map(countWords);
|
|
251
|
+
|
|
252
|
+
// Heuristic: intro + 3+ body sections + short conclusion
|
|
253
|
+
const hasThreeMiddleSections = bodyLens.length >= 3;
|
|
254
|
+
const conclusionShorter = lastLen < firstLen * 0.8;
|
|
255
|
+
const startsWithIntro = firstLen > 20;
|
|
256
|
+
|
|
257
|
+
return hasThreeMiddleSections && conclusionShorter && startsWithIntro;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Compute paragraph word-count uniformity score (0-1).
|
|
261
|
+
* Higher = more uniform paragraph lengths (strong AI signal).
|
|
262
|
+
* Uses inverted coefficient of variation: 1 - min(1, stddev/mean).
|
|
263
|
+
* Score > 0.7 means paragraphs are suspiciously uniform. */
|
|
264
|
+
export function paragraphUniformity(content: string): number {
|
|
265
|
+
const paragraphs = content
|
|
266
|
+
.split(/\n\s*\n/)
|
|
267
|
+
.map((p) => p.trim())
|
|
268
|
+
.filter((p) => p.length > 0 && !p.startsWith("```"))
|
|
269
|
+
.map(countWords);
|
|
270
|
+
|
|
271
|
+
if (paragraphs.length < 3) return 0;
|
|
272
|
+
|
|
273
|
+
const mean = paragraphs.reduce((s, w) => s + w, 0) / paragraphs.length;
|
|
274
|
+
if (mean === 0) return 0;
|
|
275
|
+
const variance = paragraphs.reduce((s, w) => s + (w - mean) ** 2, 0) / paragraphs.length;
|
|
276
|
+
const cv = Math.sqrt(variance) / mean;
|
|
277
|
+
return Math.round((1 - Math.min(1, cv)) * 100) / 100;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Compute sentence length clustering score (0-1). Ratio of sentences in 15-25 word range. */
|
|
281
|
+
export function sentenceLengthClustering(text: string): number {
|
|
282
|
+
const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0);
|
|
283
|
+
if (sentences.length < 3) return 0;
|
|
284
|
+
|
|
285
|
+
const wordCounts = sentences.map((s) => countWords(s));
|
|
286
|
+
const clustered = wordCounts.filter((w) => w >= 15 && w <= 25).length;
|
|
287
|
+
return clustered / sentences.length;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Count emoji-led bullet lines. */
|
|
291
|
+
export function countEmojiBullets(content: string): number {
|
|
292
|
+
// Use alternation instead of a character class to avoid
|
|
293
|
+
// biome lint error about character + combining character in same class.
|
|
294
|
+
const emojiPattern = /^[ \t]*(?:✅|❌|🔴|🟢|🟡|⭐|🎯|💡|📌|🔹|🔸|✔️|✏️|📝|🚀|💪|🔧|⚡|🔥|💎)/gm;
|
|
295
|
+
return (content.match(emojiPattern) || []).length;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Output structured result as JSON. */
|
|
299
|
+
export function outputJSON(data: unknown): void {
|
|
300
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
301
|
+
}
|