@mslmio/eslint-config 1.0.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/LICENSE +21 -0
- package/README.md +85 -0
- package/index.js +135 -0
- package/package.json +73 -0
- package/plugin.js +22 -0
- package/rules/no-express-json-spaces.js +32 -0
- package/rules/no-lib-process-env.js +59 -0
- package/rules/no-module-scope-init.js +140 -0
- package/rules/no-property-shorthand.js +31 -0
- package/rules/no-upward-import.js +0 -0
- package/rules/prefer-copy-icon.js +42 -0
- package/rules/require-leading-with-text-size.js +115 -0
- package/rules/us-spelling.js +134 -0
- package/ui.js +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mslm Dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @mslmio/eslint-config
|
|
2
|
+
|
|
3
|
+
Shareable ESLint **flat config** plus a small plugin of house rules — the kind of convention
|
|
4
|
+
teams write down and then enforce with nothing.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pnpm add -D @mslmio/eslint-config eslint
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Use it
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
// A backend service or a non-UI library - installs no Tailwind/React tooling.
|
|
14
|
+
import { lib, typeChecked } from "@mslmio/eslint-config";
|
|
15
|
+
export default [...lib, typeChecked(import.meta.dirname)];
|
|
16
|
+
|
|
17
|
+
// Anything rendering Tailwind. Needs @shadcn/lint (and eslint-plugin-react-hooks
|
|
18
|
+
// for uiLib) installed alongside - both are OPTIONAL peers.
|
|
19
|
+
import { uiLib } from "@mslmio/eslint-config/ui";
|
|
20
|
+
export default [...uiLib];
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Two entry points on purpose.** A backend service has no Tailwind and no React, so keeping the
|
|
24
|
+
UI half behind `/ui` means the root entry resolves neither `@shadcn/lint` nor
|
|
25
|
+
`eslint-plugin-react-hooks`, nor `oxc-parser`'s native binaries.
|
|
26
|
+
|
|
27
|
+
| Preset | For | Adds |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `house` | anything TS/JS | `no-property-shorthand`, `curly: all`, `us-spelling`, `no-express-json-spaces` |
|
|
30
|
+
| `lib` | a published library | house + `no-lib-process-env`, `no-module-scope-init` |
|
|
31
|
+
| `ui` | anything rendering Tailwind | house + `require-leading-with-text-size`, `prefer-copy-icon` |
|
|
32
|
+
| `uiLib` | a published UI library | `lib` + `ui` |
|
|
33
|
+
|
|
34
|
+
## Rules
|
|
35
|
+
|
|
36
|
+
| Rule | What it says |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `no-upward-import` | A package may not import a tier above it, or a sibling leaf. Off until configured — see below |
|
|
39
|
+
| `no-property-shorthand` | Always `{ key: key }`; exempts destructuring and imports |
|
|
40
|
+
| `no-lib-process-env` | A library never reads `process.env` — configuration is passed in |
|
|
41
|
+
| `no-module-scope-init` | No client/config built at module scope; build lazily and memoise |
|
|
42
|
+
| `no-express-json-spaces` | Express's `json spaces` indents but drops the trailing newline |
|
|
43
|
+
| `prefer-copy-icon` | lucide `Copy`, not `Clipboard` |
|
|
44
|
+
| `us-spelling` | US spelling in prose and identifiers |
|
|
45
|
+
| `require-leading-with-text-size` | An arbitrary Tailwind text size must state its own line height |
|
|
46
|
+
|
|
47
|
+
`us-spelling` skips regex literals — a pattern matching scraped text has to accept both
|
|
48
|
+
spellings — and takes `allow` for proper names that keep their own:
|
|
49
|
+
`["mslmio/us-spelling", "error", { allow: ["Macquarie Data Centres"] }]`.
|
|
50
|
+
|
|
51
|
+
`no-lib-process-env` exempts `main.*` by default: a runnable entry point living in a library
|
|
52
|
+
directory IS the service. Override with `{ entryPoints: [...] }`.
|
|
53
|
+
|
|
54
|
+
Every rule that explains itself takes an optional `docsUrl`, appended to the message so you can
|
|
55
|
+
point at your own write-up: `["mslmio/no-lib-process-env", "error", { docsUrl: "https://..." }]`.
|
|
56
|
+
|
|
57
|
+
## `no-upward-import`
|
|
58
|
+
|
|
59
|
+
A workspace layered into **ordered tiers** plus any number of **leaves**. A tier may import the
|
|
60
|
+
tiers below it; a leaf may import every tier but no other leaf. Nothing reaches up.
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
["mslmio/no-upward-import", "error", {
|
|
64
|
+
tiers: ["base", "platform"], // ordered, lowest first
|
|
65
|
+
exempt: ["tools"], // outside the layering entirely
|
|
66
|
+
typeDirs: ["svc", "lib", "web"],
|
|
67
|
+
docsUrl: "https://example.com/layering",
|
|
68
|
+
}]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**It is a no-op until `tiers` is set**, which is the default. The leaves are *discovered* from the
|
|
72
|
+
workspace root — the directory holding every tier as a child — rather than listed: any other child
|
|
73
|
+
containing one of `typeDirs` is a leaf. So adding a leaf needs no config change, and no package has
|
|
74
|
+
to hold a list of its siblings.
|
|
75
|
+
|
|
76
|
+
## Ignores
|
|
77
|
+
|
|
78
|
+
`house`, and so every preset, globally ignores `.design-sync/**`, `.ds-sync/**` and
|
|
79
|
+
`ds-bundle/**`. Flat config does not read `.gitignore`, and these hold symlinks into app
|
|
80
|
+
components — one moved component leaves a dangling link and `eslint .` exits `ENOENT` instead of
|
|
81
|
+
reporting anything.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import plugin from "./plugin.js";
|
|
2
|
+
import tsParser from "@typescript-eslint/parser";
|
|
3
|
+
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
|
4
|
+
|
|
5
|
+
const TS_GLOBS = ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.js", "**/*.mjs"];
|
|
6
|
+
|
|
7
|
+
// design-sync scratch and its generated bundle. All three are gitignored, and `ds-src` holds
|
|
8
|
+
// symlinks that dangle the moment a component moves - which makes `eslint .` exit on ENOENT
|
|
9
|
+
// rather than report. Flat config does not read .gitignore, so every consumer needs this.
|
|
10
|
+
export const designSyncIgnores = {
|
|
11
|
+
ignores: [".design-sync/**", ".ds-sync/**", "ds-bundle/**"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// The @typescript-eslint rule set. Without this the parser parses TypeScript and
|
|
15
|
+
// NOTHING lints it - no unused vars, no floating promises, no unsafe any. The rules
|
|
16
|
+
// go in `house` (so every consumer gets them) while the PARSER stays out of it, since
|
|
17
|
+
// a Next app already has one and flat config is last-wins.
|
|
18
|
+
// The `plugins` key is stripped on purpose. A flat-config plugin namespace may be
|
|
19
|
+
// declared only ONCE across the whole array, and eslint-config-next already declares
|
|
20
|
+
// `@typescript-eslint`; redeclaring it fails the whole run with "Cannot redefine
|
|
21
|
+
// plugin". Rules resolve against whichever config declared the namespace - next's in
|
|
22
|
+
// an app, the `typescript` config below in a standalone package.
|
|
23
|
+
export const typescriptRules = (tsPlugin.configs["flat/recommended"] ?? []).map((c) => {
|
|
24
|
+
const { plugins: _plugins, ...rest } = c;
|
|
25
|
+
return { ...rest, files: ["**/*.ts", "**/*.tsx", "**/*.mts"] };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Scoped to TS files, not folded into `house`: `house` also matches .js/.mjs, where
|
|
29
|
+
// the @typescript-eslint namespace is not declared and naming one of its rules fails
|
|
30
|
+
// the whole run. A leading underscore is how you say "deliberately discarded".
|
|
31
|
+
typescriptRules.push({
|
|
32
|
+
files: ["**/*.ts", "**/*.tsx", "**/*.mts"],
|
|
33
|
+
rules: {
|
|
34
|
+
"@typescript-eslint/no-unused-vars": ["error", {
|
|
35
|
+
argsIgnorePattern: "^_",
|
|
36
|
+
varsIgnorePattern: "^_",
|
|
37
|
+
caughtErrorsIgnorePattern: "^_",
|
|
38
|
+
destructuredArrayIgnorePattern: "^_",
|
|
39
|
+
}],
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Every rule here encodes a documented house rule. Adding one means editing
|
|
44
|
+
export const house = [
|
|
45
|
+
designSyncIgnores,
|
|
46
|
+
...typescriptRules,
|
|
47
|
+
{
|
|
48
|
+
files: TS_GLOBS,
|
|
49
|
+
plugins: { mslmio: plugin },
|
|
50
|
+
rules: {
|
|
51
|
+
"mslmio/no-property-shorthand": "error",
|
|
52
|
+
curly: ["error", "all"],
|
|
53
|
+
"mslmio/us-spelling": "error",
|
|
54
|
+
"mslmio/no-express-json-spaces": "error",
|
|
55
|
+
"mslmio/no-upward-import": "error",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// A parser for `.ts`/`.tsx`, for a package that has no framework config supplying one.
|
|
61
|
+
// Deliberately NOT in `house`/`ui`: a Next app gets its parser from eslint-config-next,
|
|
62
|
+
// and flat config is last-wins, so adding one here would silently replace theirs.
|
|
63
|
+
export const typescript = {
|
|
64
|
+
files: ["**/*.ts", "**/*.tsx", "**/*.mts"],
|
|
65
|
+
plugins: { "@typescript-eslint": tsPlugin },
|
|
66
|
+
languageOptions: {
|
|
67
|
+
parser: tsParser,
|
|
68
|
+
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Type-aware rules, opt-in per package because they need that package's tsconfig.
|
|
73
|
+
//
|
|
74
|
+
// import { uiLib, typeChecked } from "@mslmio/eslint-config";
|
|
75
|
+
// export default [...uiLib, typeChecked(import.meta.dirname)];
|
|
76
|
+
//
|
|
77
|
+
// Deliberately NOT the full `recommended-type-checked`: the no-unsafe-* family reports
|
|
78
|
+
// every read off an `any`, which on a codebase with untyped edges is thousands of hits
|
|
79
|
+
// that bury these four. These catch real bugs - an un-awaited promise silently does
|
|
80
|
+
// nothing, and a promise in a boolean position is always truthy.
|
|
81
|
+
export function typeChecked(tsconfigRootDir, extraProjectOptions = {}) {
|
|
82
|
+
return {
|
|
83
|
+
files: ["**/*.ts", "**/*.tsx", "**/*.mts"],
|
|
84
|
+
// Type-aware rules need the file to be in a tsconfig project. Tests and tool
|
|
85
|
+
// configs are routinely NOT - a separate tsconfig.test.json, or nothing at all -
|
|
86
|
+
// and the parser then fails the file outright rather than skipping it.
|
|
87
|
+
// `allowDefaultProject` is not the answer: it errors the other way round for a
|
|
88
|
+
// package whose tests ARE in the project, so it cannot be a shared default.
|
|
89
|
+
// They still get every non-type-aware rule from `typescript`.
|
|
90
|
+
ignores: ["**/*.test.ts", "**/*.test.tsx", "**/*.config.ts", "**/*.config.mts", "**/*.config.js"],
|
|
91
|
+
languageOptions: {
|
|
92
|
+
parser: tsParser,
|
|
93
|
+
parserOptions: {
|
|
94
|
+
projectService: true,
|
|
95
|
+
tsconfigRootDir: tsconfigRootDir,
|
|
96
|
+
...extraProjectOptions,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
rules: {
|
|
100
|
+
"@typescript-eslint/no-floating-promises": "error",
|
|
101
|
+
"@typescript-eslint/no-misused-promises": "error",
|
|
102
|
+
"@typescript-eslint/await-thenable": "error",
|
|
103
|
+
"@typescript-eslint/require-await": "warn",
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A runnable service. Reads process.env and builds clients at startup by design, so
|
|
109
|
+
// it gets the house rules and a parser but NOT the library-only bans.
|
|
110
|
+
export const svc = [
|
|
111
|
+
typescript,
|
|
112
|
+
...house,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
export const lib = [
|
|
116
|
+
typescript,
|
|
117
|
+
...house,
|
|
118
|
+
{
|
|
119
|
+
files: TS_GLOBS,
|
|
120
|
+
rules: {
|
|
121
|
+
"mslmio/no-lib-process-env": "error",
|
|
122
|
+
"mslmio/no-module-scope-init": "error",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
export default {
|
|
128
|
+
house: house,
|
|
129
|
+
svc: svc,
|
|
130
|
+
lib: lib,
|
|
131
|
+
typescript: typescript,
|
|
132
|
+
typescriptRules: typescriptRules,
|
|
133
|
+
typeChecked: typeChecked,
|
|
134
|
+
plugin: plugin,
|
|
135
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mslmio/eslint-config",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shareable ESLint flat config and house rules: US spelling, library hygiene, workspace layering, and Tailwind/React conventions.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"eslint",
|
|
7
|
+
"eslintconfig",
|
|
8
|
+
"eslint-config",
|
|
9
|
+
"eslint-plugin",
|
|
10
|
+
"flat-config",
|
|
11
|
+
"typescript",
|
|
12
|
+
"monorepo",
|
|
13
|
+
"layering",
|
|
14
|
+
"tailwind",
|
|
15
|
+
"react"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"homepage": "https://github.com/mslmio/eslint-config",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/mslmio/eslint-config.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/mslmio/eslint-config/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"packageManager": "pnpm@11.1.0",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"registry": "https://registry.npmjs.org/"
|
|
31
|
+
},
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./index.js",
|
|
34
|
+
"./ui": "./ui.js",
|
|
35
|
+
"./plugin": "./plugin.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"index.js",
|
|
39
|
+
"ui.js",
|
|
40
|
+
"plugin.js",
|
|
41
|
+
"rules/*.js",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --test test/*.test.js",
|
|
47
|
+
"pub-dryrun": "pnpm run lint && pnpm publish --no-git-checks --dry-run",
|
|
48
|
+
"pub": "pnpm run lint && pnpm publish --no-git-checks",
|
|
49
|
+
"lint": "eslint"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"eslint": ">=9.30.0",
|
|
53
|
+
"@shadcn/lint": "^0.1.0",
|
|
54
|
+
"eslint-plugin-react-hooks": "^5.2.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@shadcn/lint": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"eslint-plugin-react-hooks": {
|
|
61
|
+
"optional": true
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"@typescript-eslint/eslint-plugin": "8.70.0",
|
|
66
|
+
"@typescript-eslint/parser": "8.70.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"eslint": "^9.39.4",
|
|
70
|
+
"@shadcn/lint": "0.1.0",
|
|
71
|
+
"eslint-plugin-react-hooks": "^5.2.0"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/plugin.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import noLibProcessEnv from "./rules/no-lib-process-env.js";
|
|
2
|
+
import noModuleScopeInit from "./rules/no-module-scope-init.js";
|
|
3
|
+
import noExpressJsonSpaces from "./rules/no-express-json-spaces.js";
|
|
4
|
+
import preferCopyIcon from "./rules/prefer-copy-icon.js";
|
|
5
|
+
import usSpelling from "./rules/us-spelling.js";
|
|
6
|
+
import requireLeadingWithTextSize from "./rules/require-leading-with-text-size.js";
|
|
7
|
+
import noPropertyShorthand from "./rules/no-property-shorthand.js";
|
|
8
|
+
import noUpwardImport from "./rules/no-upward-import.js";
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
meta: { name: "@mslmio/eslint-plugin", version: "2.4.0" },
|
|
12
|
+
rules: {
|
|
13
|
+
"no-lib-process-env": noLibProcessEnv,
|
|
14
|
+
"no-module-scope-init": noModuleScopeInit,
|
|
15
|
+
"no-express-json-spaces": noExpressJsonSpaces,
|
|
16
|
+
"prefer-copy-icon": preferCopyIcon,
|
|
17
|
+
"us-spelling": usSpelling,
|
|
18
|
+
"require-leading-with-text-size": requireLeadingWithTextSize,
|
|
19
|
+
"no-property-shorthand": noPropertyShorthand,
|
|
20
|
+
"no-upward-import": noUpwardImport,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: "problem",
|
|
4
|
+
docs: {
|
|
5
|
+
description:
|
|
6
|
+
"Use the prettyJson middleware, not express's `json spaces`, which omits the trailing newline",
|
|
7
|
+
},
|
|
8
|
+
schema: [],
|
|
9
|
+
messages: {
|
|
10
|
+
jsonSpaces:
|
|
11
|
+
"`app.set('json spaces', ...)` indents but omits the trailing newline that keeps a shell " +
|
|
12
|
+
"prompt off the closing brace. Use a pretty-print middleware that emits one.",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
create(context) {
|
|
16
|
+
return {
|
|
17
|
+
CallExpression(node) {
|
|
18
|
+
if (node.callee?.type !== "MemberExpression") {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (node.callee.property?.name !== "set" || node.arguments.length !== 2) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const [key] = node.arguments;
|
|
25
|
+
if (key?.type !== "Literal" || key.value !== "json spaces") {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
context.report({ node: node, messageId: "jsonSpaces" });
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const DEFAULT_ENTRY_POINTS = ["main.ts", "main.js", "main.mts", "main.mjs"];
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: "problem",
|
|
6
|
+
docs: {
|
|
7
|
+
description:
|
|
8
|
+
"A published library never reads process.env; configuration is passed in as a typed argument",
|
|
9
|
+
},
|
|
10
|
+
schema: [
|
|
11
|
+
{
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
entryPoints: { type: "array", items: { type: "string" } },
|
|
15
|
+
docsUrl: { type: "string" },
|
|
16
|
+
},
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
messages: {
|
|
21
|
+
libReadsEnv:
|
|
22
|
+
"A library must not read process.env - take this as a typed argument instead. " +
|
|
23
|
+
"An env-reading library cannot be configured twice in one process or differently in a " +
|
|
24
|
+
"test, and a missing value fails at request time under a name the service author never " +
|
|
25
|
+
"saw.{{docs}}",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
create(context) {
|
|
29
|
+
const docs = docsSuffix(context);
|
|
30
|
+
const entryPoints = context.options[0]?.entryPoints ?? DEFAULT_ENTRY_POINTS;
|
|
31
|
+
const filename = context.filename ?? context.getFilename();
|
|
32
|
+
if (entryPoints.some((e) => filename.endsWith(e))) {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
MemberExpression(node) {
|
|
37
|
+
if (!isProcessEnv(node)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
context.report({ node: node, messageId: "libReadsEnv", data: { docs: docs } });
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function isProcessEnv(node) {
|
|
47
|
+
return (
|
|
48
|
+
node.object?.type === "Identifier" &&
|
|
49
|
+
node.object.name === "process" &&
|
|
50
|
+
node.property?.type === "Identifier" &&
|
|
51
|
+
node.property.name === "env"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A link only an internal caller can resolve, so the public default is none.
|
|
56
|
+
function docsSuffix(context) {
|
|
57
|
+
const url = context.options[0]?.docsUrl;
|
|
58
|
+
return url ? ` See ${url}.` : "";
|
|
59
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const FACTORY_RE = /^(create|connect|init|make|load|open|build)[A-Z]/;
|
|
2
|
+
const CONFIG_CALLEES = new Set(["cfg", "config", "env", "getConfig", "getEnv"]);
|
|
3
|
+
// Factories that MUST run at module scope. A React context is a shared token, not a
|
|
4
|
+
// client: building it lazily would hand each caller a different context object, so the
|
|
5
|
+
// provider would set one instance and the consumer read another.
|
|
6
|
+
const SAFE_CALLEES = new Set([
|
|
7
|
+
"createContext",
|
|
8
|
+
"createServerContext",
|
|
9
|
+
"createElement",
|
|
10
|
+
"createRef",
|
|
11
|
+
"createPortal",
|
|
12
|
+
"createToastManager",
|
|
13
|
+
"createRoot",
|
|
14
|
+
"createSelector",
|
|
15
|
+
"createGlobalStyle",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
meta: {
|
|
20
|
+
type: "problem",
|
|
21
|
+
docs: {
|
|
22
|
+
description:
|
|
23
|
+
"No client or config construction at module scope - it runs at import, before any caller can configure it",
|
|
24
|
+
},
|
|
25
|
+
schema: [
|
|
26
|
+
{
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
extraCallees: { type: "array", items: { type: "string" } },
|
|
30
|
+
docsUrl: { type: "string" },
|
|
31
|
+
},
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
messages: {
|
|
36
|
+
moduleScopeInit:
|
|
37
|
+
"`{{name}}` is constructed at module scope, so it runs on import - before any caller " +
|
|
38
|
+
"can configure it, and in every process that merely imports this file. Build it lazily " +
|
|
39
|
+
"inside a function and memoise.{{docs}}",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
create(context) {
|
|
43
|
+
const extra = new Set(context.options[0]?.extraCallees ?? []);
|
|
44
|
+
const docs = docsSuffix(context);
|
|
45
|
+
return {
|
|
46
|
+
"Program > VariableDeclaration > VariableDeclarator": (node) => check(context, extra, node, docs),
|
|
47
|
+
"Program > ExportNamedDeclaration > VariableDeclaration > VariableDeclarator": (node) =>
|
|
48
|
+
check(context, extra, node, docs),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function check(context, extra, node, docs) {
|
|
54
|
+
if (!node.init || node.id?.type !== "Identifier") {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// A factory with STATIC arguments is a shared singleton, not config: `createRouteMemory("/",
|
|
58
|
+
// isOverlayHref)` computes nothing at import. What this rule is for is the config being READ
|
|
59
|
+
// at import - `createClient(cfg().url)` - so require an argument that computes, or an env read.
|
|
60
|
+
if (!readsEnv(node.init) && !(callsFactory(node.init, extra) && hasComputedArg(node.init))) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
context.report({ node: node, messageId: "moduleScopeInit", data: { name: node.id.name, docs: docs } });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function callsFactory(node, extra) {
|
|
67
|
+
if (node.type !== "CallExpression") {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const name = calleeName(node.callee);
|
|
71
|
+
if (!name || SAFE_CALLEES.has(name)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return FACTORY_RE.test(name) || CONFIG_CALLEES.has(name) || extra.has(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasComputedArg(node) {
|
|
78
|
+
return (node.arguments ?? []).some((arg) => {
|
|
79
|
+
let found = false;
|
|
80
|
+
walk(arg, (n) => {
|
|
81
|
+
if (n.type === "CallExpression" || n.type === "NewExpression") {
|
|
82
|
+
found = true;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return found;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function calleeName(callee) {
|
|
90
|
+
if (callee?.type === "Identifier") {
|
|
91
|
+
return callee.name;
|
|
92
|
+
}
|
|
93
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier") {
|
|
94
|
+
return callee.property.name;
|
|
95
|
+
}
|
|
96
|
+
if (callee?.type === "NewExpression") {
|
|
97
|
+
return calleeName(callee.callee);
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readsEnv(node) {
|
|
103
|
+
let found = false;
|
|
104
|
+
walk(node, (n) => {
|
|
105
|
+
if (
|
|
106
|
+
n.type === "MemberExpression" &&
|
|
107
|
+
n.object?.type === "Identifier" &&
|
|
108
|
+
n.object.name === "process" &&
|
|
109
|
+
n.property?.type === "Identifier" &&
|
|
110
|
+
n.property.name === "env"
|
|
111
|
+
) {
|
|
112
|
+
found = true;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function walk(node, visit) {
|
|
119
|
+
if (!node || typeof node.type !== "string") {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
visit(node);
|
|
123
|
+
for (const key of Object.keys(node)) {
|
|
124
|
+
if (key === "parent") {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const child = node[key];
|
|
128
|
+
if (Array.isArray(child)) {
|
|
129
|
+
child.forEach((c) => walk(c, visit));
|
|
130
|
+
} else if (child && typeof child.type === "string") {
|
|
131
|
+
walk(child, visit);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A link only an internal caller can resolve, so the public default is none.
|
|
137
|
+
function docsSuffix(context) {
|
|
138
|
+
const url = context.options[0]?.docsUrl;
|
|
139
|
+
return url ? ` See ${url}.` : "";
|
|
140
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: "suggestion",
|
|
4
|
+
docs: { description: "Object literals spell out the value: `{ key: key }`, never `{ key }`" },
|
|
5
|
+
schema: [],
|
|
6
|
+
fixable: "code",
|
|
7
|
+
messages: { shorthand: "Write `{{name}}: {{name}}` rather than the shorthand `{{name}}`." },
|
|
8
|
+
},
|
|
9
|
+
create(context) {
|
|
10
|
+
return {
|
|
11
|
+
// Only object literal CREATION: code-ts.md exempts destructuring and imports, which
|
|
12
|
+
// also parse as shorthand Property nodes but under an ObjectPattern. `method` is
|
|
13
|
+
// excluded because the doc bans shorthand PROPERTIES, not `f() {}`.
|
|
14
|
+
Property(node) {
|
|
15
|
+
if (!node.shorthand || node.method || node.parent?.type !== "ObjectExpression") {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const name = node.key?.name;
|
|
19
|
+
if (!name) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
context.report({
|
|
23
|
+
node: node,
|
|
24
|
+
messageId: "shorthand",
|
|
25
|
+
data: { name: name },
|
|
26
|
+
fix: (fixer) => fixer.replaceText(node, `${name}: ${name}`),
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
Binary file
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const BANNED = new Map([["Clipboard", "Copy"], ["ClipboardCopy", "Copy"]]);
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: "suggestion",
|
|
6
|
+
docs: { description: "Copy buttons use lucide's Copy icon, not Clipboard" },
|
|
7
|
+
schema: [],
|
|
8
|
+
fixable: "code",
|
|
9
|
+
messages: {
|
|
10
|
+
useCopy: "Use lucide's `{{good}}` icon rather than `{{bad}}` for copy affordances.",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
return {
|
|
15
|
+
ImportDeclaration(node) {
|
|
16
|
+
if (node.source.value !== "lucide-react") {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
for (const spec of node.specifiers) {
|
|
20
|
+
if (spec.type !== "ImportSpecifier") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const good = BANNED.get(spec.imported.name);
|
|
24
|
+
if (!good) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
context.report({
|
|
28
|
+
node: spec,
|
|
29
|
+
messageId: "useCopy",
|
|
30
|
+
data: { bad: spec.imported.name, good: good },
|
|
31
|
+
fix: (fixer) => {
|
|
32
|
+
if (spec.local.name !== spec.imported.name) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return fixer.replaceText(spec, good);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const CLASS_FNS = new Set(["cn", "clsx", "cva", "classNames", "twMerge", "twJoin"]);
|
|
2
|
+
const COLOR_RE = /^(#|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|color-mix\(|currentColor|transparent)/i;
|
|
3
|
+
const LENGTH_RE = /(^|[\d.])(rem|px|em|ex|ch|vw|vh|vmin|vmax|pt|pc|%)\b|^(calc|clamp|min|max|var)\(|^[\d.]+$/;
|
|
4
|
+
const TEXT_ARB_RE = /\btext-\[([^\]]+)\](\/\S+)?/g;
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: "problem",
|
|
9
|
+
docs: {
|
|
10
|
+
description:
|
|
11
|
+
"An arbitrary text size must state its own line height - it inherits one otherwise, and the ancestor differs per host",
|
|
12
|
+
},
|
|
13
|
+
schema: [{ type: "object", properties: { docsUrl: { type: "string" } }, additionalProperties: false }],
|
|
14
|
+
messages: {
|
|
15
|
+
needsLeading:
|
|
16
|
+
"`text-[{{value}}]` sets a font SIZE only, so line height comes from whatever ancestor " +
|
|
17
|
+
"set one - which differs between hosts and renders the same row at two heights. State it: " +
|
|
18
|
+
"`text-[{{value}}]/[<leading>]` or an explicit `leading-*`.{{docs}}",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
create(context) {
|
|
22
|
+
const docs = docsSuffix(context);
|
|
23
|
+
const seen = new WeakSet();
|
|
24
|
+
|
|
25
|
+
const checkContainer = (node, parts) => {
|
|
26
|
+
const combined = parts.join(" ");
|
|
27
|
+
if (/\bleading-/.test(combined)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const part of parts) {
|
|
31
|
+
for (const m of part.matchAll(TEXT_ARB_RE)) {
|
|
32
|
+
if (m[2] || !isLength(m[1])) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
context.report({ node: node, messageId: "needsLeading", data: { value: m[1], docs: docs } });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
JSXAttribute(node) {
|
|
42
|
+
if (node.name?.name !== "className" && node.name?.name !== "class") {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
seen.add(node);
|
|
46
|
+
checkContainer(node, collectStrings(node.value, seen));
|
|
47
|
+
},
|
|
48
|
+
CallExpression(node) {
|
|
49
|
+
if (!CLASS_FNS.has(calleeName(node.callee)) || seen.has(node)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
seen.add(node);
|
|
53
|
+
checkContainer(node, node.arguments.flatMap((a) => collectStrings(a, seen)));
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function collectStrings(node, seen) {
|
|
60
|
+
const out = [];
|
|
61
|
+
walk(node, (n) => {
|
|
62
|
+
if (n !== node && (n.type === "CallExpression" || n.type === "JSXAttribute")) {
|
|
63
|
+
seen.add(n);
|
|
64
|
+
}
|
|
65
|
+
if (n.type === "Literal" && typeof n.value === "string") {
|
|
66
|
+
out.push(n.value);
|
|
67
|
+
}
|
|
68
|
+
if (n.type === "TemplateElement") {
|
|
69
|
+
out.push(n.value?.raw ?? "");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isLength(value) {
|
|
76
|
+
const v = value.trim();
|
|
77
|
+
if (COLOR_RE.test(v)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return LENGTH_RE.test(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function calleeName(callee) {
|
|
84
|
+
if (callee?.type === "Identifier") {
|
|
85
|
+
return callee.name;
|
|
86
|
+
}
|
|
87
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier") {
|
|
88
|
+
return callee.property.name;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function walk(node, visit) {
|
|
94
|
+
if (!node || typeof node.type !== "string") {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
visit(node);
|
|
98
|
+
for (const key of Object.keys(node)) {
|
|
99
|
+
if (key === "parent") {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const child = node[key];
|
|
103
|
+
if (Array.isArray(child)) {
|
|
104
|
+
child.forEach((c) => walk(c, visit));
|
|
105
|
+
} else if (child && typeof child.type === "string") {
|
|
106
|
+
walk(child, visit);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A link only an internal caller can resolve, so the public default is none.
|
|
112
|
+
function docsSuffix(context) {
|
|
113
|
+
const url = context.options[0]?.docsUrl;
|
|
114
|
+
return url ? ` See ${url}.` : "";
|
|
115
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const COMPOUND_STEMS = { centre: "center", centres: "centers", centred: "centered" };
|
|
2
|
+
|
|
3
|
+
const WORDS = {
|
|
4
|
+
metre: "meter", metres: "meters", litre: "liter", litres: "liters",
|
|
5
|
+
fibre: "fiber", fibres: "fibers", theatre: "theater", calibre: "caliber",
|
|
6
|
+
sombre: "somber", spectre: "specter", lustre: "luster", manoeuvre: "maneuver",
|
|
7
|
+
colour: "color", colours: "colors", coloured: "colored", colourful: "colorful",
|
|
8
|
+
behaviour: "behavior", behaviours: "behaviors", favourite: "favorite",
|
|
9
|
+
favourites: "favorites", honour: "honor", neighbour: "neighbor",
|
|
10
|
+
neighbours: "neighbors", labour: "labor", flavour: "flavor", flavours: "flavors",
|
|
11
|
+
harbour: "harbor", rumour: "rumor", humour: "humor", odour: "odor",
|
|
12
|
+
vapour: "vapor", armour: "armor", endeavour: "endeavor", parlour: "parlor",
|
|
13
|
+
savour: "savor", valour: "valor",
|
|
14
|
+
grey: "gray", greyed: "grayed", licence: "license", licences: "licenses",
|
|
15
|
+
defence: "defense", offence: "offense", pretence: "pretense", practise: "practice",
|
|
16
|
+
analyse: "analyze", analysed: "analyzed", analysing: "analyzing",
|
|
17
|
+
paralyse: "paralyze", catalogue: "catalog", catalogues: "catalogs",
|
|
18
|
+
programme: "program", programmes: "programs", aluminium: "aluminum",
|
|
19
|
+
storey: "story", tyre: "tire", sulphur: "sulfur", plough: "plow",
|
|
20
|
+
mould: "mold", draught: "draft", enquire: "inquire", enquiry: "inquiry",
|
|
21
|
+
cancelled: "canceled", cancelling: "canceling", travelled: "traveled",
|
|
22
|
+
modelling: "modeling", labelled: "labeled", labelling: "labeling",
|
|
23
|
+
fuelled: "fueled", signalling: "signaling", marvellous: "marvelous",
|
|
24
|
+
skilful: "skillful", enrol: "enroll", fulfil: "fulfill", instalment: "installment",
|
|
25
|
+
organise: "organize", organised: "organized", organisation: "organization",
|
|
26
|
+
organisations: "organizations", recognise: "recognize", recognised: "recognized",
|
|
27
|
+
realise: "realize", realised: "realized", apologise: "apologize",
|
|
28
|
+
prioritise: "prioritize", prioritised: "prioritized", customise: "customize",
|
|
29
|
+
customised: "customized", normalise: "normalize", normalised: "normalized",
|
|
30
|
+
serialise: "serialize", serialised: "serialized", initialise: "initialize",
|
|
31
|
+
initialised: "initialized", optimise: "optimize", optimised: "optimized",
|
|
32
|
+
summarise: "summarize", categorise: "categorize", categorised: "categorized",
|
|
33
|
+
standardise: "standardize", specialise: "specialize", emphasise: "emphasize",
|
|
34
|
+
criticise: "criticize", authorise: "authorize", authorised: "authorized",
|
|
35
|
+
minimise: "minimize", maximise: "maximize", visualise: "visualize",
|
|
36
|
+
utilise: "utilize", memorise: "memorize", synchronise: "synchronize",
|
|
37
|
+
synchronised: "synchronized",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const WORD_RE = new RegExp(`\\b(${Object.keys(WORDS).join("|")})\\b`, "gi");
|
|
41
|
+
const COMPOUND_RE = new RegExp(`\\b(\\w*?)(${Object.keys(COMPOUND_STEMS).join("|")})\\b`, "gi");
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
meta: {
|
|
45
|
+
type: "suggestion",
|
|
46
|
+
docs: { description: "US spelling everywhere we write" },
|
|
47
|
+
// Fixable in COMMENTS and JSX TEXT only. A string literal can be a wire value -
|
|
48
|
+
// an upstream status, a served route, a search synonym - and rewriting one is how
|
|
49
|
+
// a filter silently stops matching. Those are reported for a human to judge.
|
|
50
|
+
fixable: "code",
|
|
51
|
+
schema: [
|
|
52
|
+
{
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: { allow: { type: "array", items: { type: "string" } } },
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
messages: { british: "Use US spelling: `{{bad}}` -> `{{good}}`." },
|
|
59
|
+
},
|
|
60
|
+
create(context) {
|
|
61
|
+
const allow = context.options[0]?.allow ?? [];
|
|
62
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
63
|
+
|
|
64
|
+
const check = (node, text, fixable) => {
|
|
65
|
+
if (typeof text !== "string" || !text) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (allow.some((phrase) => text.includes(phrase))) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const [bad, good] of findAll(text)) {
|
|
72
|
+
context.report({
|
|
73
|
+
node: node,
|
|
74
|
+
messageId: "british",
|
|
75
|
+
data: { bad: bad, good: good },
|
|
76
|
+
fix: fixable
|
|
77
|
+
? (fixer) => {
|
|
78
|
+
const range = node.range ?? [node.start, node.end];
|
|
79
|
+
const src = sourceCode.getText().slice(range[0], range[1]);
|
|
80
|
+
return fixer.replaceTextRange(range, replaceWord(src, bad, good));
|
|
81
|
+
}
|
|
82
|
+
: null,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
// A regex matching scraped text must accept BOTH spellings, so literals carrying one are exempt.
|
|
89
|
+
Literal(node) {
|
|
90
|
+
if (node.regex) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
check(node, node.value, false);
|
|
94
|
+
},
|
|
95
|
+
TemplateElement(node) {
|
|
96
|
+
check(node, node.value?.raw, false);
|
|
97
|
+
},
|
|
98
|
+
JSXText(node) {
|
|
99
|
+
check(node, node.value, true);
|
|
100
|
+
},
|
|
101
|
+
"Program:exit": () => {
|
|
102
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
103
|
+
check(comment, comment.value, true);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function replaceWord(src, bad, good) {
|
|
111
|
+
return src.replace(new RegExp(`\\b${bad}\\b`, "g"), good);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findAll(text) {
|
|
115
|
+
const hits = [];
|
|
116
|
+
for (const m of text.matchAll(WORD_RE)) {
|
|
117
|
+
hits.push([m[1], matchCase(m[1], WORDS[m[1].toLowerCase()])]);
|
|
118
|
+
}
|
|
119
|
+
for (const m of text.matchAll(COMPOUND_RE)) {
|
|
120
|
+
const stem = COMPOUND_STEMS[m[2].toLowerCase()];
|
|
121
|
+
hits.push([m[0], m[1] + matchCase(m[2], stem)]);
|
|
122
|
+
}
|
|
123
|
+
return hits;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function matchCase(sample, replacement) {
|
|
127
|
+
if (sample === sample.toUpperCase()) {
|
|
128
|
+
return replacement.toUpperCase();
|
|
129
|
+
}
|
|
130
|
+
if (sample[0] === sample[0].toUpperCase()) {
|
|
131
|
+
return replacement[0].toUpperCase() + replacement.slice(1);
|
|
132
|
+
}
|
|
133
|
+
return replacement;
|
|
134
|
+
}
|
package/ui.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The Tailwind + React half, behind its own entry point.
|
|
2
|
+
//
|
|
3
|
+
// A backend service has no Tailwind and no React. Keeping these behind their own
|
|
4
|
+
// entry point means the root entry installs neither @shadcn/lint nor
|
|
5
|
+
// eslint-plugin-react-hooks (nor oxc-parser's native binaries), and a service that
|
|
6
|
+
// imports only the root never resolves them.
|
|
7
|
+
//
|
|
8
|
+
// import { ui } from "@mslmio/eslint-config/ui";
|
|
9
|
+
import shadcn from "@shadcn/lint";
|
|
10
|
+
import reactHooks from "eslint-plugin-react-hooks";
|
|
11
|
+
|
|
12
|
+
import { house, lib } from "./index.js";
|
|
13
|
+
|
|
14
|
+
// Tailwind class hygiene. `cn`, `clsx`, `cva`, `twMerge` and `twJoin` are already
|
|
15
|
+
// built-in merge functions, so only the component packages need naming.
|
|
16
|
+
export const shadcnUi = [
|
|
17
|
+
{
|
|
18
|
+
files: ["**/*.tsx", "**/*.ts"],
|
|
19
|
+
plugins: { shadcn: shadcn },
|
|
20
|
+
settings: {
|
|
21
|
+
// EMPTY by default: which packages are the component library is the
|
|
22
|
+
// consumer's fact, not this config's. Append a later flat-config entry
|
|
23
|
+
// setting `settings.shadcn.componentImports` to declare them.
|
|
24
|
+
shadcn: { componentImports: [] },
|
|
25
|
+
},
|
|
26
|
+
rules: {
|
|
27
|
+
// `allow: ["layout"]` is the plugin's own documented adoption setting, and it
|
|
28
|
+
// matches how our library is actually built: Panel, PanelBody and friends take
|
|
29
|
+
// `className` and merge it with `cn()`, so external composition is their API.
|
|
30
|
+
// Typography, color and internal spacing stay errors - that is the drift
|
|
31
|
+
// this rule set exists to stop.
|
|
32
|
+
// WARN, not error, and deliberately so: these landed on a codebase with a
|
|
33
|
+
// real backlog (~1,340 at adoption), and clearing it needs design decisions
|
|
34
|
+
// rather than effort - component variants in the library, and a named
|
|
35
|
+
// typographic scale where clamp()/ch have no Tailwind equivalent. A red
|
|
36
|
+
// `pnpm lint` nobody can fix is a lint nobody reads. Ratchet per app to
|
|
37
|
+
// "error" as each backlog clears.
|
|
38
|
+
"shadcn/no-restyle": ["warn", { allow: ["layout"] }],
|
|
39
|
+
"shadcn/no-raw-colors": "warn",
|
|
40
|
+
"shadcn/no-arbitrary-values": "warn",
|
|
41
|
+
"shadcn/no-inline-styles": "warn",
|
|
42
|
+
"shadcn/require-static-classes": "warn",
|
|
43
|
+
"shadcn/no-unknown-classes": "warn",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const ui = [
|
|
49
|
+
...house,
|
|
50
|
+
...shadcnUi,
|
|
51
|
+
{
|
|
52
|
+
files: ["**/*.tsx", "**/*.ts"],
|
|
53
|
+
rules: {
|
|
54
|
+
"mslmio/require-leading-with-text-size": "error",
|
|
55
|
+
"mslmio/prefer-copy-icon": "error",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// A published React component library. The hook rules are not decoration: a
|
|
61
|
+
// rebuilt dependency can silently disable a memo other code relied on.
|
|
62
|
+
export const uiLib = [
|
|
63
|
+
...lib,
|
|
64
|
+
...ui,
|
|
65
|
+
{
|
|
66
|
+
files: ["**/*.tsx", "**/*.ts"],
|
|
67
|
+
plugins: { "react-hooks": reactHooks },
|
|
68
|
+
rules: {
|
|
69
|
+
"react-hooks/rules-of-hooks": "error",
|
|
70
|
+
"react-hooks/exhaustive-deps": "warn",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export default { shadcnUi: shadcnUi, ui: ui, uiLib: uiLib };
|