@kensio/skills 1.13.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.
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ // Scores markdown prose against the sentence shapes that separate LLM-written technical
3
+ // documentation from human-written technical documentation, plus three banned marks, a
4
+ // lexical-spread measure and a check on heading framing.
5
+ //
6
+ // Baselines are the rate in 66,000 words of human technical documentation (Django, Effective
7
+ // Go, the Rust Book, the Python docs). Warn is the 90th percentile of those files. Fail is the
8
+ // lowest threshold that flags none of them, which still flags every LLM-written file tested.
9
+ // See reference/measurements.md.
10
+ //
11
+ // node prose-check.mjs docs/ score every .md under docs/
12
+ // node prose-check.mjs README.md --examples 5
13
+ // node prose-check.mjs docs/ --json
14
+ // node prose-check.mjs docs/ --no-bans measured patterns only, skip house bans
15
+ //
16
+ // Exits 1 if any file is over a fail threshold.
17
+
18
+ import { readFileSync, readdirSync, statSync } from "node:fs";
19
+ import { join, relative } from "node:path";
20
+
21
+ const PATTERNS = [
22
+ {
23
+ name: "significance-tail",
24
+ // The comma is the discriminator. Requiring it separates the corpora at 8.1x,
25
+ // against 5.8x without, at the same recall. See reference/measurements.md.
26
+ re: /,\s+so\s+(?:a|an|the|it|that|this|there|nothing|no|tests?|you|we|they)\b/gi,
27
+ baseline: 0.79,
28
+ warn: 1.7,
29
+ fail: 2.3,
30
+ hint: "State the fact and stop. Give the consequence its own sentence only if it is not derivable.",
31
+ },
32
+ {
33
+ name: "contrastive-def",
34
+ re: /\b(?:rather than|instead of)\b/gi,
35
+ baseline: 1.06,
36
+ warn: 2.3,
37
+ fail: 4.0,
38
+ hint: "Say what the thing does. Contrast only to correct a belief the reader is likely to hold.",
39
+ },
40
+ {
41
+ name: "contrastive-coda",
42
+ // The same construction as contrastive-def, wearing a comma. Separates the
43
+ // corpora at 8.3x, and went undetected for four releases because the
44
+ // contrastive-def regex only looks for the two-word forms. The exclusions are
45
+ // correlatives and adverbs that open an ordinary clause instead of a
46
+ // corrective coda. "not only X but Y", "no matter how", "not yet available".
47
+ re: /,\s+not\s+(?!only|just|merely|because|that|to|if|when|matter|longer|yet|so|such|more|less|fewer|doubt)[a-z'"`][^.!?;:]*[.!?]/gi,
48
+ baseline: 0.3,
49
+ warn: 0.85,
50
+ fail: 1.6,
51
+ hint: "Delete the coda, or make the correction the whole sentence. Contrast only to fix a wrong belief.",
52
+ },
53
+ {
54
+ name: "negation-frame",
55
+ re: /\b(?:is not|are not|does not|nothing|neither)\b/gi,
56
+ baseline: 2.04,
57
+ warn: 4.6,
58
+ fail: 5.0,
59
+ hint: "Keep negation where the absence is the fact. Otherwise write the positive statement.",
60
+ },
61
+ {
62
+ name: "appositive-tail",
63
+ re: /, which (?:is|means|makes|gives|lets|keeps|does)\b/gi,
64
+ baseline: 0.59,
65
+ warn: 1.1,
66
+ fail: 1.8,
67
+ hint: "Promote the clause to its own sentence, or delete it.",
68
+ },
69
+
70
+ {
71
+ name: "lexical-spread",
72
+ // Distinct words per 100, averaged over the document. Human technical writing
73
+ // names a thing and then keeps naming it that. LLM prose reaches for a synonym,
74
+ // which spreads the vocabulary and makes the reader re-resolve the referent.
75
+ // 15 human documents average 0.628 and none exceeds 0.664. 55 LLM documents
76
+ // average 0.685 and none falls below 0.658. See reference/measurements.md.
77
+ measure: (prose) => {
78
+ const words = prose.toLowerCase().match(/[a-z']+/g) ?? [];
79
+ if (words.length < 100) return null;
80
+ const chunks = [];
81
+ for (let i = 0; i + 100 <= words.length; i += 100) {
82
+ chunks.push(new Set(words.slice(i, i + 100)).size / 100);
83
+ }
84
+ return chunks.reduce((a, b) => a + b, 0) / chunks.length;
85
+ },
86
+ unit: "distinct/100",
87
+ baseline: 0.628,
88
+ warn: 0.655,
89
+ // Advisory, never a failure. Two reasons. It scores a whole document and points
90
+ // at no line to go and fix. And it is trivially
91
+ // gameable by padding with repeated words, which would make the prose worse while
92
+ // moving the number the right way.
93
+ advisory: true,
94
+ hint: "Use the same term for the same thing. A synonym makes the reader re-resolve the referent.",
95
+ },
96
+ // Banned marks. Any occurrence in prose fails, whatever the rate.
97
+ {
98
+ name: "em-dash",
99
+ re: /—/g,
100
+ baseline: 0.24,
101
+ ban: true,
102
+ hint: "Use a full stop, a comma, or brackets. House rule: none in prose.",
103
+ },
104
+ {
105
+ name: "semicolon",
106
+ re: /;/g,
107
+ baseline: 2.7,
108
+ ban: true,
109
+ hint: "Split the sentence. House rule, none in prose.",
110
+ },
111
+ {
112
+ // A colon ending a line to introduce a list or a code block does not match,
113
+ // because the next block starts with a capital or a fence. Only the
114
+ // mid-sentence form is banned.
115
+ name: "colon-splice",
116
+ re: /[a-z,] ?: [a-z]/g,
117
+ baseline: 2.05,
118
+ ban: true,
119
+ hint: "Start a new sentence. House rule, no mid-sentence colons in prose.",
120
+ },
121
+ // Headings are stripped from the prose, so until now nothing scored them. Framing
122
+ // a heading by what a section excludes runs at 6.3% of 128 LLM headings against
123
+ // 0.5% of 437 human ones. Two of the human hits are Django headings documenting a
124
+ // genuine prohibition, so this reports every occurrence and never fails a file.
125
+ {
126
+ name: "heading-frame",
127
+ scope: "headings",
128
+ re: /\b(?:is not|are not|does not|do not|nothing|neither|not)\b/i,
129
+ advisory: true,
130
+ hint: "Name what the section covers. A negative heading makes the reader invert it to find out.",
131
+ },
132
+ ];
133
+
134
+ let active;
135
+ const MIN_WORDS = 200; // below this, rates per 1000 words are too noisy to act on
136
+ const MIN_COUNT = 3; // a rate computed from one or two occurrences is noise too
137
+
138
+ /**
139
+ * Reduce markdown to the prose a human actually reads.
140
+ *
141
+ * Regex patterns need inline code masked, so a colon or a comma inside a code span
142
+ * cannot be mistaken for punctuation in a sentence. The lexical measure needs the
143
+ * opposite: an identifier like `SimAws` is exactly the kind of term that should be
144
+ * repeated, and collapsing every span to one token would hide that.
145
+ */
146
+ function toProse(markdown, { keepCode = false } = {}) {
147
+ let text = markdown
148
+ .replace(/^---\n[\s\S]*?\n---\n/, "") // frontmatter
149
+ // Regions a document has deliberately excluded, for quoting prose as an example of
150
+ // what not to write. Applied before code stripping so it can cover anything.
151
+ .replace(/<!--\s*prose-check:off\s*-->[\s\S]*?<!--\s*prose-check:on\s*-->/g, "")
152
+ .replace(/^(?: {0,3})(```|~~~)[\s\S]*?^(?: {0,3})\1[^\n]*$/gm, "") // fenced code
153
+ .replace(/<!--[\s\S]*?-->/g, "")
154
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images
155
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links keep their text
156
+ .replace(/^\[[^\]]+\]:.*$/gm, "") // reference definitions
157
+ // List typography, not a prose splice: `- **Term** — description`.
158
+ .replace(/^(\s*(?:[-*+]|\d+\.)\s+(?:\*\*[^*\n]+\*\*|\[[^\]\n]+\]|`[^`\n]+`))\s+—/gm, "$1 ")
159
+ .replace(/`([^`\n]*)`/g, keepCode ? "$1" : "CODE") // inline code
160
+ .replace(/<[^>\n]+>/g, "")
161
+ .replace(/https?:\/\/\S+/g, "");
162
+
163
+ // Rejoin hard-wrapped lines into blocks first. A block is a paragraph or a single
164
+ // list item, and only a whole block gets a terminator, so wrapped sentences survive.
165
+ const blocks = [];
166
+ let current = [];
167
+ const flush = () => {
168
+ if (current.length) blocks.push(current.join(" "));
169
+ current = [];
170
+ };
171
+
172
+ for (const raw of text.split("\n")) {
173
+ const line = raw.trim();
174
+ if (
175
+ !line ||
176
+ line.startsWith("#") || // headings are not prose
177
+ line.startsWith("|") || // table rows
178
+ /^[-*_]{3,}$/.test(line) // horizontal rules
179
+ ) {
180
+ flush();
181
+ continue;
182
+ }
183
+ if (/^(?:[-*+]|\d+\.)\s+/.test(line)) {
184
+ flush();
185
+ current.push(line.replace(/^(?:[-*+]|\d+\.)\s+/, ""));
186
+ continue;
187
+ }
188
+ current.push(line.replace(/^>\s?/, ""));
189
+ }
190
+ flush();
191
+
192
+ return (
193
+ blocks
194
+ // A block ending in a colon introduces the next block, a list or a code
195
+ // fence. Its object is not in this block, so the colon is closed off here.
196
+ // Without this, joining blocks manufactures a mid-sentence colon that the
197
+ // author never wrote.
198
+ .map((block) => block.replace(/:$/, "."))
199
+ .map((block) => (/[.!?]$/.test(block) ? block : `${block}.`))
200
+ .join(" ")
201
+ .replace(/\s+/g, " ")
202
+ .trim()
203
+ );
204
+ }
205
+
206
+ function sentences(prose) {
207
+ return prose
208
+ .split(/(?<=[.!?])\s+/)
209
+ .map((s) => s.trim())
210
+ .filter((s) => s.length > 15);
211
+ }
212
+
213
+ /**
214
+ * Heading text, with the markup taken off. Code fences go first, so a `# comment`
215
+ * inside a shell block is not mistaken for a heading.
216
+ */
217
+ function headings(markdown) {
218
+ const body = markdown
219
+ .replace(/^---\n[\s\S]*?\n---\n/, "")
220
+ .replace(/^(?: {0,3})(```|~~~)[\s\S]*?^(?: {0,3})\1[^\n]*$/gm, "");
221
+ return [...body.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)].map((m) =>
222
+ m[1]
223
+ .replace(/`([^`]*)`/g, "$1")
224
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
225
+ .replace(/\*+/g, "")
226
+ .trim(),
227
+ );
228
+ }
229
+
230
+ function scoreFile(path) {
231
+ const markdown = readFileSync(path, "utf8");
232
+ const prose = toProse(markdown);
233
+ const measurable = toProse(markdown, { keepCode: true });
234
+ const words = prose ? prose.split(" ").length : 0;
235
+ const sents = sentences(prose);
236
+ const heads = headings(markdown);
237
+
238
+ const results = active.map((pattern) => {
239
+ if (pattern.scope === "headings") {
240
+ const hit = heads.filter((h) => pattern.re.test(h));
241
+ return {
242
+ pattern,
243
+ count: hit.length,
244
+ rate: hit.length,
245
+ status: hit.length > 0 ? "warn" : "ok",
246
+ worst: hit,
247
+ };
248
+ }
249
+
250
+ if (pattern.measure) {
251
+ const value = pattern.measure(measurable);
252
+ let status = "ok";
253
+ if (words < MIN_WORDS || value === null) status = "short";
254
+ else if (!pattern.advisory && value >= pattern.fail) status = "FAIL";
255
+ else if (value >= pattern.warn) status = "warn";
256
+ return { pattern, count: null, rate: value, status, worst: [] };
257
+ }
258
+
259
+ const count = [...prose.matchAll(pattern.re)].length;
260
+ const rate = words ? (count / words) * 1000 : 0;
261
+ let status = "ok";
262
+ if (pattern.ban) status = count > 0 ? "FAIL" : "ok";
263
+ else if (words < MIN_WORDS) status = "short";
264
+ else if (count >= MIN_COUNT) {
265
+ if (rate >= pattern.fail) status = "FAIL";
266
+ else if (rate >= pattern.warn) status = "warn";
267
+ }
268
+
269
+ const worst = sents
270
+ .map((s) => ({ s, n: [...s.matchAll(pattern.re)].length }))
271
+ .filter((x) => x.n > 0)
272
+ .sort((a, b) => b.n - a.n || b.s.length - a.s.length)
273
+ .map((x) => x.s);
274
+
275
+ return { pattern, count, rate, status, worst };
276
+ });
277
+
278
+ return { path, words, sentences: sents.length, results };
279
+ }
280
+
281
+ function markdownFiles(target) {
282
+ if (statSync(target).isFile()) return target.endsWith(".md") ? [target] : [];
283
+ const found = [];
284
+ for (const entry of readdirSync(target, { withFileTypes: true })) {
285
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
286
+ found.push(...markdownFiles(join(target, entry.name)));
287
+ }
288
+ return found.sort();
289
+ }
290
+
291
+ const argv = process.argv.slice(2);
292
+ const json = argv.includes("--json");
293
+ // House bans are style, not evidence. Skip them to re-check the measured separation.
294
+ const noBans = argv.includes("--no-bans");
295
+ const exampleFlag = argv.indexOf("--examples");
296
+ const maxExamples = exampleFlag === -1 ? 1 : Number(argv[exampleFlag + 1]) || 1;
297
+ const exampleValue = exampleFlag === -1 ? -1 : exampleFlag + 1;
298
+ const targets = argv.filter((a, i) => !a.startsWith("--") && i !== exampleValue);
299
+
300
+ if (targets.length === 0) {
301
+ console.error(
302
+ "usage: prose-check.mjs <file-or-directory>... [--examples N] [--json] [--no-bans]",
303
+ );
304
+ process.exit(2);
305
+ }
306
+
307
+ active = noBans ? PATTERNS.filter((p) => !p.ban) : PATTERNS;
308
+ const reports = targets.flatMap(markdownFiles).map(scoreFile);
309
+
310
+ if (json) {
311
+ console.log(
312
+ JSON.stringify(
313
+ reports.map((r) => ({
314
+ path: r.path,
315
+ words: r.words,
316
+ patterns: Object.fromEntries(
317
+ r.results.map((x) => [
318
+ x.pattern.name,
319
+ {
320
+ count: x.count,
321
+ rate: x.rate === null ? null : Number(x.rate.toFixed(3)),
322
+ status: x.status,
323
+ },
324
+ ]),
325
+ ),
326
+ })),
327
+ null,
328
+ 2,
329
+ ),
330
+ );
331
+ } else {
332
+ const cwd = process.cwd();
333
+ for (const report of reports) {
334
+ const flagged = report.results.filter((r) => r.status === "FAIL" || r.status === "warn");
335
+ const label = relative(cwd, report.path) || report.path;
336
+
337
+ if (flagged.length === 0) {
338
+ console.log(`\x1b[32m✔\x1b[0m ${label} ${report.words} words`);
339
+ continue;
340
+ }
341
+
342
+ console.log(`\n${label} ${report.words} words, ${report.sentences} sentences`);
343
+ for (const { pattern, count, rate, status, worst } of flagged) {
344
+ const colour = status === "FAIL" ? "\x1b[31m" : "\x1b[33m";
345
+ if (pattern.scope === "headings") {
346
+ console.log(
347
+ ` ${colour}${status.padEnd(4)}\x1b[0m ${pattern.name.padEnd(18)} ` +
348
+ `${String(count).padStart(5)} heading(s) (advisory)`,
349
+ );
350
+ } else {
351
+ console.log(
352
+ ` ${colour}${status.padEnd(4)}\x1b[0m ${pattern.name.padEnd(18)} ` +
353
+ (pattern.measure
354
+ ? `${rate.toFixed(3).padStart(5)} ${pattern.unit} (baseline ${pattern.baseline}, `
355
+ : `${rate.toFixed(2).padStart(5)} /1k (${count}, baseline ${pattern.baseline}, `) +
356
+ (pattern.ban
357
+ ? "banned)"
358
+ : pattern.advisory
359
+ ? `warn ${pattern.warn}, advisory)`
360
+ : `warn ${pattern.warn}, fail ${pattern.fail})`),
361
+ );
362
+ }
363
+ console.log(` ${pattern.hint}`);
364
+ // Headings are the finding, so show all of them. Prose examples are samples.
365
+ const shownWorst = pattern.scope === "headings" ? worst : worst.slice(0, maxExamples);
366
+ for (const sentence of shownWorst) {
367
+ const shown = sentence.length > 170 ? `${sentence.slice(0, 167)}...` : sentence;
368
+ console.log(` \x1b[2m↳ ${shown}\x1b[0m`);
369
+ }
370
+ }
371
+ }
372
+
373
+ const failing = reports.filter((r) => r.results.some((x) => x.status === "FAIL"));
374
+ const short = reports.filter((r) => r.words < MIN_WORDS);
375
+ console.log(
376
+ `\n${reports.length} file(s), ${failing.length} over a fail threshold` +
377
+ (short.length ? `, ${short.length} too short to score` : ""),
378
+ );
379
+ }
380
+
381
+ process.exit(reports.some((r) => r.results.some((x) => x.status === "FAIL")) ? 1 : 0);