@heyramzi/lint 0.0.0-stage → 1.32.2

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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +277 -2
  3. package/bin/heyramzi-comment-length.js +99 -0
  4. package/bin/heyramzi-commit-msg.js +31 -0
  5. package/bin/heyramzi-file-tokens.js +66 -0
  6. package/bin/heyramzi-inline-path.js +79 -0
  7. package/bin/heyramzi-jev-code.js +107 -0
  8. package/bin/heyramzi-push-diff.js +93 -0
  9. package/bin/heyramzi-repo-rules.js +70 -0
  10. package/bin/heyramzi-slop.js +314 -0
  11. package/bin/heyramzi-svelte-markup.js +50 -0
  12. package/bin/heyramzi-ui-rules.js +56 -0
  13. package/comment-length.js +368 -0
  14. package/commit-msg.js +57 -0
  15. package/config/oxlintrc.json +87 -0
  16. package/data/invisibles.js +218 -0
  17. package/data/jev-bench.js +115 -0
  18. package/data/jev-code-bench.js +313 -0
  19. package/data/slop-words.d.ts +64 -0
  20. package/data/slop-words.js +2681 -0
  21. package/file-tokens.js +122 -0
  22. package/gate-files.js +14 -0
  23. package/index.js +116 -0
  24. package/inline-path.js +90 -0
  25. package/jev-code.js +194 -0
  26. package/jev.js +341 -0
  27. package/package.json +80 -4
  28. package/processors/markdown.js +22 -0
  29. package/push-diff.js +55 -0
  30. package/register.d.ts +43 -0
  31. package/register.js +255 -0
  32. package/repo-rules/packages.js +164 -0
  33. package/repo-rules/paths.js +101 -0
  34. package/repo-rules/toolchain.js +172 -0
  35. package/repo-rules/tree.js +51 -0
  36. package/repo-rules/workflows.js +116 -0
  37. package/repo-rules.js +54 -0
  38. package/rules/convex-ast.js +66 -0
  39. package/rules/convex-index-name.js +40 -0
  40. package/rules/convex-internal-only.js +59 -0
  41. package/rules/convex-no-date-now-in-query.js +48 -0
  42. package/rules/convex-no-deprecated-crons.js +40 -0
  43. package/rules/convex-no-filter.js +41 -0
  44. package/rules/convex-no-get-metadata.js +26 -0
  45. package/rules/convex-no-v-any.js +24 -0
  46. package/rules/convex-react-client-module-scope.js +37 -0
  47. package/rules/convex-require-args.js +39 -0
  48. package/rules/convex-require-returns.js +31 -0
  49. package/rules/convex-runtime-imports.js +48 -0
  50. package/rules/max-function-lines.js +105 -0
  51. package/rules/max-object-methods.js +86 -0
  52. package/rules/no-ai-words.js +78 -0
  53. package/rules/no-app-stores.js +25 -0
  54. package/rules/no-chained-type-assertions.js +91 -0
  55. package/rules/no-class.js +47 -0
  56. package/rules/no-conditional-empty-object-spread.js +64 -0
  57. package/rules/no-deep-phosphor-imports.js +29 -0
  58. package/rules/no-default-export.js +70 -0
  59. package/rules/no-em-dash.js +194 -0
  60. package/rules/no-emoji.js +56 -0
  61. package/rules/no-hardcoded-models.js +63 -0
  62. package/rules/no-inline-path.js +46 -0
  63. package/rules/no-inline-svg.js +67 -0
  64. package/rules/no-known-value-widening.js +289 -0
  65. package/rules/no-legacy-compat.js +35 -0
  66. package/rules/no-overkill-fallback.js +77 -0
  67. package/rules/no-raw-try-catch.js +26 -0
  68. package/rules/no-redundant-prop-fallback.js +153 -0
  69. package/rules/no-relative-imports.js +213 -0
  70. package/rules/no-relative-type-imports.js +40 -0
  71. package/rules/no-rune-outside-svelte-file.js +56 -0
  72. package/rules/no-rune-shadowing-binding.js +85 -0
  73. package/rules/no-skipped-or-focused-tests.js +74 -0
  74. package/rules/no-snake-case-props.js +45 -0
  75. package/rules/no-svelte4-syntax.js +33 -0
  76. package/rules/no-vitest-import-in-vite-plus.js +64 -0
  77. package/rules/props-interface.js +44 -0
  78. package/rules/redirect-external-needs-option.js +45 -0
  79. package/rules/require-safety-comment-for-type-assertion.js +110 -0
  80. package/rules/svelte-section-order.js +115 -0
  81. package/rules/test-file.js +8 -0
  82. package/scripts/count-hits.mjs +213 -0
  83. package/scripts/harvest-voice.mjs +161 -0
  84. package/scripts/jev-bench.mjs +41 -0
  85. package/scripts/jev-code-bench.mjs +61 -0
  86. package/scripts/measure-register.mjs +61 -0
  87. package/slop.d.ts +53 -0
  88. package/slop.js +644 -0
  89. package/svelte-markup.js +107 -0
  90. package/svelte-template.js +258 -0
  91. package/ui-rules.js +324 -0
