@mslmio/eslint-config 2.2.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/plugin.js +3 -1
- package/rules/no-invisible-characters.js +95 -0
- package/rules/us-spelling.js +12 -0
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ UI half behind `/ui` means the root entry resolves neither `@shadcn/lint` nor
|
|
|
26
26
|
|
|
27
27
|
| Preset | For | Adds |
|
|
28
28
|
|---|---|---|
|
|
29
|
-
| `house` | anything TS/JS | `no-property-shorthand`, `curly: all`, `us-spelling`, `no-express-json-spaces` |
|
|
29
|
+
| `house` | anything TS/JS | `no-property-shorthand`, `curly: all`, `us-spelling`, `no-express-json-spaces`, `no-invisible-characters` |
|
|
30
30
|
| `lib` | a published library | house + `no-lib-process-env`, `no-module-scope-init` |
|
|
31
31
|
| `ui` | anything rendering Tailwind | house + `require-leading-with-text-size`, `prefer-copy-icon`, `prefer-table-facets` |
|
|
32
32
|
| `uiLib` | a published UI library | `lib` + `ui` |
|
|
@@ -44,6 +44,7 @@ UI half behind `/ui` means the root entry resolves neither `@shadcn/lint` nor
|
|
|
44
44
|
| `us-spelling` | US spelling in prose and identifiers |
|
|
45
45
|
| `require-leading-with-text-size` | An arbitrary Tailwind text size must state its own line height |
|
|
46
46
|
| `prefer-table-facets` | A `kind: "select"` filter lists the values its rows hold (facets), never a literal `options` list |
|
|
47
|
+
| `no-invisible-characters` | No bidi control character anywhere, strings and comments included (Trojan Source), and no zero-width space, joiner or direction mark outside text: write one as an escape |
|
|
47
48
|
|
|
48
49
|
`us-spelling` skips regex literals — a pattern matching scraped text has to accept both
|
|
49
50
|
spellings — and takes `allow` for proper names that keep their own:
|
package/index.js
CHANGED
package/package.json
CHANGED
package/plugin.js
CHANGED
|
@@ -7,9 +7,10 @@ import requireLeadingWithTextSize from "./rules/require-leading-with-text-size.j
|
|
|
7
7
|
import noPropertyShorthand from "./rules/no-property-shorthand.js";
|
|
8
8
|
import noUpwardImport from "./rules/no-upward-import.js";
|
|
9
9
|
import preferTableFacets from "./rules/prefer-table-facets.js";
|
|
10
|
+
import noInvisibleCharacters from "./rules/no-invisible-characters.js";
|
|
10
11
|
|
|
11
12
|
export default {
|
|
12
|
-
meta: { name: "@mslmio/eslint-plugin", version: "2.
|
|
13
|
+
meta: { name: "@mslmio/eslint-plugin", version: "2.4.0" },
|
|
13
14
|
rules: {
|
|
14
15
|
"no-lib-process-env": noLibProcessEnv,
|
|
15
16
|
"no-module-scope-init": noModuleScopeInit,
|
|
@@ -20,5 +21,6 @@ export default {
|
|
|
20
21
|
"no-property-shorthand": noPropertyShorthand,
|
|
21
22
|
"no-upward-import": noUpwardImport,
|
|
22
23
|
"prefer-table-facets": preferTableFacets,
|
|
24
|
+
"no-invisible-characters": noInvisibleCharacters,
|
|
23
25
|
},
|
|
24
26
|
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Characters that print as nothing, so source reads differently from how it runs.
|
|
2
|
+
//
|
|
3
|
+
// The explicit bidi controls reorder what a reader sees (Trojan Source, CVE-2021-42574),
|
|
4
|
+
// inside a string or a comment as much as in code, so they are refused everywhere; so is a
|
|
5
|
+
// U+FEFF past the start of a file. The zero-width space, joiners and marks are text: a joiner
|
|
6
|
+
// sits in a Persian word or an emoji sequence, a mark in Arabic. In code they are the same
|
|
7
|
+
// name spelled two ways, so only there are they refused.
|
|
8
|
+
const ANYWHERE = new Map([
|
|
9
|
+
[0x202a, "LEFT-TO-RIGHT EMBEDDING"],
|
|
10
|
+
[0x202b, "RIGHT-TO-LEFT EMBEDDING"],
|
|
11
|
+
[0x202c, "POP DIRECTIONAL FORMATTING"],
|
|
12
|
+
[0x202d, "LEFT-TO-RIGHT OVERRIDE"],
|
|
13
|
+
[0x202e, "RIGHT-TO-LEFT OVERRIDE"],
|
|
14
|
+
[0x2066, "LEFT-TO-RIGHT ISOLATE"],
|
|
15
|
+
[0x2067, "RIGHT-TO-LEFT ISOLATE"],
|
|
16
|
+
[0x2068, "FIRST STRONG ISOLATE"],
|
|
17
|
+
[0x2069, "POP DIRECTIONAL ISOLATE"],
|
|
18
|
+
[0xfeff, "ZERO WIDTH NO-BREAK SPACE"],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const IN_CODE = new Map([
|
|
22
|
+
[0x200b, "ZERO WIDTH SPACE"],
|
|
23
|
+
[0x200c, "ZERO WIDTH NON-JOINER"],
|
|
24
|
+
[0x200d, "ZERO WIDTH JOINER"],
|
|
25
|
+
[0x200e, "LEFT-TO-RIGHT MARK"],
|
|
26
|
+
[0x200f, "RIGHT-TO-LEFT MARK"],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const PATTERN = /[\u{200B}-\u{200F}\u{202A}-\u{202E}\u{2066}-\u{2069}\u{FEFF}]/gu;
|
|
30
|
+
|
|
31
|
+
// What a reader takes as text rather than code: where a joiner or a mark belongs.
|
|
32
|
+
const TEXT_TOKENS = new Set(["String", "Template", "RegularExpression", "JSXText"]);
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "problem",
|
|
37
|
+
docs: {
|
|
38
|
+
description: "No invisible bidi or zero-width characters, which make source read differently from how it runs",
|
|
39
|
+
},
|
|
40
|
+
schema: [
|
|
41
|
+
{
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: { docsUrl: { type: "string" } },
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
messages: {
|
|
48
|
+
invisible:
|
|
49
|
+
"{{codePoint}} ({{name}}) prints as nothing, so this line reads differently from how it " +
|
|
50
|
+
"runs. Write it as an escape in a string, or remove it.{{docs}}",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
create(context) {
|
|
54
|
+
const docs = docsSuffix(context);
|
|
55
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
56
|
+
return {
|
|
57
|
+
Program() {
|
|
58
|
+
let text = null;
|
|
59
|
+
for (const m of sourceCode.text.matchAll(PATTERN)) {
|
|
60
|
+
const codePoint = m[0].codePointAt(0);
|
|
61
|
+
let name = ANYWHERE.get(codePoint);
|
|
62
|
+
if (name === undefined) {
|
|
63
|
+
text ??= textRanges(sourceCode);
|
|
64
|
+
if (text.some(([start, end]) => m.index >= start && m.index < end)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
name = IN_CODE.get(codePoint);
|
|
68
|
+
}
|
|
69
|
+
context.report({
|
|
70
|
+
loc: {
|
|
71
|
+
start: sourceCode.getLocFromIndex(m.index),
|
|
72
|
+
end: sourceCode.getLocFromIndex(m.index + m[0].length),
|
|
73
|
+
},
|
|
74
|
+
messageId: "invisible",
|
|
75
|
+
data: {
|
|
76
|
+
codePoint: `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`,
|
|
77
|
+
name: name,
|
|
78
|
+
docs: docs,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function textRanges(sourceCode) {
|
|
88
|
+
const tokens = (sourceCode.ast.tokens ?? []).filter((t) => TEXT_TOKENS.has(t.type));
|
|
89
|
+
return [...tokens, ...sourceCode.getAllComments()].map((t) => t.range);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function docsSuffix(context) {
|
|
93
|
+
const url = context.options[0]?.docsUrl;
|
|
94
|
+
return url ? ` See ${url}.` : "";
|
|
95
|
+
}
|
package/rules/us-spelling.js
CHANGED
|
@@ -11,6 +11,9 @@ const ISE_VERBS = {
|
|
|
11
11
|
utilise: "utilize", visualise: "visualize",
|
|
12
12
|
};
|
|
13
13
|
const ISE_ENDINGS = ["e", "ed", "es", "ing", "ation", "ations", "er", "ers", "able"];
|
|
14
|
+
// A prefix an -ise verb takes as one word, which the fix keeps. A closed list, unlike
|
|
15
|
+
// COMPOUND_STEMS's open one, which would also match the tail of a snake_case value.
|
|
16
|
+
const ISE_PREFIXES = ["un", "re", "de", "dis", "pre", "over", "under", "mis"];
|
|
14
17
|
|
|
15
18
|
const WORDS = {
|
|
16
19
|
metre: "meter", metres: "meters", litre: "liter", litres: "liters", fibre: "fiber", fibres: "fibers",
|
|
@@ -73,6 +76,11 @@ const WORDS = {
|
|
|
73
76
|
|
|
74
77
|
const WORD_RE = new RegExp(`\\b(${Object.keys(WORDS).join("|")})\\b`, "gi");
|
|
75
78
|
const COMPOUND_RE = new RegExp(`\\b(\\w*?)(${Object.keys(COMPOUND_STEMS).join("|")})\\b`, "gi");
|
|
79
|
+
const ISE_STEMS = Object.keys(ISE_VERBS).map((gb) => gb.slice(0, -1));
|
|
80
|
+
const PREFIXED_ISE_RE = new RegExp(
|
|
81
|
+
`\\b(${ISE_PREFIXES.join("|")})(${ISE_STEMS.join("|")})(${ISE_ENDINGS.join("|")})\\b`,
|
|
82
|
+
"gi",
|
|
83
|
+
);
|
|
76
84
|
|
|
77
85
|
export default {
|
|
78
86
|
meta: {
|
|
@@ -154,6 +162,10 @@ function findAll(text) {
|
|
|
154
162
|
const stem = COMPOUND_STEMS[m[2].toLowerCase()];
|
|
155
163
|
hits.push([m[0], m[1] + matchCase(m[2], stem)]);
|
|
156
164
|
}
|
|
165
|
+
for (const m of text.matchAll(PREFIXED_ISE_RE)) {
|
|
166
|
+
const stem = ISE_VERBS[`${m[2].toLowerCase()}e`].slice(0, -1);
|
|
167
|
+
hits.push([m[0], matchCase(m[0], `${m[1]}${stem}${m[3]}`.toLowerCase())]);
|
|
168
|
+
}
|
|
157
169
|
return hits;
|
|
158
170
|
}
|
|
159
171
|
|