@mslmio/eslint-config 2.3.0 → 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 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
@@ -45,6 +45,7 @@ export const house = [
45
45
  "mslmio/us-spelling": "error",
46
46
  "mslmio/no-express-json-spaces": "error",
47
47
  "mslmio/no-upward-import": "error",
48
+ "mslmio/no-invisible-characters": "error",
48
49
  },
49
50
  },
50
51
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mslmio/eslint-config",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Shareable ESLint flat config and house rules: US spelling, library hygiene, workspace layering, and Tailwind/React conventions.",
5
5
  "keywords": [
6
6
  "eslint",
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.3.0" },
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
+ }