@heyramzi/lint 1.32.2 → 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.
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /*
15
- * cat draft.txt | heyramzi-slop - stdin in, cleaned text on stdout
15
+ * cat draft.txt | heyramzi-slop "-" stdin in, cleaned text on stdout
16
16
  */
17
17
 
18
18
  /*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/lint",
3
- "version": "1.32.2",
3
+ "version": "1.32.4",
4
4
  "description": "Shared oxlint rules, base config and gates for Studio projects",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,6 +37,7 @@
37
37
  "push-diff.js",
38
38
  "slop.d.ts",
39
39
  "slop.js",
40
+ "slop-views.js",
40
41
  "ui-rules.js",
41
42
  "svelte-markup.js",
42
43
  "svelte-template.js",
@@ -7,10 +7,14 @@ import { isTestFile } from "#lint/rules/test-file.js";
7
7
 
8
8
  const SPACED_HYPHEN = / - /;
9
9
 
10
+ const EM_DASH = String.fromCharCode(0x2014);
11
+ const EN_DASH = String.fromCharCode(0x2013);
12
+ const SPACED_HYPHEN_TEXT = String.fromCharCode(0x20, 0x2d, 0x20);
13
+
10
14
  const PATTERNS = [
11
- { regex: /\u2014/, name: "em dash (\u2014)" },
12
- { regex: /\u2013/, name: "en dash (\u2013)" },
13
- { regex: SPACED_HYPHEN, name: "spaced hyphen ( - ) used as dash" },
15
+ { regex: /\u2014/, name: `em dash (${EM_DASH})` },
16
+ { regex: /\u2013/, name: `en dash (${EN_DASH})` },
17
+ { regex: SPACED_HYPHEN, name: `spaced hyphen (${SPACED_HYPHEN_TEXT}) used as dash` },
14
18
  ];
15
19
 
16
20
  /**
@@ -18,7 +22,7 @@ const PATTERNS = [
18
22
  */
19
23
 
20
24
  /*
21
- * `calc((100% - var(--gap)) / 2)` needs the spaces around its operator, the spec requires them, so a `<style>` block reported a spaced hyphen on every Svelte component doing arithmetic and
25
+ * `calc()` arithmetic needs spaces around its minus operator, the spec requires them, so a `<style>` block reported a spaced hyphen on every Svelte component doing arithmetic and
22
26
  */
23
27
 
24
28
  /*
@@ -33,7 +37,7 @@ function isInsideStyleBlock(node) {
33
37
  }
34
38
 
35
39
  /**
36
- * SQL is not prose either, and for the same reason CSS is not: `now() - make_interval(...)` and `extract(epoch from a - lag(b))` need the spaces around the operator,
40
+ * SQL is not prose either, and for the same reason CSS is not: date subtraction in a query needs spaces around its minus operator,
37
41
  */
38
42
 
39
43
  /*
@@ -59,7 +63,7 @@ function taggedWithCode(node) {
59
63
  }
60
64
 
61
65
  /**
62
- * A markdown bullet is not punctuation. ` - item` at the head of a comment or template line is a list, and rewording it deletes the list.
66
+ * A markdown bullet is not punctuation. a hyphen then a space at the head of a comment or template line is a list, and rewording it deletes the list.
63
67
  */
64
68
 
