@heyramzi/lint 0.0.0-stage → 1.32.4

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 (92) 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 +81 -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 +198 -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-views.js +122 -0
  88. package/slop.d.ts +53 -0
  89. package/slop.js +534 -0
  90. package/svelte-markup.js +107 -0
  91. package/svelte-template.js +258 -0
  92. package/ui-rules.js +324 -0
package/slop.js ADDED
@@ -0,0 +1,534 @@
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 {
52
+ FAQ_TITLE,
53
+ copyStrings,
54
+ properNouns,
55
+ withStringStarts,
56
+ withoutCode,
57
+ withoutComments,
58
+ withoutFaqSections,
59
+ } from "#lint/slop-views.js";
60
+ import { auditShorts } from "@heyramzi/social/shorts";
61
+
62
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+
64
+ /**
65
+ * A word boundary that counts an accented letter as a letter.
66
+ */
67
+
68
+ /*
69
+ * `\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
70
+ */
71
+
72
+ /*
73
+ * and unenforced. The lookarounds do the same job over `\p{L}`, in both languages.
74
+ */
75
+ const bounded = (word) =>
76
+ new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRe(word)}(?![\\p{L}\\p{N}_])`, "iu");
77
+
78
+ /**
79
+ * HTML in, prose out, so an email body and a markdown draft audit the same way.
80
+ */
81
+
82
+ /*
83
+ * 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.
84
+ */
85
+ export function stripMarkup(raw) {
86
+ return (
87
+ raw
88
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
89
+ // A markup comment is a note to the next developer, not copy.
90
+
91
+ // It goes with the script block, for the same reason: the CBAM pages rule their sections with `<!-- name -->` wrapped in rows of em dashes, which reported 8 dashes nobody reads.
92
+ .replace(/<!--[\s\S]*?-->/g, " ")
93
+ .replace(/&[a-z]+;|&#\d+;/gi, " ")
94
+ .replace(/<[^>]+>/g, " ")
95
+ // Svelte and Vue control blocks are code that survives tag stripping, and their loop variables read as prose: `{#each items as row,
96
+
97
+ // i (row)}` counted `i` as a first-person pronoun and failed 3 pages on the pronoun ledger.
98
+
99
+ // Only `{#`, `{:` and `{/` go; a plain `{value}` interpolation is left alone, because the copy it stands for is audited where it is written.
100
+
101
+ // `{@render ...}` is a call, so it goes.
102
+ .replace(/\{[#:/@][^}]*\}/g, " ")
103
+ .replace(/[ \t]+/g, " ")
104
+ .trim()
105
+ );
106
+ }
107
+
108
+ const isPictographic = (ch) => /\p{Extended_Pictographic}/u.test(ch);
109
+
110
+ /**
111
+ * Remove every invisible character, normalise the fake spaces, and report what went.
112
+ */
113
+
114
+ /*
115
+ * The one exception is emoji glue.
116
+ */
117
+
118
+ /*
119
+ * 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
120
+ */
121
+
122
+ /*
123
+ * its colour. Anywhere else the same character is a carrier and goes.
124
+ */
125
+
126
+ /*
127
+ * `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,
128
+ */
129
+
130
+ /*
131
+ * because a no-break space between two words is invisible to the writer and breaks wrapping for the reader.
132
+ */
133
+ export function stripInvisibles(text, { normalizeSpaces = true } = {}) {
134
+ const chars = [...text];
135
+ const removed = new Map();
136
+ const note = (cp) => {
137
+ const key = `${invisibleKind(cp)} ${hex(cp)}`;
138
+ removed.set(key, (removed.get(key) ?? 0) + 1);
139
+ };
140
+
141
+ let out = "";
142
+ for (let i = 0; i < chars.length; i += 1) {
143
+ const ch = chars[i];
144
+ const cp = ch.codePointAt(0);
145
+
146
+ if (EMOJI_GLUE.has(cp) && i > 0 && isPictographic(chars[i - 1])) {
147
+ out += ch;
148
+ continue;
149
+ }
150
+ if (isInvisible(cp)) {
151
+ note(cp);
152
+ continue;
153
+ }
154
+ if (SEPARATORS.has(cp)) {
155
+ note(cp);
156
+ out += SEPARATORS.get(cp);
157
+ continue;
158
+ }
159
+ if (normalizeSpaces && SPACE_HOMOGLYPHS.has(cp)) {
160
+ note(cp);
161
+ out += SPACE_HOMOGLYPHS.get(cp);
162
+ continue;
163
+ }
164
+ out += ch;
165
+ }
166
+
167
+ return {
168
+ text: out,
169
+ removed: [...removed].map(([what, count]) => ({ what, count })),
170
+ };
171
+ }
172
+
173
+ /** True when the text carries nothing invisible. The cheap gate. */
174
+ export function isClean(text) {
175
+ return stripInvisibles(text).removed.length === 0;
176
+ }
177
+
178
+ /**
179
+ * A regex over these fires on ordinary English, so they are reported as `review` rather than as a hit and never fail the gate.
180
+ */
181
+
182
+ /*
183
+ * A person decides whether the sentence defines a thing by denying another, or states a fact.
184
+ */
185
+ const PIVOT_GREPS = [
186
+ /rather than/gi,
187
+ /instead of/gi,
188
+ /\bnot a\b/gi,
189
+ /\bnot the\b/gi,
190
+ /\bis not\b/gi,
191
+ /plutôt que/gi,
192
+ /au lieu de/gi,
193
+ /n'est pas un/gi,
194
+ ];
195
+
196
+ /**
197
+ * "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
198
+ */
199
+
200
+ /*
201
+ * 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.
202
+ */
203
+
204
+ /*
205
+ * 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.
206
+ */
207
+ // Combining marks are what this class is for.
208
+ const EMOJI =
209
+ // oxlint-disable-next-line eslint/no-misleading-character-class
210
+ /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1FA00}-\u{1FA9F}\u{20E3}]/gu;
211
+
212
+ /**
213
+ * A hashtag is a `#` glued to a word mid-line.
214
+ */
215
+
216
+ /*
217
+ * 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;
218
+ */
219
+
220
+ /*
221
+ * a hex colour, which is `#` and hex digits only; and an issue reference, which is `#` and digits.
222
+ */
223
+ const HASHTAG = /(?<=[^\n#]\s|^\s*\S[^\n]*?\s)#(?![0-9a-fA-F]{3,8}\b)(?!\d+\b)[A-Za-z][\w-]*/g;
224
+
225
+ /**
226
+ * Which language the draft is in, so the checks that only work in one of them pick the right list.
227
+ */
228
+
229
+ /*
230
+ * Stopword counting, not a library.
231
+ */
232
+
233
+ /*
234
+ * 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
235
+ */
236
+
237
+ /*
238
+ * the pronoun ledger, which reports rather than rewrites. English is the default on a tie, including on an empty string.
239
+ */
240
+ const FR_STOPWORDS =
241
+ /(?<![\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;
242
+ const EN_STOPWORDS =
243
+ /(?<![\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;
244
+
245
+ export function detectLanguage(text) {
246
+ // Japanese first, and by script rather than by stopword.
247
+
248
+ // 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
249
+
250
+ // back "en" and was then graded on English pronouns it does not use.
251
+ const cjk = (text.match(/[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g) ?? []).length;
252
+ const letters = (text.match(/[\p{L}]/gu) ?? []).length;
253
+ if (letters > 0 && cjk / letters > 0.2) return "ja";
254
+ const fr = (text.match(FR_STOPWORDS) ?? []).length;
255
+ const en = (text.match(EN_STOPWORDS) ?? []).length;
256
+ return fr > en ? "fr" : "en";
257
+ }
258
+
259
+ /**
260
+ * The first person against the second, per language.
261
+ */
262
+
263
+ /*
264
+ * 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.
265
+ */
266
+
267
+ /*
268
+ * 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,
269
+ */
270
+
271
+ /*
272
+ * not a page about its author.
273
+ */
274
+ const PRONOUNS = {
275
+ en: {
276
+ // Case-sensitive: a lone `i` is a loop index, `US` a country, and `Annex I` a numeral.
277
+ first:
278
+ /(?<![\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,
279
+ second: /(?<![\p{L}\p{N}_])(?:you|your|yours)(?![\p{L}\p{N}_])/giu,
280
+ },
281
+ fr: {
282
+ first: /(?<![\p{L}\p{N}_])(?:je|j'|nous|mon|ma|mes|notre|nos|moi|me)(?![\p{L}\p{N}_])/giu,
283
+ second: /(?<![\p{L}\p{N}_])(?:vous|votre|vos|tu|ton|ta|tes|toi)(?![\p{L}\p{N}_])/giu,
284
+ },
285
+ };
286
+
287
+ /**
288
+ * The signs that are only visible across a whole document, so no regex over one sentence finds them: Wikipedia's title case, heading levels,
289
+ */
290
+
291
+ /*
292
+ * boldface density, thematic breaks and mixed apostrophes (WP:AITITLECASE, WP:AIBOLD, WP:AICURLY), and the French page's "majuscules superflues dans les titres".
293
+ */
294
+
295
+ /*
296
+ * 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.
297
+ */
298
+ function auditStructure(text) {
299
+ const found = [];
300
+ const hit = (id, detail) => found.push({ kind: "structure", id, detail });
301
+
302
+ // Frontmatter is not a thematic break and its keys are not headings.
303
+ const body = text.replace(/^---\n[\s\S]*?\n---\n/, "\n");
304
+ const headings = [...body.matchAll(/^(#{1,6})[ \t]+(.+?)[ \t]*#*$/gm)].map((m) => ({
305
+ level: m[1].length,
306
+ title: m[2].trim(),
307
+ at: m.index,
308
+ }));
309
+
310
+ // 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,
311
+
312
+ // and 2 of them capitalised is the threshold: "Awards and Recognition" hits, "the ClickUp API" does not.
313
+ const names = properNouns(body);
314
+ for (const heading of headings) {
315
+ const rest = heading.title
316
+ .split(/\s+/)
317
+ .slice(1)
318
+ .filter((w) => w.replace(/\W/g, "").length >= 4);
319
+ const capitalised = rest.filter(
320
+ (w) => /^[A-ZÀ-Þ]/.test(w) && !names.has(w.replace(/[^\p{L}\p{N}'’-]+$/u, "")),
321
+ );
322
+ if (rest.length >= 2 && capitalised.length >= 2) hit("title-case-heading", heading.title);
323
+ }
324
+
325
+ const h1 = headings.filter((h) => h.level === 1);
326
+ if (h1.length > 1) hit("multiple-h1", `${h1.length} level-1 headings`);
327
+
328
+ for (let i = 1; i < headings.length; i += 1) {
329
+ if (headings[i].level - headings[i - 1].level > 1) {
330
+ hit(
331
+ "skipped-heading-level",
332
+ `${"#".repeat(headings[i - 1].level)} then ${"#".repeat(headings[i].level)} at "${headings[i].title}"`,
333
+ );
334
+ }
335
+ // A heading whose whole content is the next heading. Wikipedia's "headings
336
+ // only containing other headings": the section says nothing of its own.
337
+ const between = body
338
+ .slice(headings[i - 1].at, headings[i].at)
339
+ .split("\n")
340
+ .slice(1)
341
+ .join("")
342
+ .trim();
343
+ if (between === "" && !FAQ_TITLE.test(headings[i - 1].title)) {
344
+ hit("empty-section", headings[i - 1].title);
345
+ }
346
+ }
347
+
348
+ const bold = [...withoutCode(body).matchAll(/\*\*[^*\n]{2,}\*\*/g)];
349
+ // 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
350
+
351
+ // boldface density. A CJK character counts as a word, which is how the languages are counted everywhere else.
352
+ const cjk = (body.match(/[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g) ?? []).length;
353
+ const wordCount = body.split(/\s+/).filter(Boolean).length + cjk;
354
+ if (bold.length > 3 && bold.length > wordCount / 120) {
355
+ hit("boldface-overuse", `${bold.length} bold runs in ${wordCount} words`);
356
+ }
357
+
358
+ const breaks = [...body.matchAll(/^[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$/gm)];
359
+ if (breaks.length > 1) hit("thematic-breaks", `${breaks.length} horizontal rules`);
360
+
361
+ for (const m of body.matchAll(/^[ \t]*[•▪●–—][ \t]+\S/gm)) {
362
+ hit("raw-bullet-glyph", m[0].trim());
363
+ }
364
+
365
+ return found;
366
+ }
367
+
368
+ /**
369
+ * The enumerable half of the humanizer, over one string.
370
+ */
371
+
372
+ /*
373
+ * Six layers: the banned words, the banned phrases, the anchored sentence `patterns`, the chatbot `artifacts`, the document-level structure, and the pronoun ledger.
374
+ */
375
+
376
+ /*
377
+ * Every sign on Wikipedia:Signs of AI writing that a machine can decide is in one of those; the French page adds no seventh.
378
+ */
379
+
380
+ /*
381
+ * 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".
382
+ */
383
+
384
+ /*
385
+ * Those still need a human read of the humanizer's `references/ patterns.md`, and this function's output says so.
386
+ */
387
+
388
+ /*
389
+ * So does everything the page lists that lives outside the text: a dead citation URL, an invalid ISBN, a hallucinated template, an edit summary.
390
+ */
391
+
392
+ /*
393
+ * Those are claims about the world, and a string is all this function gets.
394
+ */
395
+ export function auditSlop(draft, { code = false } = {}) {
396
+ const text = code ? withoutComments(draft) : draft;
397
+ const findings = [];
398
+ const add = (kind, id, detail) => findings.push({ kind, id, detail });
399
+
400
+ // 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
401
+
402
+ // name, and both failed the gate in files that carry no copy at all.
403
+
404
+ // The `patterns` below still read the whole text, where the surrounding sentence is what they are judging.
405
+ const prose = withoutCode(text);
406
+ for (const word of words) {
407
+ const m = prose.match(bounded(word));
408
+ if (m) add("word", word, m[0]);
409
+ }
410
+ for (const phrase of phrases) {
411
+ // 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.
412
+
413
+ // Every phrase here is whole words.
414
+ if (bounded(phrase).test(prose)) add("phrase", phrase, phrase);
415
+ }
416
+ // 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.
417
+
418
+ // 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
419
+
420
+ // to be added in two places.
421
+ const views = {
422
+ anchored: code ? withStringStarts(text) : text,
423
+ faqless: withoutFaqSections(text),
424
+ // In source, an import list or an object literal is a comma run too.
425
+ prose: code ? copyStrings(text) : prose,
426
+ };
427
+ for (const pattern of patterns) {
428
+ const m = views[pattern.view ?? "anchored"].match(pattern.test);
429
+ if (m) add("pattern", pattern.id, m[0].slice(0, 90));
430
+ }
431
+ for (const artifact of artifacts) {
432
+ const m = text.match(artifact.test);
433
+ if (m) add("artifact", artifact.id, m[0].slice(0, 90));
434
+ }
435
+ for (const finding of auditStructure(text)) findings.push(finding);
436
+ for (const m of text.matchAll(/[—–]/g)) {
437
+ add("dash", "em-or-en-dash", text.slice(Math.max(0, m.index - 30), m.index + 30));
438
+ }
439
+ for (const m of text.matchAll(EMOJI)) {
440
+ add("emoji", "emoji", text.slice(Math.max(0, m.index - 30), m.index + 30));
441
+ }
442
+ for (const m of withoutCode(text).matchAll(HASHTAG)) {
443
+ add("hashtag", "hashtag", m[0]);
444
+ }
445
+
446
+ const ledgerText = code ? copyStrings(text) : text;
447
+ const count = (re) => (ledgerText.match(re) ?? []).length;
448
+ const language = detectLanguage(text);
449
+ // Only the languages with pronoun sets are graded.
450
+
451
+ // 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.
452
+ const pronouns = PRONOUNS[language];
453
+ const first = pronouns ? count(pronouns.first) : 0;
454
+ const second = pronouns ? count(pronouns.second) : 0;
455
+ // Strictly outnumber, which is the rule `pronouns-and-openings.md` states. `<=` failed a draft at
456
+ // parity, and a gate stricter than its own spec teaches the reader to ignore it.
457
+ if (first > 0 && second < first)
458
+ add("ledger", "selfish-framing", `first ${first}, second ${second}`);
459
+
460
+ const review = [];
461
+ for (const grep of PIVOT_GREPS) {
462
+ for (const m of text.matchAll(grep)) {
463
+ const from = Math.max(0, m.index - 45);
464
+ review.push({
465
+ kind: "review-pivot",
466
+ id: m[0].toLowerCase(),
467
+ detail: `...${text.slice(from, m.index + 55)}...`,
468
+ });
469
+ }
470
+ }
471
+
472
+ // WP:AICURLY. Curly quotes alone prove nothing: macOS types them by default and French typography wants them.
473
+
474
+ // Mixing both forms in one draft is the sign, because that is what a paste from a chat window into typed text leaves behind.
475
+
476
+ // Reported, never failed, so a deliberate house style survives.
477
+ const curly = (text.match(/[‘’“”]/g) ?? []).length;
478
+ const straight = (text.match(/['"]/g) ?? []).length;
479
+ if (curly > 0 && straight > 0) {
480
+ review.push({
481
+ kind: "review-quotes",
482
+ id: "mixed-apostrophes",
483
+ detail: `${curly} curly, ${straight} straight`,
484
+ });
485
+ }
486
+
487
+ return {
488
+ findings,
489
+ review,
490
+ language,
491
+ ledger: { first, second },
492
+ words: text.split(/\s+/).filter(Boolean).length,
493
+ };
494
+ }
495
+
496
+ /**
497
+ * Clean, then audit. The one call a publishing path makes.
498
+ */
499
+
500
+ /*
501
+ * Returns the cleaned text whether or not the audit passed, because the two results answer different questions: the text is always safe to send,
502
+ */
503
+
504
+ /*
505
+ * 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.
506
+ */
507
+ export function slopGate(
508
+ raw,
509
+ { markup = false, code = false, normalizeSpaces = true, register } = {},
510
+ ) {
511
+ const source = markup ? stripMarkup(raw) : raw;
512
+ const cleaned = stripInvisibles(source, { normalizeSpaces });
513
+ const audit = auditSlop(cleaned.text, { code });
514
+ // 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,
515
+
516
+ // because the register was written and the delivery was spoken. `register.js` carries why.
517
+ const registerAudit = register ? auditRegister(cleaned.text, register) : null;
518
+ // A Short also owes a hook from the library and a counted, reply-ending skeleton.
519
+ const shortsAudit = register === "shorts" ? auditShorts(cleaned.text) : null;
520
+ const findings = [
521
+ ...audit.findings,
522
+ ...(registerAudit?.findings ?? []),
523
+ ...(shortsAudit?.findings ?? []),
524
+ ];
525
+ return {
526
+ text: cleaned.text,
527
+ removed: cleaned.removed,
528
+ ok: findings.length === 0,
529
+ ...audit,
530
+ findings,
531
+ register: registerAudit,
532
+ shorts: shortsAudit,
533
+ };
534
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The Svelte markup gate: the template checks oxlint can't make.
3
+ */
4
+
5
+ /*
6
+ * oxlint only visits a .svelte file that has a `<script>`, and a markup-only component (a `<slot />` wrapper) has none. Pure here, git and process in `bin/heyramzi-svelte-markup.js`.
7
+ */
8
+ import {
9
+ isRegularElement,
10
+ MATHML_ELEMENTS,
11
+ scanTemplate,
12
+ SVG_ELEMENTS,
13
+ VOID_ELEMENTS,
14
+ } from "#lint/svelte-template.js";
15
+
16
+ /** Generated or vendored trees: nobody can fix code they don't write. */
17
+ export const DEFAULT_IGNORED = [
18
+ "node_modules",
19
+ "dist",
20
+ "build",
21
+ "out",
22
+ "coverage",
23
+ "vendor",
24
+ ".upstream",
25
+ "generated",
26
+ "_generated",
27
+ ".svelte-kit",
28
+ ".wrangler",
29
+ ".agents",
30
+ ".claude",
31
+ ".opencode",
32
+ ".gemini",
33
+ "playground",
34
+ ];
35
+
36
+ /** Every violation in one .svelte source: `{ line, rule, message }`. */
37
+ export function findMarkupViolations(source) {
38
+ const { tags } = scanTemplate(source);
39
+ const customElement = tags.some(
40
+ (tag) => tag.name === "svelte:options" && tag.attrs.some((a) => a.name === "customElement"),
41
+ );
42
+ const found = [];
43
+ const add = (line, rule, message) => found.push({ line, rule, message });
44
+
45
+ for (const tag of tags) {
46
+ if (tag.closing) continue;
47
+
48
+ if (tag.name === "slot" && !customElement) {
49
+ add(
50
+ tag.line,
51
+ "svelte4-slot",
52
+ "`<slot>` is Svelte 4. Take `children` or a named snippet prop and render it with `{@render children?.()}`.",
53
+ );
54
+ }
55
+ if (tag.name === "svelte:component") {
56
+ add(
57
+ tag.line,
58
+ "svelte4-component",
59
+ "`<svelte:component>` is Svelte 4. Put the component in a variable and render `<Component />`.",
60
+ );
61
+ }
62
+ for (const attr of tag.attrs) {
63
+ if (attr.name.startsWith("on:")) {
64
+ const event = attr.name.slice(3).split("|")[0];
65
+ add(
66
+ attr.line,
67
+ "svelte4-event",
68
+ `\`${attr.name.split("|")[0]}\` is Svelte 4. Use the \`on${event}\` attribute, and a callback prop on a component.`,
69
+ );
70
+ }
71
+ if (attr.name.startsWith("class:") && /\[[^\]]+\]/.test(attr.name)) {
72
+ add(
73
+ attr.line,
74
+ "class-arbitrary-value",
75
+ `\`${attr.name}\` never applies: \`class:\` can't carry a Tailwind arbitrary value. Use \`style:property={...}\` (for example \`style:min-height={compact ? '300px' : '460px'}\`).`,
76
+ );
77
+ }
78
+ }
79
+ if (
80
+ tag.selfClosing &&
81
+ tag.name !== "slot" &&
82
+ isRegularElement(tag.name) &&
83
+ !VOID_ELEMENTS.has(tag.name) &&
84
+ !SVG_ELEMENTS.has(tag.name) &&
85
+ !MATHML_ELEMENTS.has(tag.name)
86
+ ) {
87
+ add(
88
+ tag.line,
89
+ "self-closing",
90
+ `\`<${tag.name} />\` is ambiguous HTML. Write \`<${tag.name}></${tag.name}>\`.`,
91
+ );
92
+ }
93
+ }
94
+ return found.toSorted((a, b) => a.line - b.line);
95
+ }
96
+
97
+ /** Filter tracked files down to the .svelte files the gate reads. */
98
+ export function selectFiles(tracked, { ignored = DEFAULT_IGNORED, targets = [] } = {}) {
99
+ return tracked.filter((file) => {
100
+ if (!file.endsWith(".svelte")) return false;
101
+ if (file.split("/").some((segment) => ignored.includes(segment))) return false;
102
+ if (targets.length === 0) return true;
103
+ return targets.some(
104
+ (target) => file === target || file.startsWith(`${target.replace(/\/$/, "")}/`),
105
+ );
106
+ });
107
+ }