@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.
- package/bin/heyramzi-slop.js +1 -1
- package/package.json +2 -1
- package/rules/no-em-dash.js +12 -8
- package/rules/no-hardcoded-models.js +1 -1
- package/rules/no-overkill-fallback.js +1 -1
- package/slop-views.js +122 -0
- package/slop.js +10 -120
- package/ui-rules.js +1 -1
package/bin/heyramzi-slop.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/lint",
|
|
3
|
-
"version": "1.32.
|
|
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",
|
package/rules/no-em-dash.js
CHANGED
|
@@ -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:
|
|
12
|
-
{ regex: /\u2013/, name:
|
|
13
|
-
{ regex: SPACED_HYPHEN, name:
|
|
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(
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 `<!--
|
|
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(?=[
|
|
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
|
}
|