65
69
  /*
@@ -175,7 +179,7 @@ export default {
175
179
  JSONLiteral(node) {
176
180
  checkString(node.value, node);
177
181
  },
178
- // Svelte template body text — e.g. <p>Some prose with a dash</p>.
182
+ // Svelte template body text, e.g. <p>Some prose with a dash</p>.
179
183
  // Without this, em/en dashes inside .svelte HTML text slip through
180
184
  // because the rule otherwise only inspects JS string nodes.
181
185
  SvelteText(node) {
@@ -183,7 +187,7 @@ export default {
183
187
  if (PLACEHOLDER.test(node.value.trim())) return;
184
188
  checkValue(node.value, node);
185
189
  },
186
- // Svelte attribute literal values — e.g. title="Heads up important".
190
+ // Svelte attribute literal values, e.g. title="Heads up important".
187
191
  SvelteLiteral(node) {
188
192
  if (isInsideStyleBlock(node)) return;
189
193
  if (PLACEHOLDER.test(String(node.value).trim())) return;
@@ -14,7 +14,7 @@ const MODEL_PATTERNS = [
14
14
 
15
15
  const COMBINED = new RegExp(MODEL_PATTERNS.map((r) => r.source).join("|"));
16
16
 
17
- // WHY: files inside @heyramzi/ai define the canonical model catalog — they must use literal IDs.
17
+ // WHY: files inside @heyramzi/ai define the canonical model catalog, so they must use literal IDs.
18
18
  function isModelCatalogFile(filename) {
19
19
  return /packages\/ai\//.test(filename);
20
20
  }
@@ -24,7 +24,7 @@ export default {
24
24
  LogicalExpression(node) {
25
25
  if (node.operator !== "??") return;
26
26
 
27
- // Rule 1: Triple ?? chain — a ?? b ?? c
27
+ // Rule 1: Triple ?? chain, a ?? b ?? c
28
28
  // The outermost ?? has left side that is also a ??
29
29
  if (node.left.type === "LogicalExpression" && node.left.operator === "??") {
30
30
  context.report({ node, messageId: "tripleChain" });
package/slop-views.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The views of a draft the audit judges: code, comments, strings and FAQ sections cut away, and the names its prose keeps.
3
+ */
4
+
5
+ /**
6
+ * Code is not copy.
7
+ */
8
+
9
+ /*
10
+ * `#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
11
+ */
12
+
13
+ /*
14
+ * 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.
15
+ */
16
+ export function withoutCode(text) {
17
+ return (
18
+ text
19
+ .replace(/```[\s\S]*?```/g, " ")
20
+ .replace(/`[^`\n]*`/g, " ")
21
+ // A tool directive is syntax the file has to carry: `/* eslint-enable ... */`
22
+ // failed the banned word `enable` in 2 files that contain no copy at all.
23
+ .replace(
24
+ /\/[*/][ \t]*(?:eslint|oxlint|prettier|biome|c8|v8|ts-(?:ignore|expect-error|nocheck))[^\n]*/g,
25
+ " ",
26
+ )
27
+ );
28
+ }
29
+
30
+ /** A source file with its comments blanked. Comments are notes to the next developer, not copy; `comment-length` gates them. */
31
+ export function withoutComments(text) {
32
+ return text
33
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
34
+ .replace(/(^|[\s;{}(,])\/\/[^\n]*/g, "$1");
35
+ }
36
+
37
+ /**
38
+ * The copy inside a source file: string literals of 2+ words, comments gone. The ledger read `get
39
+ * ours()` and `winner: "ours"` as first person: 24 false hits.
40
+ */
41
+ export function copyStrings(text) {
42
+ const literals =
43
+ withoutComments(text).match(/"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`|'(?:[^'\\\n]|\\.)*'/g) ?? [];
44
+ return literals.filter((s) => /\p{L}{2,}\s+\p{L}{2,}/u.test(s)).join("\n");
45
+ }
46
+
47
+ /**
48
+ * A string literal's first sentence is sentence-initial, and every anchored pattern used to miss it.
49
+ */
50
+
51
+ /*
52
+ * The anchors in `patterns` accept `^`, a newline, or `.!?` plus a space.
53
+ */
54
+
55
+ /*
56
+ * 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
57
+ */
58
+
59
+ /*
60
+ * 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
61
+ */
62
+
63
+ /*
64
+ * 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.
65
+ */
66
+
67
+ /*
68
+ * 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.
69
+ */
70
+ export function withStringStarts(text) {
71
+ return text.replace(/([:(,=[]\s*)["'`]/g, "$1\n");
72
+ }
73
+
74
+ /**
75
+ * An FAQ is the one section where a question heading is the right heading: it is written to match the query the reader typed,
76
+ */
77
+
78
+ /*
79
+ * 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.
80
+ */
81
+ export const FAQ_TITLE =
82
+ /^(?:FAQ|F\.A\.Q\.?|Frequently asked questions|よくある質問|Questions? fréquentes?)$/i;
83
+
84
+ export function withoutFaqSections(text) {
85
+ let depth = 0;
86
+ return text
87
+ .split("\n")
88
+ .map((line) => {
89
+ const heading = /^(#{1,6})[ \t]+(.+?)[ \t]*#*$/.exec(line);
90
+ if (heading) {
91
+ const level = heading[1].length;
92
+ if (depth && level <= depth) depth = 0;
93
+ if (!depth && FAQ_TITLE.test(heading[2].trim())) depth = level;
94
+ }
95
+ return depth ? "" : line;
96
+ })
97
+ .join("\n");
98
+ }
99
+
100
+ /**
101
+ * The words the body capitalises mid-sentence, which are names rather than styling.
102
+ */
103
+
104
+ /*
105
+ * 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.
106
+ */
107
+
108
+ /*
109
+ * Headings are excluded from the scan so a title-cased heading cannot vouch for itself.
110
+ */
111
+ export function properNouns(body) {
112
+ const found = new Set();
113
+ for (const line of body.replace(/^#{1,6}[ \t]+.*$/gm, " ").split("\n")) {
114
+ let start = true;
115
+ for (const raw of line.trim().split(/\s+/)) {
116
+ const word = raw.replace(/^[^\p{L}]+/u, "").replace(/[^\p{L}\p{N}'’-]+$/u, "");
117
+ if (word && !start && /^[A-ZÀ-Þ]/.test(word)) found.add(word);
118
+ start = /[.!?:][""''”’)\]]?$/.test(raw);
119
+ }
120
+ }
121
+ return found;
122
+ }
package/slop.js CHANGED
@@ -48,6 +48,15 @@ import {
48
48
  } from "#lint/data/invisibles.js";
49
49
  import { artifacts, patterns, phrases, words } from "#lint/data/slop-words.js";
50
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";
51
60
  import { auditShorts } from "@heyramzi/social/shorts";
52
61
 
53
62
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -79,7 +88,7 @@ export function stripMarkup(raw) {
79
88
  .replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
80
89
  // A markup comment is a note to the next developer, not copy.
81
90
 
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.
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.
83
92
  .replace(/<!--[\s\S]*?-->/g, " ")
84
93
  .replace(/&[a-z]+;|&#\d+;/gi, " ")
85
94
  .replace(/<[^>]+>/g, " ")
@@ -213,125 +222,6 @@ const EMOJI =
213
222
  */
214
223
  const HASHTAG = /(?<=[^\n#]\s|^\s*\S[^\n]*?\s)#(?![0-9a-fA-F]{3,8}\b)(?!\d+\b)[A-Za-z][\w-]*/g;
215
224
 
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
225
  /**
336
226
  * Which language the draft is in, so the checks that only work in one of them pick the right list.
337
227
  */
package/ui-rules.js CHANGED
@@ -158,7 +158,7 @@ function cssRules(source, file) {
158
158
  hits.push({ rule: "raw-hover", line, snippet: rule.selector.slice(0, 80) });
159
159
  }
160
160
  const selectors = rule.selector.split(",").map((s) => s.trim());
161
- const own = selectors.some((s) => /(^|[\s>+~])\.t-acc-panel$/.test(s) || /(^|[\s>+~])\.t-acc-panel(?=[:.\[])/.test(s));
161
+ const own = selectors.some((s) => /(^|[\s>+~])\.t-acc-panel$/.test(s) || /(^|[\s>+~])\.t-acc-panel(?=[:.[])/.test(s));
162
162
  if (own && /(?<![\w-])padding(?:-[a-z]+)*\s*:/.test(rule.body) && !selectors.some((s) => /t-acc-panel-/.test(s))) {
163
163
  hits.push({ rule: "acc-panel-padding", line, snippet: `${rule.selector} { padding }` });
164
164
  }