package/slop.js ADDED
@@ -0,0 +1,644 @@
1
+ /**
2
+ * The publish gate. One function, run on anything before it goes out.
3
+ */
4
+
5
+ /*
6
+ * WHY it exists: the two halves of this check were a 122-line node script and 835 lines of vendored Python, both parked under `ai-doc/skills/.../humanizer/ scripts/`,
7
+ */
8
+
9
+ /*
10
+ * both invoked by hand, and neither reachable from code.
11
+ */
12
+
13
+ /*
14
+ * So the social drafter, the slide renderer, the Buffer scheduler and the CMS each grew their own partial version, and the invisible-character half ran on nothing at all.
15
+ */
16
+
17
+ /*
18
+ * Ramzi, 31 Aug 2026: "anything before it gets published goes through there."
19
+ */
20
+
21
+ /*
22
+ * Two layers, in this order, because the second reads what the first leaves:
23
+ */
24
+
25
+ /*
26
+ * 1. `stripInvisibles` removes the invisible characters. Deterministic, lossless for anything a reader can see, and it REWRITES the text.
27
+ */
28
+
29
+ /*
30
+ * 2. `auditSlop` reports the slop. It never rewrites: a word list cannot write the replacement sentence, and a gate that guesses is a gate nobody trusts.
31
+ */
32
+
33
+ /*
34
+ * `slopGate` runs both and is the only entry point a caller needs.
35
+ */
36
+
37
+ /*
38
+ * Both lists are imported, never restated: `data/invisibles.js` and `data/slop-words.js` are the one home for each.
39
+ */
40
+
41
+ import {
42
+ EMOJI_GLUE,
43
+ SEPARATORS,
44
+ SPACE_HOMOGLYPHS,
45
+ hex,
46
+ invisibleKind,
47
+ isInvisible,
48
+ } from "#lint/data/invisibles.js";
49
+ import { artifacts, patterns, phrases, words } from "#lint/data/slop-words.js";
50
+ import { auditRegister } from "#lint/register.js";
51
+ import { auditShorts } from "@heyramzi/social/shorts";
52
+
53
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+
55
+ /**
56
+ * A word boundary that counts an accented letter as a letter.
57
+ */
58
+
59
+ /*
60
+ * `\b` is defined against `[A-Za-z0-9_]`, so there is no boundary between a space and `é`: `\bécosystème\b` matched nothing, and every French ban starting with an accent was on the list
61
+ */
62
+
63
+ /*
64
+ * and unenforced. The lookarounds do the same job over `\p{L}`, in both languages.
65
+ */
66
+ const bounded = (word) =>
67
+ new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRe(word)}(?![\\p{L}\\p{N}_])`, "iu");
68
+
69
+ /**
70
+ * HTML in, prose out, so an email body and a markdown draft audit the same way.
71
+ */
72
+
73
+ /*
74
+ * Entities go before tags. A preheader is deliberately padded with zero-width entities, and decoding those first would turn the padding into real characters and make every length count downstream wrong.
75
+ */
76
+ export function stripMarkup(raw) {
77
+ return (
78
+ raw
79
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
80
+ // A markup comment is a note to the next developer, not copy.
81
+
82
+ // It goes with the script block, for the same reason: the CBAM pages rule their sections with `<!-- ————— name ————— -->`, which reported 8 em dashes nobody reads.
83
+ .replace(/<!--[\s\S]*?-->/g, " ")
84
+ .replace(/&[a-z]+;|&#\d+;/gi, " ")
85
+ .replace(/<[^>]+>/g, " ")
86
+ // Svelte and Vue control blocks are code that survives tag stripping, and their loop variables read as prose: `{#each items as row,
87
+
88
+ // i (row)}` counted `i` as a first-person pronoun and failed 3 pages on the pronoun ledger.
89
+
90
+ // Only `{#`, `{:` and `{/` go; a plain `{value}` interpolation is left alone, because the copy it stands for is audited where it is written.
91
+
92
+ // `{@render ...}` is a call, so it goes.
93
+ .replace(/\{[#:/@][^}]*\}/g, " ")
94
+ .replace(/[ \t]+/g, " ")
95
+ .trim()
96
+ );
97
+ }
98
+
99
+ const isPictographic = (ch) => /\p{Extended_Pictographic}/u.test(ch);
100
+
101
+ /**
102
+ * Remove every invisible character, normalise the fake spaces, and report what went.
103
+ */
104
+
105
+ /*
106
+ * The one exception is emoji glue.
107
+ */
108
+
109
+ /*
110
+ * A zero-width joiner or a variation selector sitting after an emoji is part of a visible sequence: strip it and a family becomes three people and a red heart loses
111
+ */
112
+
113
+ /*
114
+ * its colour. Anywhere else the same character is a carrier and goes.
115
+ */
116
+
117
+ /*
118
+ * `normalizeSpaces` is on by default. Turn it off only when editing a file whose spacing is deliberate; for anything headed into a web form, leave it on,
119
+ */
120
+
121
+ /*
122
+ * because a no-break space between two words is invisible to the writer and breaks wrapping for the reader.
123
+ */
124
+ export function stripInvisibles(text, { normalizeSpaces = true } = {}) {
125
+ const chars = [...text];
126
+ const removed = new Map();
127
+ const note = (cp) => {
128
+ const key = `${invisibleKind(cp)} ${hex(cp)}`;
129
+ removed.set(key, (removed.get(key) ?? 0) + 1);
130
+ };
131
+
132
+ let out = "";
133
+ for (let i = 0; i < chars.length; i += 1) {
134
+ const ch = chars[i];
135
+ const cp = ch.codePointAt(0);
136
+
137
+ if (EMOJI_GLUE.has(cp) && i > 0 && isPictographic(chars[i - 1])) {
138
+ out += ch;
139
+ continue;
140
+ }
141
+ if (isInvisible(cp)) {
142
+ note(cp);
143
+ continue;
144
+ }
145
+ if (SEPARATORS.has(cp)) {
146
+ note(cp);
147
+ out += SEPARATORS.get(cp);
148
+ continue;
149
+ }
150
+ if (normalizeSpaces && SPACE_HOMOGLYPHS.has(cp)) {
151
+ note(cp);
152
+ out += SPACE_HOMOGLYPHS.get(cp);
153
+ continue;
154
+ }
155
+ out += ch;
156
+ }
157
+
158
+ return {
159
+ text: out,
160
+ removed: [...removed].map(([what, count]) => ({ what, count })),
161
+ };
162
+ }
163
+
164
+ /** True when the text carries nothing invisible. The cheap gate. */
165
+ export function isClean(text) {
166
+ return stripInvisibles(text).removed.length === 0;
167
+ }
168
+
169
+ /**
170
+ * A regex over these fires on ordinary English, so they are reported as `review` rather than as a hit and never fail the gate.
171
+ */
172
+
173
+ /*
174
+ * A person decides whether the sentence defines a thing by denying another, or states a fact.
175
+ */
176
+ const PIVOT_GREPS = [
177
+ /rather than/gi,
178
+ /instead of/gi,
179
+ /\bnot a\b/gi,
180
+ /\bnot the\b/gi,
181
+ /\bis not\b/gi,
182
+ /plutôt que/gi,
183
+ /au lieu de/gi,
184
+ /n'est pas un/gi,
185
+ ];
186
+
187
+ /**
188
+ * "Banned formatting: no hashtags and no emojis, on any surface" was one line in `slopPromptBlock` and a check in nothing, so 13 skills wrote it out again as a checklist
189
+ */
190
+
191
+ /*
192
+ * bullet and the gate reported a draft full of both as clean. The rule belongs here, where it is applied, not in the thirteenth file that remembers it.
193
+ */
194
+
195
+ /*
196
+ * The emoji range is the one `rules/no-emoji.js` uses, so the ESLint rule and the gate cannot disagree about what an emoji is.
197
+ */
198
+ // Combining marks are what this class is for.
199
+ const EMOJI =
200
+ // oxlint-disable-next-line eslint/no-misleading-character-class
201
+ /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1FA00}-\u{1FA9F}\u{20E3}]/gu;
202
+
203
+ /**
204
+ * A hashtag is a `#` glued to a word mid-line.
205
+ */
206
+
207
+ /*
208
+ * The three shapes that are not one, and each of which fired before they were excluded: a markdown heading, which is a `#` at line start;
209
+ */
210
+
211
+ /*
212
+ * a hex colour, which is `#` and hex digits only; and an issue reference, which is `#` and digits.
213
+ */
214
+ const HASHTAG = /(?<=[^\n#]\s|^\s*\S[^\n]*?\s)#(?![0-9a-fA-F]{3,8}\b)(?!\d+\b)[A-Za-z][\w-]*/g;
215
+
216
+ /**
217
+ * Code is not copy.
218
+ */
219
+
220
+ /*
221
+ * `#if`, `#endif`, `#Preview` and `#available` are Swift, `#newlink` is a DOM id and `#sales-alerts` is a Slack channel: all 16 hashtag hits in the first corpus run were one of
222
+ */
223
+
224
+ /*
225
+ * those, every one of them inside a fence or a backtick span. Only the hashtag check reads this, because only it is confused by code.
226
+ */
227
+ function withoutCode(text) {
228
+ return (
229
+ text
230
+ .replace(/```[\s\S]*?```/g, " ")
231
+ .replace(/`[^`\n]*`/g, " ")
232
+ // A tool directive is syntax the file has to carry: `/* eslint-enable ... */`
233
+ // failed the banned word `enable` in 2 files that contain no copy at all.
234
+ .replace(
235
+ /\/[*/][ \t]*(?:eslint|oxlint|prettier|biome|c8|v8|ts-(?:ignore|expect-error|nocheck))[^\n]*/g,
236
+ " ",
237
+ )
238
+ );
239
+ }
240
+
241
+ /** A source file with its comments blanked. Comments are notes to the next developer, not copy; `comment-length` gates them. */
242
+ function withoutComments(text) {
243
+ return text
244
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
245
+ .replace(/(^|[\s;{}(,])\/\/[^\n]*/g, "$1");
246
+ }
247
+
248
+ /**
249
+ * The copy inside a source file: string literals of 2+ words, comments gone. The ledger read `get
250
+ * ours()` and `winner: "ours"` as first person: 24 false hits.
251
+ */
252
+ function copyStrings(text) {
253
+ const literals =
254
+ withoutComments(text).match(/"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`|'(?:[^'\\\n]|\\.)*'/g) ?? [];
255
+ return literals.filter((s) => /\p{L}{2,}\s+\p{L}{2,}/u.test(s)).join("\n");
256
+ }
257
+
258
+ /**
259
+ * A string literal's first sentence is sentence-initial, and every anchored pattern used to miss it.
260
+ */
261
+
262
+ /*
263
+ * The anchors in `patterns` accept `^`, a newline, or `.!?` plus a space.
264
+ */
265
+
266
+ /*
267
+ * In a copy module the sentence opens after `key: "`, which is none of the three, so `nobody-opener` and the 4 other anchored patterns never fired on the first sentence
268
+ */
269
+
270
+ /*
271
+ * of any string. sitesmiths keeps every word of the page in `copy.ts`, ran the gate as its AGENTS.md tells it to, and shipped "Nobody
272
+ */
273
+
274
+ /*
275
+ * pays us to start." past a pattern that had been lint-enforced since 5 Sep 2026. Opening the quote into a line break makes the anchors true again.
276
+ */
277
+
278
+ /*
279
+ * Only for files the caller says are source, because in markdown `he said, "Nobody knows."` is a quotation rather than a key, and it stays one.
280
+ */
281
+ function withStringStarts(text) {
282
+ return text.replace(/([:(,=[]\s*)["'`]/g, "$1\n");
283
+ }
284
+
285
+ /**
286
+ * An FAQ is the one section where a question heading is the right heading: it is written to match the query the reader typed,
287
+ */
288
+
289
+ /*
290
+ * and it holds its own subheadings by design. `question-heading` and `empty-section` skip it rather than asking a page to hide the question it answers.
291
+ */
292
+ const FAQ_TITLE =
293
+ /^(?:FAQ|F\.A\.Q\.?|Frequently asked questions|よくある質問|Questions? fréquentes?)$/i;
294
+
295
+ function withoutFaqSections(text) {
296
+ let depth = 0;
297
+ return text
298
+ .split("\n")
299
+ .map((line) => {
300
+ const heading = /^(#{1,6})[ \t]+(.+?)[ \t]*#*$/.exec(line);
301
+ if (heading) {
302
+ const level = heading[1].length;
303
+ if (depth && level <= depth) depth = 0;
304
+ if (!depth && FAQ_TITLE.test(heading[2].trim())) depth = level;
305
+ }
306
+ return depth ? "" : line;
307
+ })
308
+ .join("\n");
309
+ }
310
+
311
+ /**
312
+ * The words the body capitalises mid-sentence, which are names rather than styling.
313
+ */
314
+
315
+ /*
316
+ * Without it every review page naming 2 products failed `title-case-heading`: "What Wispr Flow does better than Wavenote" counts 2 capitals and none of them is a styling choice.
317
+ */
318
+
319
+ /*
320
+ * Headings are excluded from the scan so a title-cased heading cannot vouch for itself.
321
+ */
322
+ function properNouns(body) {
323
+ const found = new Set();
324
+ for (const line of body.replace(/^#{1,6}[ \t]+.*$/gm, " ").split("\n")) {
325
+ let start = true;
326
+ for (const raw of line.trim().split(/\s+/)) {
327
+ const word = raw.replace(/^[^\p{L}]+/u, "").replace(/[^\p{L}\p{N}'’-]+$/u, "");
328
+ if (word && !start && /^[A-ZÀ-Þ]/.test(word)) found.add(word);
329
+ start = /[.!?:][""''”’)\]]?$/.test(raw);
330
+ }
331
+ }
332
+ return found;
333
+ }
334
+
335
+ /**
336
+ * Which language the draft is in, so the checks that only work in one of them pick the right list.
337
+ */
338
+
339
+ /*
340
+ * Stopword counting, not a library.
341
+ */
342
+
343
+ /*
344
+ * The two sets share almost nothing, a draft of any length separates them by a wide margin, and the only caller that can be hurt by a wrong answer is
345
+ */
346
+
347
+ /*
348
+ * the pronoun ledger, which reports rather than rewrites. English is the default on a tie, including on an empty string.
349
+ */
350
+ const FR_STOPWORDS =
351
+ /(?<![\p{L}\p{N}_])(?:le|la|les|des|une|un|et|est|dans|pour|que|qui|pas|avec|sur|plus|cette|vous|nous|aux|mais|sont|être|ce|se|son|ses|leur|par|au|du|elle|il)(?![\p{L}\p{N}_])/giu;
352
+ const EN_STOPWORDS =
353
+ /(?<![\p{L}\p{N}_])(?:the|and|is|of|to|in|that|it|for|with|you|this|are|on|but|not|have|from|as|was|they|be|at|by|or|an|its|their)(?![\p{L}\p{N}_])/giu;
354
+
355
+ export function detectLanguage(text) {
356
+ // Japanese first, and by script rather than by stopword.
357
+
358
+ // A Japanese draft has no English or French stopwords of its own, so the count is decided by whatever it quotes, and every Japanese page on the Wavenote site came
359
+
360
+ // back "en" and was then graded on English pronouns it does not use.
361
+ const cjk = (text.match(/[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g) ?? []).length;
362
+ const letters = (text.match(/[\p{L}]/gu) ?? []).length;
363
+ if (letters > 0 && cjk / letters > 0.2) return "ja";
364
+ const fr = (text.match(FR_STOPWORDS) ?? []).length;
365
+ const en = (text.match(EN_STOPWORDS) ?? []).length;
366
+ return fr > en ? "fr" : "en";
367
+ }
368
+
369
+ /**
370
+ * The first person against the second, per language.
371
+ */
372
+
373
+ /*
374
+ * The English-only sets were the whole check until 5 Sep 2026, so every French draft counted 0 against 0 and failed `selfish-framing` by construction.
375
+ */
376
+
377
+ /*
378
+ * The check now runs on the detected language and only fires when the writer actually used the first person: copy with no pronouns at all is a reference page,
379
+ */
380
+
381
+ /*
382
+ * not a page about its author.
383
+ */
384
+ const PRONOUNS = {
385
+ en: {
386
+ // Case-sensitive: a lone `i` is a loop index, `US` a country, and `Annex I` a numeral.
387
+ first:
388
+ /(?<![\p{L}\p{N}_])(?:(?<!(?:Annex|Article|Title|Chapter|Part|Section|Phase|Tier) )I|[Ww]e|WE|[Mm]y|[Oo]urs?|OUR|[Mm]ine|[Uu]s)(?![\p{L}\p{N}_])/gu,
389
+ second: /(?<![\p{L}\p{N}_])(?:you|your|yours)(?![\p{L}\p{N}_])/giu,
390
+ },
391
+ fr: {
392
+ first: /(?<![\p{L}\p{N}_])(?:je|j'|nous|mon|ma|mes|notre|nos|moi|me)(?![\p{L}\p{N}_])/giu,
393
+ second: /(?<![\p{L}\p{N}_])(?:vous|votre|vos|tu|ton|ta|tes|toi)(?![\p{L}\p{N}_])/giu,
394
+ },
395
+ };
396
+
397
+ /**
398
+ * The signs that are only visible across a whole document, so no regex over one sentence finds them: Wikipedia's title case, heading levels,
399
+ */
400
+
401
+ /*
402
+ * boldface density, thematic breaks and mixed apostrophes (WP:AITITLECASE, WP:AIBOLD, WP:AICURLY), and the French page's "majuscules superflues dans les titres".
403
+ */
404
+
405
+ /*
406
+ * Markdown only. HTML is stripped to prose before it reaches here, so a page audits on its words and an `.md` draft audits on its words and its outline.
407
+ */
408
+ function auditStructure(text) {
409
+ const found = [];
410
+ const hit = (id, detail) => found.push({ kind: "structure", id, detail });
411
+
412
+ // Frontmatter is not a thematic break and its keys are not headings.
413
+ const body = text.replace(/^---\n[\s\S]*?\n---\n/, "\n");
414
+ const headings = [...body.matchAll(/^(#{1,6})[ \t]+(.+?)[ \t]*#*$/gm)].map((m) => ({
415
+ level: m[1].length,
416
+ title: m[2].trim(),
417
+ at: m.index,
418
+ }));
419
+
420
+ // Title case. The tell is the words a sentence would leave lowercase, so only words of 4 letters or more after the first one are counted,
421
+
422
+ // and 2 of them capitalised is the threshold: "Awards and Recognition" hits, "the ClickUp API" does not.
423
+ const names = properNouns(body);
424
+ for (const heading of headings) {
425
+ const rest = heading.title
426
+ .split(/\s+/)
427
+ .slice(1)
428
+ .filter((w) => w.replace(/\W/g, "").length >= 4);
429
+ const capitalised = rest.filter(
430
+ (w) => /^[A-ZÀ-Þ]/.test(w) && !names.has(w.replace(/[^\p{L}\p{N}'’-]+$/u, "")),
431
+ );
432
+ if (rest.length >= 2 && capitalised.length >= 2) hit("title-case-heading", heading.title);
433
+ }
434
+
435
+ const h1 = headings.filter((h) => h.level === 1);
436
+ if (h1.length > 1) hit("multiple-h1", `${h1.length} level-1 headings`);
437
+
438
+ for (let i = 1; i < headings.length; i += 1) {
439
+ if (headings[i].level - headings[i - 1].level > 1) {
440
+ hit(
441
+ "skipped-heading-level",
442
+ `${"#".repeat(headings[i - 1].level)} then ${"#".repeat(headings[i].level)} at "${headings[i].title}"`,
443
+ );
444
+ }
445
+ // A heading whose whole content is the next heading. Wikipedia's "headings
446
+ // only containing other headings": the section says nothing of its own.
447
+ const between = body
448
+ .slice(headings[i - 1].at, headings[i].at)
449
+ .split("\n")
450
+ .slice(1)
451
+ .join("")
452
+ .trim();
453
+ if (between === "" && !FAQ_TITLE.test(headings[i - 1].title)) {
454
+ hit("empty-section", headings[i - 1].title);
455
+ }
456
+ }
457
+
458
+ const bold = [...withoutCode(body).matchAll(/\*\*[^*\n]{2,}\*\*/g)];
459
+ // Japanese and Chinese put no spaces between words, so a whitespace split read a 3,500 character article as 256 words and failed all 15 Japanese pages on the site for
460
+
461
+ // boldface density. A CJK character counts as a word, which is how the languages are counted everywhere else.
462
+ const cjk = (body.match(/[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g) ?? []).length;
463
+ const wordCount = body.split(/\s+/).filter(Boolean).length + cjk;
464
+ if (bold.length > 3 && bold.length > wordCount / 120) {
465
+ hit("boldface-overuse", `${bold.length} bold runs in ${wordCount} words`);
466
+ }
467
+
468
+ const breaks = [...body.matchAll(/^[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$/gm)];
469
+ if (breaks.length > 1) hit("thematic-breaks", `${breaks.length} horizontal rules`);
470
+
471
+ for (const m of body.matchAll(/^[ \t]*[•▪●–—][ \t]+\S/gm)) {
472
+ hit("raw-bullet-glyph", m[0].trim());
473
+ }
474
+
475
+ return found;
476
+ }
477
+
478
+ /**
479
+ * The enumerable half of the humanizer, over one string.
480
+ */
481
+
482
+ /*
483
+ * Six layers: the banned words, the banned phrases, the anchored sentence `patterns`, the chatbot `artifacts`, the document-level structure, and the pronoun ledger.
484
+ */
485
+
486
+ /*
487
+ * Every sign on Wikipedia:Signs of AI writing that a machine can decide is in one of those; the French page adds no seventh.
488
+ */
489
+
490
+ /*
491
+ * Enumerable is the operative word. `shapes` in `slop-words.js` is deliberately absent, because a regex for "negation pivot" fires on every ordinary "is not".
492
+ */
493
+
494
+ /*
495
+ * Those still need a human read of the humanizer's `references/ patterns.md`, and this function's output says so.
496
+ */
497
+
498
+ /*
499
+ * So does everything the page lists that lives outside the text: a dead citation URL, an invalid ISBN, a hallucinated template, an edit summary.
500
+ */
501
+
502
+ /*
503
+ * Those are claims about the world, and a string is all this function gets.
504
+ */
505
+ export function auditSlop(draft, { code = false } = {}) {
506
+ const text = code ? withoutComments(draft) : draft;
507
+ const findings = [];
508
+ const add = (kind, id, detail) => findings.push({ kind, id, detail });
509
+
510
+ // The ban lists read the draft with its code spans removed, because an identifier is not a word somebody chose: `enhance` is a Worker directory and `enable` is an API
511
+
512
+ // name, and both failed the gate in files that carry no copy at all.
513
+
514
+ // The `patterns` below still read the whole text, where the surrounding sentence is what they are judging.
515
+ const prose = withoutCode(text);
516
+ for (const word of words) {
517
+ const m = prose.match(bounded(word));
518
+ if (m) add("word", word, m[0]);
519
+ }
520
+ for (const phrase of phrases) {
521
+ // Bounded like a word, not `includes`. A substring test read "En sommeil" as the banned "en somme" and failed a dormant-status label on the UpSys site, 6 Sep 2026.
522
+
523
+ // Every phrase here is whole words.
524
+ if (bounded(phrase).test(prose)) add("phrase", phrase, phrase);
525
+ }
526
+ // A pattern names the view of the draft it judges, and defaults to the whole thing. Only `faqless` differs, for the reason `FAQ_TITLE` carries.
527
+
528
+ // It was a `pattern.id === "question-heading"` test here until 17 Sep 2026, which put half of one pattern in this file and meant the next pattern needing a view had
529
+
530
+ // to be added in two places.
531
+ const views = {
532
+ anchored: code ? withStringStarts(text) : text,
533
+ faqless: withoutFaqSections(text),
534
+ // In source, an import list or an object literal is a comma run too.
535
+ prose: code ? copyStrings(text) : prose,
536
+ };
537
+ for (const pattern of patterns) {
538
+ const m = views[pattern.view ?? "anchored"].match(pattern.test);
539
+ if (m) add("pattern", pattern.id, m[0].slice(0, 90));
540
+ }
541
+ for (const artifact of artifacts) {
542
+ const m = text.match(artifact.test);
543
+ if (m) add("artifact", artifact.id, m[0].slice(0, 90));
544
+ }
545
+ for (const finding of auditStructure(text)) findings.push(finding);
546
+ for (const m of text.matchAll(/[—–]/g)) {
547
+ add("dash", "em-or-en-dash", text.slice(Math.max(0, m.index - 30), m.index + 30));
548
+ }
549
+ for (const m of text.matchAll(EMOJI)) {
550
+ add("emoji", "emoji", text.slice(Math.max(0, m.index - 30), m.index + 30));
551
+ }
552
+ for (const m of withoutCode(text).matchAll(HASHTAG)) {
553
+ add("hashtag", "hashtag", m[0]);
554
+ }
555
+
556
+ const ledgerText = code ? copyStrings(text) : text;
557
+ const count = (re) => (ledgerText.match(re) ?? []).length;
558
+ const language = detectLanguage(text);
559
+ // Only the languages with pronoun sets are graded.
560
+
561
+ // Japanese marks the same distinction with particles and by leaving the subject out, so a regex over `we` and `you` says nothing about a Japanese draft.
562
+ const pronouns = PRONOUNS[language];
563
+ const first = pronouns ? count(pronouns.first) : 0;
564
+ const second = pronouns ? count(pronouns.second) : 0;
565
+ // Strictly outnumber, which is the rule `pronouns-and-openings.md` states. `<=` failed a draft at
566
+ // parity, and a gate stricter than its own spec teaches the reader to ignore it.
567
+ if (first > 0 && second < first)
568
+ add("ledger", "selfish-framing", `first ${first}, second ${second}`);
569
+
570
+ const review = [];
571
+ for (const grep of PIVOT_GREPS) {
572
+ for (const m of text.matchAll(grep)) {
573
+ const from = Math.max(0, m.index - 45);
574
+ review.push({
575
+ kind: "review-pivot",
576
+ id: m[0].toLowerCase(),
577
+ detail: `...${text.slice(from, m.index + 55)}...`,
578
+ });
579
+ }
580
+ }
581
+
582
+ // WP:AICURLY. Curly quotes alone prove nothing: macOS types them by default and French typography wants them.
583
+
584
+ // Mixing both forms in one draft is the sign, because that is what a paste from a chat window into typed text leaves behind.
585
+
586
+ // Reported, never failed, so a deliberate house style survives.
587
+ const curly = (text.match(/[‘’“”]/g) ?? []).length;
588
+ const straight = (text.match(/['"]/g) ?? []).length;
589
+ if (curly > 0 && straight > 0) {
590
+ review.push({
591
+ kind: "review-quotes",
592
+ id: "mixed-apostrophes",
593
+ detail: `${curly} curly, ${straight} straight`,
594
+ });
595
+ }
596
+
597
+ return {
598
+ findings,
599
+ review,
600
+ language,
601
+ ledger: { first, second },
602
+ words: text.split(/\s+/).filter(Boolean).length,
603
+ };
604
+ }
605
+
606
+ /**
607
+ * Clean, then audit. The one call a publishing path makes.
608
+ */
609
+
610
+ /*
611
+ * Returns the cleaned text whether or not the audit passed, because the two results answer different questions: the text is always safe to send,
612
+ */
613
+
614
+ /*
615
+ * and `ok` says whether it is good enough to send. A caller that refuses on `ok` still publishes `text` once the words are fixed, never the original.
616
+ */
617
+ export function slopGate(
618
+ raw,
619
+ { markup = false, code = false, normalizeSpaces = true, register } = {},
620
+ ) {
621
+ const source = markup ? stripMarkup(raw) : raw;
622
+ const cleaned = stripInvisibles(source, { normalizeSpaces });
623
+ const audit = auditSlop(cleaned.text, { code });
624
+ // A ban list cannot see the fault that produced the 9 Sep 2026 Shorts batch: every script passed the words and still read as somebody else,
625
+
626
+ // because the register was written and the delivery was spoken. `register.js` carries why.
627
+ const registerAudit = register ? auditRegister(cleaned.text, register) : null;
628
+ // A Short also owes a hook from the library and a counted, reply-ending skeleton.
629
+ const shortsAudit = register === "shorts" ? auditShorts(cleaned.text) : null;
630
+ const findings = [
631
+ ...audit.findings,
632
+ ...(registerAudit?.findings ?? []),
633
+ ...(shortsAudit?.findings ?? []),
634
+ ];
635
+ return {
636
+ text: cleaned.text,
637
+ removed: cleaned.removed,
638
+ ok: findings.length === 0,
639
+ ...audit,
640
+ findings,
641
+ register: registerAudit,
642
+ shorts: shortsAudit,
643
+ };
644
+ }