@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.
- package/README.md +48 -0
- package/bin/kensio-skills.mjs +213 -0
- package/package.json +39 -0
- package/skills/dynamodb-single-table/SKILL.md +329 -0
- package/skills/dynamodb-single-table/references/aws-guidance.md +183 -0
- package/skills/github-issue-drafting/SKILL.md +260 -0
- package/skills/isolated-testing-style/SKILL.md +293 -0
- package/skills/pangram-check/SKILL.md +125 -0
- package/skills/pangram-check/references/configuration.md +120 -0
- package/skills/pangram-check/references/reading-results.md +69 -0
- package/skills/pangram-check/scripts/pangram-check.mjs +807 -0
- package/skills/part-factory-test-data/SKILL.md +238 -0
- package/skills/skill-template/SKILL.md +126 -0
- package/skills/technical-prose-style/SKILL.md +356 -0
- package/skills/technical-prose-style/references/measurements.md +356 -0
- package/skills/technical-prose-style/scripts/prose-check.mjs +381 -0
- package/skills/yulin-aws-simulation/SKILL.md +324 -0
|
@@ -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);
|