@tuomashatakka/eslint-config 2.6.2 → 3.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/.github/workflows/ci.yml +23 -0
- package/.github/workflows/publish.yml +45 -0
- package/eslint.config.mjs +1 -0
- package/index.mjs +4 -14
- package/package.json +9 -15
- package/plugins/react-strict/index.mjs +19 -0
- package/plugins/react-strict/rules/jsx-prop-layout.mjs +100 -0
- package/plugins/react-strict/rules/no-complex-jsx-map.mjs +66 -0
- package/plugins/react-strict/rules/no-jsx-value-calculations.mjs +99 -0
- package/plugins/react-strict/rules/no-nested-divs.mjs +59 -0
- package/plugins/react-strict/rules/no-style-prop.mjs +43 -0
- package/plugins/react-strict/rules/prefer-no-use-effect.mjs +26 -0
- package/rules.mjs +43 -6
- package/test/fixtures/react-strict.invalid.tsx +33 -0
- package/test/fixtures/react-strict.valid.tsx +76 -0
- package/test/fixtures/whitespaced.valid.ts +50 -0
- package/test/test-runner.mjs +129 -45
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ['**']
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
lint-and-test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
node-version: [20, 22]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: ${{ matrix.node-version }}
|
|
20
|
+
cache: npm
|
|
21
|
+
- run: npm ci
|
|
22
|
+
- run: npm run lint
|
|
23
|
+
- run: npm run test
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ['v*']
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
with:
|
|
15
|
+
ref: main
|
|
16
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: 22
|
|
21
|
+
registry-url: https://registry.npmjs.org
|
|
22
|
+
cache: npm
|
|
23
|
+
|
|
24
|
+
- name: Extract version from tag
|
|
25
|
+
id: version
|
|
26
|
+
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
|
27
|
+
|
|
28
|
+
- name: Sync package.json version with tag
|
|
29
|
+
run: npm version ${{ steps.version.outputs.VERSION }} --no-git-tag-version
|
|
30
|
+
|
|
31
|
+
- run: npm ci
|
|
32
|
+
- run: npm run test
|
|
33
|
+
|
|
34
|
+
- name: Publish to npm
|
|
35
|
+
run: npm publish --access public
|
|
36
|
+
env:
|
|
37
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
38
|
+
|
|
39
|
+
- name: Commit version bump back to main
|
|
40
|
+
run: |
|
|
41
|
+
git config user.name "github-actions[bot]"
|
|
42
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
43
|
+
git add package.json package-lock.json
|
|
44
|
+
git commit -m "${{ steps.version.outputs.VERSION }}" || echo "No changes to commit"
|
|
45
|
+
git push origin main
|
package/eslint.config.mjs
CHANGED
package/index.mjs
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import stylistic from '@stylistic/eslint-plugin'
|
|
2
|
-
import stylisticJsx from '@stylistic/eslint-plugin-jsx'
|
|
3
|
-
import tsplugin from '@typescript-eslint/eslint-plugin'
|
|
4
2
|
import importPlugin from 'eslint-plugin-import'
|
|
5
3
|
import noInlineMultilineTypesPlugin from 'eslint-plugin-no-inline-multiline-types'
|
|
6
4
|
import whitespacedPlugin from 'eslint-plugin-whitespaced'
|
|
@@ -9,6 +7,7 @@ import react from 'eslint-plugin-react'
|
|
|
9
7
|
import reactHooks from 'eslint-plugin-react-hooks'
|
|
10
8
|
import globals from 'globals'
|
|
11
9
|
import tseslint from 'typescript-eslint'
|
|
10
|
+
import reactStrictPlugin from './plugins/react-strict/index.mjs'
|
|
12
11
|
import { rules } from './rules.mjs'
|
|
13
12
|
|
|
14
13
|
|
|
@@ -17,10 +16,11 @@ const plugins = {
|
|
|
17
16
|
'react-hooks': reactHooks,
|
|
18
17
|
'import': importPlugin,
|
|
19
18
|
'@stylistic': stylistic,
|
|
20
|
-
'@typescript-eslint':
|
|
19
|
+
'@typescript-eslint': tseslint.plugin,
|
|
21
20
|
'omit': omitPlugin,
|
|
22
21
|
'no-inline-types': noInlineMultilineTypesPlugin,
|
|
23
22
|
'whitespaced': whitespacedPlugin,
|
|
23
|
+
'react-strict': reactStrictPlugin,
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export { rules }
|
|
@@ -38,17 +38,7 @@ export const baseConfig = {
|
|
|
38
38
|
'import/internal-regex': '^@/(.+)',
|
|
39
39
|
},
|
|
40
40
|
ignores: [ '**/node_modules/**' ],
|
|
41
|
-
plugins
|
|
42
|
-
...plugins,
|
|
43
|
-
// Merge stylistic JSX plugin into the main stylistic namespace
|
|
44
|
-
'@stylistic': {
|
|
45
|
-
...stylistic,
|
|
46
|
-
rules: {
|
|
47
|
-
...stylistic.rules,
|
|
48
|
-
...stylisticJsx.rules,
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
},
|
|
41
|
+
plugins,
|
|
52
42
|
rules,
|
|
53
43
|
}
|
|
54
44
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuomashatakka/eslint-config",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Default eslint configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
@@ -26,28 +26,22 @@
|
|
|
26
26
|
"typescript": "*"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"eslint": "^9.
|
|
30
|
-
"@stylistic/eslint-plugin": "^5.
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"typescript": "^5.8.3",
|
|
29
|
+
"eslint": "^9.39.0",
|
|
30
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
31
|
+
"typescript-eslint": "^8.58.0",
|
|
32
|
+
"globals": "^16.5.0",
|
|
33
|
+
"typescript": "^5.9.0",
|
|
35
34
|
"eslint-plugin-react": "^7.37.5",
|
|
36
35
|
"eslint-plugin-react-hooks": "^5.2.0",
|
|
37
36
|
"eslint-plugin-import": "^2.32.0",
|
|
38
|
-
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
|
39
37
|
"eslint-plugin-no-inline-multiline-types": "^0.0.5",
|
|
40
38
|
"eslint-plugin-omit-unnecessary": "^0.0.3",
|
|
41
39
|
"eslint-plugin-whitespaced": "^1.0.2"
|
|
42
40
|
},
|
|
43
41
|
"devDependencies": {
|
|
44
|
-
"@eslint/
|
|
45
|
-
"@eslint/
|
|
46
|
-
"@eslint/js": "^9.31.0",
|
|
47
|
-
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
|
42
|
+
"@eslint/eslintrc": "^3.3.5",
|
|
43
|
+
"@eslint/js": "^9.39.0",
|
|
48
44
|
"@types/eslint__eslintrc": "^2.1.2",
|
|
49
|
-
"@types/eslint__js": "^8.42.3"
|
|
50
|
-
"@typescript-eslint/parser": "^8.37.0",
|
|
51
|
-
"eslint-plugin-block-padding": "^0.0.3"
|
|
45
|
+
"@types/eslint__js": "^8.42.3"
|
|
52
46
|
}
|
|
53
47
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import noStyleProp from './rules/no-style-prop.mjs'
|
|
2
|
+
import noNestedDivs from './rules/no-nested-divs.mjs'
|
|
3
|
+
import noComplexJsxMap from './rules/no-complex-jsx-map.mjs'
|
|
4
|
+
import preferNoUseEffect from './rules/prefer-no-use-effect.mjs'
|
|
5
|
+
import noJsxValueCalculations from './rules/no-jsx-value-calculations.mjs'
|
|
6
|
+
import jsxPropLayout from './rules/jsx-prop-layout.mjs'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export const rules = {
|
|
10
|
+
'no-style-prop': noStyleProp,
|
|
11
|
+
'no-nested-divs': noNestedDivs,
|
|
12
|
+
'no-complex-jsx-map': noComplexJsxMap,
|
|
13
|
+
'prefer-no-use-effect': preferNoUseEffect,
|
|
14
|
+
'no-jsx-value-calculations': noJsxValueCalculations,
|
|
15
|
+
'jsx-prop-layout': jsxPropLayout,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export default { rules }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const RESERVED_PROPS = new Set([
|
|
2
|
+
'key',
|
|
3
|
+
'ref',
|
|
4
|
+
])
|
|
5
|
+
const STYLE_PROPS = new Set([
|
|
6
|
+
'className',
|
|
7
|
+
'style',
|
|
8
|
+
'id',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
function getPropName (attr) {
|
|
13
|
+
if (attr.type === 'JSXSpreadAttribute')
|
|
14
|
+
return null
|
|
15
|
+
|
|
16
|
+
if (attr.name.type === 'JSXIdentifier')
|
|
17
|
+
return attr.name.name
|
|
18
|
+
|
|
19
|
+
if (attr.name.type === 'JSXNamespacedName')
|
|
20
|
+
return `${attr.name.namespace.name}:${attr.name.name.name}`
|
|
21
|
+
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
function getPropGroup (name) {
|
|
27
|
+
if (!name)
|
|
28
|
+
return 4 // spread attributes — middle
|
|
29
|
+
|
|
30
|
+
if (RESERVED_PROPS.has(name))
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
if (STYLE_PROPS.has(name))
|
|
34
|
+
return 1
|
|
35
|
+
|
|
36
|
+
if (name.startsWith('data-') || name.startsWith('aria-'))
|
|
37
|
+
return 2
|
|
38
|
+
|
|
39
|
+
if (name.startsWith('on') && name[2] === name[2].toUpperCase())
|
|
40
|
+
return 5
|
|
41
|
+
|
|
42
|
+
return 3
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
export default {
|
|
47
|
+
meta: {
|
|
48
|
+
type: 'suggestion',
|
|
49
|
+
docs: { description: 'Enforce consistent JSX prop ordering: key/ref first, className/style next, data/aria attrs, then regular props, callbacks last' },
|
|
50
|
+
fixable: 'code',
|
|
51
|
+
schema: [],
|
|
52
|
+
messages: {
|
|
53
|
+
propOrder: '`{{ current }}` should be placed before `{{ previous }}` ({{ currentGroup }} props should come before {{ previousGroup }} props).',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
create (context) {
|
|
57
|
+
const groupNames = [
|
|
58
|
+
'reserved',
|
|
59
|
+
'style/class',
|
|
60
|
+
'data/aria',
|
|
61
|
+
'regular',
|
|
62
|
+
'spread',
|
|
63
|
+
'callback',
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
JSXOpeningElement (node) {
|
|
68
|
+
const attrs = node.attributes
|
|
69
|
+
|
|
70
|
+
if (attrs.length < 2)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
let lastGroup = -1
|
|
74
|
+
let lastName = null
|
|
75
|
+
|
|
76
|
+
for (const attr of attrs) {
|
|
77
|
+
const name = getPropName(attr)
|
|
78
|
+
const group = getPropGroup(name)
|
|
79
|
+
|
|
80
|
+
if (group < lastGroup) {
|
|
81
|
+
context.report({
|
|
82
|
+
node: attr,
|
|
83
|
+
messageId: 'propOrder',
|
|
84
|
+
data: {
|
|
85
|
+
current: name || '{...spread}',
|
|
86
|
+
previous: lastName || '{...spread}',
|
|
87
|
+
currentGroup: groupNames[group],
|
|
88
|
+
previousGroup: groupNames[lastGroup],
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
lastGroup = group
|
|
95
|
+
lastName = name
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
function isInsideJSXExpression (node) {
|
|
2
|
+
let current = node.parent
|
|
3
|
+
|
|
4
|
+
while (current) {
|
|
5
|
+
if (current.type === 'JSXExpressionContainer')
|
|
6
|
+
return true
|
|
7
|
+
|
|
8
|
+
current = current.parent
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
function hasComplexBody (callbackBody) {
|
|
16
|
+
if (callbackBody.type === 'BlockStatement') {
|
|
17
|
+
const hasIfOrSwitch = callbackBody.body.some(stmt =>
|
|
18
|
+
stmt.type === 'IfStatement' || stmt.type === 'SwitchStatement')
|
|
19
|
+
|
|
20
|
+
if (hasIfOrSwitch)
|
|
21
|
+
return true
|
|
22
|
+
|
|
23
|
+
if (callbackBody.body.length > 3)
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
meta: {
|
|
33
|
+
type: 'suggestion',
|
|
34
|
+
docs: { description: 'Disallow complex .map() callbacks with inline logic inside JSX' },
|
|
35
|
+
fixable: null,
|
|
36
|
+
schema: [],
|
|
37
|
+
messages: {
|
|
38
|
+
noComplexMap: 'Extract complex .map() callback into a separate component. Move conditional logic outside the JSX return block.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
create (context) {
|
|
42
|
+
return {
|
|
43
|
+
CallExpression (node) {
|
|
44
|
+
if (
|
|
45
|
+
node.callee.type !== 'MemberExpression' ||
|
|
46
|
+
node.callee.property.type !== 'Identifier' ||
|
|
47
|
+
node.callee.property.name !== 'map'
|
|
48
|
+
)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
if (!isInsideJSXExpression(node))
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
const callback = node.arguments[0]
|
|
55
|
+
|
|
56
|
+
if (!callback)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
if (callback.type === 'ArrowFunctionExpression' || callback.type === 'FunctionExpression') {
|
|
60
|
+
if (hasComplexBody(callback.body))
|
|
61
|
+
context.report({ node, messageId: 'noComplexMap' })
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
function isComponentFunction (node) {
|
|
2
|
+
let current = node
|
|
3
|
+
|
|
4
|
+
while (current) {
|
|
5
|
+
if (
|
|
6
|
+
current.type === 'ArrowFunctionExpression' ||
|
|
7
|
+
current.type === 'FunctionExpression' ||
|
|
8
|
+
current.type === 'FunctionDeclaration'
|
|
9
|
+
) {
|
|
10
|
+
const parent = current.parent
|
|
11
|
+
|
|
12
|
+
if (parent && parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
|
|
13
|
+
const name = parent.id.name
|
|
14
|
+
|
|
15
|
+
if (name[0] === name[0].toUpperCase() && name[0] !== name[0].toLowerCase())
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (current.type === 'FunctionDeclaration' && current.id) {
|
|
20
|
+
const name = current.id.name
|
|
21
|
+
|
|
22
|
+
if (name[0] === name[0].toUpperCase() && name[0] !== name[0].toLowerCase())
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
current = current.parent
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
function isInsideReturnJSX (node) {
|
|
35
|
+
let current = node.parent
|
|
36
|
+
|
|
37
|
+
while (current) {
|
|
38
|
+
if (current.type === 'ReturnStatement')
|
|
39
|
+
return true
|
|
40
|
+
|
|
41
|
+
if (current.type === 'ArrowFunctionExpression' && current.body.type !== 'BlockStatement')
|
|
42
|
+
return true
|
|
43
|
+
|
|
44
|
+
if (
|
|
45
|
+
current.type === 'FunctionDeclaration' ||
|
|
46
|
+
current.type === 'FunctionExpression' ||
|
|
47
|
+
current.type === 'ArrowFunctionExpression'
|
|
48
|
+
)
|
|
49
|
+
return false
|
|
50
|
+
|
|
51
|
+
current = current.parent
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
export default {
|
|
59
|
+
meta: {
|
|
60
|
+
type: 'suggestion',
|
|
61
|
+
docs: { description: 'Disallow value calculations and assignments inside JSX return blocks' },
|
|
62
|
+
fixable: null,
|
|
63
|
+
schema: [],
|
|
64
|
+
messages: {
|
|
65
|
+
noJsxCalculations: 'Move value calculations and assignments outside the return block. Compute values before the return statement.',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
create (context) {
|
|
69
|
+
return {
|
|
70
|
+
JSXExpressionContainer (node) {
|
|
71
|
+
if (!isComponentFunction(node) || !isInsideReturnJSX(node))
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
const expr = node.expression
|
|
75
|
+
|
|
76
|
+
if (!expr || expr.type === 'JSXEmptyExpression')
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if (expr.type === 'AssignmentExpression') {
|
|
80
|
+
context.report({ node: expr, messageId: 'noJsxCalculations' })
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (expr.type === 'SequenceExpression') {
|
|
85
|
+
const hasAssignment = expr.expressions.some(e => e.type === 'AssignmentExpression')
|
|
86
|
+
|
|
87
|
+
if (hasAssignment)
|
|
88
|
+
context.report({ node: expr, messageId: 'noJsxCalculations' })
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
VariableDeclaration (node) {
|
|
92
|
+
if (!isComponentFunction(node) || !isInsideReturnJSX(node))
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
context.report({ node, messageId: 'noJsxCalculations' })
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const SEMANTIC_ALTERNATIVES = [
|
|
2
|
+
'main',
|
|
3
|
+
'section',
|
|
4
|
+
'article',
|
|
5
|
+
'aside',
|
|
6
|
+
'header',
|
|
7
|
+
'footer',
|
|
8
|
+
'nav',
|
|
9
|
+
'figure',
|
|
10
|
+
'figcaption',
|
|
11
|
+
'details',
|
|
12
|
+
'summary',
|
|
13
|
+
'dialog',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
function getElementName (node) {
|
|
18
|
+
if (node.type === 'JSXElement' && node.openingElement.name.type === 'JSXIdentifier')
|
|
19
|
+
return node.openingElement.name.name
|
|
20
|
+
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
meta: {
|
|
27
|
+
type: 'suggestion',
|
|
28
|
+
docs: { description: 'Disallow nested div elements; prefer semantic HTML5 tags' },
|
|
29
|
+
fixable: null,
|
|
30
|
+
schema: [],
|
|
31
|
+
messages: {
|
|
32
|
+
noNestedDivs: 'Avoid nesting <div> inside <div>. Use semantic HTML5 elements instead ({{ alternatives }}).',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
create (context) {
|
|
36
|
+
return {
|
|
37
|
+
JSXElement (node) {
|
|
38
|
+
const name = getElementName(node)
|
|
39
|
+
|
|
40
|
+
if (name !== 'div')
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
const parent = node.parent
|
|
44
|
+
|
|
45
|
+
if (parent.type !== 'JSXElement')
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
const parentName = getElementName(parent)
|
|
49
|
+
|
|
50
|
+
if (parentName === 'div')
|
|
51
|
+
context.report({
|
|
52
|
+
node: node.openingElement,
|
|
53
|
+
messageId: 'noNestedDivs',
|
|
54
|
+
data: { alternatives: SEMANTIC_ALTERNATIVES.join(', ') },
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const DRAG_DROP_HANDLERS = new Set([
|
|
2
|
+
'onDrag',
|
|
3
|
+
'onDragStart',
|
|
4
|
+
'onDragEnd',
|
|
5
|
+
'onDragEnter',
|
|
6
|
+
'onDragLeave',
|
|
7
|
+
'onDragOver',
|
|
8
|
+
'onDrop',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'suggestion',
|
|
15
|
+
docs: { description: 'Disallow inline style props unless used with drag/drop interactions' },
|
|
16
|
+
fixable: null,
|
|
17
|
+
schema: [],
|
|
18
|
+
messages: {
|
|
19
|
+
noStyleProp: 'Avoid inline style props. Use CSS classes or styled components instead. Inline styles are only acceptable for dynamic user interaction scenarios like drag/drop.',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
create (context) {
|
|
23
|
+
return {
|
|
24
|
+
JSXAttribute (node) {
|
|
25
|
+
if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'style')
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
const opening = node.parent
|
|
29
|
+
|
|
30
|
+
if (opening.type !== 'JSXOpeningElement')
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
const hasDragHandler = opening.attributes.some(attr =>
|
|
34
|
+
attr.type === 'JSXAttribute' &&
|
|
35
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
36
|
+
DRAG_DROP_HANDLERS.has(attr.name.name))
|
|
37
|
+
|
|
38
|
+
if (!hasDragHandler)
|
|
39
|
+
context.report({ node, messageId: 'noStyleProp' })
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: 'suggestion',
|
|
4
|
+
docs: { description: 'Discourage useEffect in favor of React context, custom hooks, or event-driven patterns' },
|
|
5
|
+
fixable: null,
|
|
6
|
+
schema: [],
|
|
7
|
+
messages: {
|
|
8
|
+
preferNoUseEffect: 'Consider alternatives to useEffect. Extract side effects into React context, a custom hook in a separate module, or use event-driven patterns.',
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
create (context) {
|
|
12
|
+
return {
|
|
13
|
+
CallExpression (node) {
|
|
14
|
+
const isDirectCall = node.callee.type === 'Identifier' &&
|
|
15
|
+
node.callee.name === 'useEffect'
|
|
16
|
+
|
|
17
|
+
const isMemberCall = node.callee.type === 'MemberExpression' &&
|
|
18
|
+
node.callee.property.type === 'Identifier' &&
|
|
19
|
+
node.callee.property.name === 'useEffect'
|
|
20
|
+
|
|
21
|
+
if (isDirectCall || isMemberCall)
|
|
22
|
+
context.report({ node, messageId: 'preferNoUseEffect' })
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
}
|
package/rules.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export const rules = {
|
|
7
|
-
'multiline-comment-style':
|
|
7
|
+
'@stylistic/multiline-comment-style': [ 'warn', 'separate-lines', { checkJSDoc: false }],
|
|
8
8
|
'@stylistic/lines-around-comment': [ 'warn', {
|
|
9
9
|
beforeBlockComment: true,
|
|
10
10
|
beforeLineComment: false,
|
|
@@ -35,21 +35,40 @@ export const rules = {
|
|
|
35
35
|
'no-undef': [ 0 ],
|
|
36
36
|
'use-isnan': [ 'error' ],
|
|
37
37
|
'no-obj-calls': [ 'error' ],
|
|
38
|
-
'no-new-
|
|
38
|
+
'no-new-native-nonconstructor': [ 'error' ],
|
|
39
39
|
'no-func-assign': [ 'error' ],
|
|
40
40
|
'no-class-assign': [ 'error' ],
|
|
41
41
|
'no-array-constructor': [ 'error' ],
|
|
42
42
|
'omit/omit-unnecessary-parens-brackets': [ 'warn' ],
|
|
43
43
|
'no-inline-types/no-inline-multiline-types': [ 'warn' ],
|
|
44
|
-
|
|
44
|
+
// Whitespaced plugin — structural spacing
|
|
45
|
+
'whitespaced/block-padding': [ 'off' ],
|
|
46
|
+
'whitespaced/aligned-assignments': [ 'off' ],
|
|
47
|
+
'whitespaced/consistent-line-spacing': [ 'warn', {
|
|
45
48
|
beforeClass: 2,
|
|
46
49
|
afterClass: 2,
|
|
47
50
|
beforeImports: 0,
|
|
48
51
|
afterImports: 2,
|
|
52
|
+
beforeExports: 2,
|
|
53
|
+
afterExports: 1,
|
|
49
54
|
beforeFunction: 2,
|
|
50
55
|
afterFunction: 2,
|
|
56
|
+
beforeComment: 1,
|
|
51
57
|
ignoreTopLevelCode: false,
|
|
52
|
-
skipImportGroups: true
|
|
58
|
+
skipImportGroups: true,
|
|
59
|
+
}],
|
|
60
|
+
'whitespaced/class-property-grouping': [ 'warn', {
|
|
61
|
+
paddingBetweenGroups: 1,
|
|
62
|
+
enforceAlphabeticalSorting: false,
|
|
63
|
+
}],
|
|
64
|
+
'whitespaced/multiline-format': [ 'warn', {
|
|
65
|
+
allowSingleLine: true,
|
|
66
|
+
multilineStyle: 'consistent',
|
|
67
|
+
minItems: 3,
|
|
68
|
+
indentation: 2,
|
|
69
|
+
trailingComma: 'always',
|
|
70
|
+
consistentSpacing: true,
|
|
71
|
+
objectAlignment: 'none',
|
|
53
72
|
}],
|
|
54
73
|
'@stylistic/function-call-spacing': [ 'warn', 'never' ],
|
|
55
74
|
'@stylistic/computed-property-spacing': [ 'warn', 'never' ],
|
|
@@ -97,9 +116,8 @@ export const rules = {
|
|
|
97
116
|
'@stylistic/template-tag-spacing': [ 'warn', 'always' ],
|
|
98
117
|
'@stylistic/yield-star-spacing': [ 'warn', 'after' ],
|
|
99
118
|
'@stylistic/quotes': [ 'warn', 'single', { avoidEscape: true, allowTemplateLiterals: 'always' }],
|
|
100
|
-
// JSX/React
|
|
119
|
+
// JSX/React — @stylistic plugin namespace
|
|
101
120
|
'@stylistic/jsx-newline': [ 'warn', { prevent: true, allowMultilines: true }],
|
|
102
|
-
'@stylistic/jsx-props-no-multi-spaces': [ 'warn' ],
|
|
103
121
|
'@stylistic/jsx-equals-spacing': [ 'warn', 'never' ],
|
|
104
122
|
'@stylistic/jsx-max-props-per-line': [ 'warn', { maximum: 1, when: 'multiline' }],
|
|
105
123
|
'@stylistic/jsx-self-closing-comp': [ 'warn', { component: true, html: true }],
|
|
@@ -111,6 +129,7 @@ export const rules = {
|
|
|
111
129
|
'@stylistic/jsx-function-call-newline': [ 'warn', 'always' ],
|
|
112
130
|
'@stylistic/jsx-indent-props': [ 'warn', { indentMode: 2, ignoreTernaryOperator: true }],
|
|
113
131
|
'@stylistic/jsx-curly-spacing': [ 'warn', { when: 'always', spacing: { objectLiterals: 'never' }}],
|
|
132
|
+
// React rules
|
|
114
133
|
'react/no-unescaped-entities': [ 'error', { forbid: [ '>', '}' ]}],
|
|
115
134
|
'react/jsx-uses-vars': [ 'error' ],
|
|
116
135
|
'react/jsx-uses-react': [ 'error' ],
|
|
@@ -128,7 +147,18 @@ export const rules = {
|
|
|
128
147
|
'react/jsx-pascal-case': [ 'warn' ],
|
|
129
148
|
'react/jsx-curly-brace-presence': [ 'warn', { props: 'never', children: 'never' }],
|
|
130
149
|
'react/display-name': [ 'warn', { checkContextObjects: true }],
|
|
150
|
+
'react/no-unstable-nested-components': [ 'warn', { allowAsProps: true }],
|
|
151
|
+
'react/function-component-definition': [ 'warn', { namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' }],
|
|
152
|
+
'react/jsx-no-leaked-render': [ 'warn', { validStrategies: [ 'ternary', 'coerce' ]}],
|
|
153
|
+
'react/jsx-sort-props': [ 'warn', {
|
|
154
|
+
callbacksLast: true,
|
|
155
|
+
shorthandFirst: true,
|
|
156
|
+
reservedFirst: true,
|
|
157
|
+
multiline: 'last',
|
|
158
|
+
noSortAlphabetically: true,
|
|
159
|
+
}],
|
|
131
160
|
'react-hooks/exhaustive-deps': 0,
|
|
161
|
+
// Formatting
|
|
132
162
|
'@stylistic/no-multi-spaces': [ 'warn', {
|
|
133
163
|
exceptions: {
|
|
134
164
|
Property: true,
|
|
@@ -157,6 +187,13 @@ export const rules = {
|
|
|
157
187
|
{ blankLine: 'always', prev: [ 'block-like', 'block' ], next: [ 'multiline-expression', 'function', 'block-like', 'block' ]},
|
|
158
188
|
{ blankLine: 'any', prev: [ 'multiline-expression', 'function', 'block-like', 'block' ], next: [ 'multiline-expression', 'function', 'block-like', 'block' ]},
|
|
159
189
|
],
|
|
190
|
+
// React strict — opinionated component quality rules
|
|
191
|
+
'react-strict/no-style-prop': [ 'warn' ],
|
|
192
|
+
'react-strict/no-nested-divs': [ 'warn' ],
|
|
193
|
+
'react-strict/no-complex-jsx-map': [ 'warn' ],
|
|
194
|
+
'react-strict/prefer-no-use-effect': [ 'warn' ],
|
|
195
|
+
'react-strict/no-jsx-value-calculations': [ 'warn' ],
|
|
196
|
+
'react-strict/jsx-prop-layout': [ 'warn' ],
|
|
160
197
|
}
|
|
161
198
|
|
|
162
199
|
export default rules
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* eslint-disable whitespaced/consistent-line-spacing */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import React, { useState, useEffect } from 'react'
|
|
5
|
+
|
|
6
|
+
// expect-warning: react-strict/prefer-no-use-effect
|
|
7
|
+
const BadComponent = () => {
|
|
8
|
+
const [ data, setData ] = useState([])
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
fetch('/api').then(r => r.json())
|
|
11
|
+
.then(setData)
|
|
12
|
+
}, [])
|
|
13
|
+
|
|
14
|
+
return <div>
|
|
15
|
+
{/* expect-warning: react-strict/no-nested-divs */}
|
|
16
|
+
<div>
|
|
17
|
+
<p>Nested div is bad</p>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
{/* expect-warning: react-strict/no-style-prop */}
|
|
21
|
+
<span style={{ color: 'red' }}>styled inline</span>
|
|
22
|
+
|
|
23
|
+
{/* expect-warning: react-strict/no-complex-jsx-map */}
|
|
24
|
+
{data.map((item: string) => {
|
|
25
|
+
if (item === 'special')
|
|
26
|
+
return <strong key={ item }>Special</strong>
|
|
27
|
+
|
|
28
|
+
return <span key={ item }>{item}</span>
|
|
29
|
+
})}
|
|
30
|
+
</div>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default BadComponent
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React, { useState, useCallback, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
interface UserCardProps {
|
|
5
|
+
id: string
|
|
6
|
+
name: string
|
|
7
|
+
email: string
|
|
8
|
+
isActive: boolean
|
|
9
|
+
onSelect: (id: string) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const UserCard: React.FC<UserCardProps> = ({ id, name, email, isActive, onSelect }) => {
|
|
14
|
+
const handleClick = useCallback(() => {
|
|
15
|
+
onSelect(id)
|
|
16
|
+
}, [ id, onSelect ])
|
|
17
|
+
|
|
18
|
+
const displayName = useMemo(() =>
|
|
19
|
+
isActive ? name : `${name} (inactive)`, [ isActive, name ])
|
|
20
|
+
|
|
21
|
+
return <article className="user-card">
|
|
22
|
+
<header>
|
|
23
|
+
<h3>{displayName}</h3>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<p>{email}</p>
|
|
27
|
+
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={ handleClick }>
|
|
31
|
+
Select
|
|
32
|
+
</button>
|
|
33
|
+
</article>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
interface UserListProps {
|
|
38
|
+
users: UserCardProps[]
|
|
39
|
+
onSelect: (id: string) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
const UserListItem: React.FC<UserCardProps> = props =>
|
|
44
|
+
<UserCard { ...props } />
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
const UserList: React.FC<UserListProps> = ({ users, onSelect }) => {
|
|
48
|
+
const [ filter, setFilter ] = useState('')
|
|
49
|
+
|
|
50
|
+
const filtered = useMemo(() =>
|
|
51
|
+
users.filter(u =>
|
|
52
|
+
u.name.toLowerCase().includes(filter.toLowerCase())), [ users, filter ])
|
|
53
|
+
|
|
54
|
+
const handleFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
55
|
+
setFilter(e.target.value)
|
|
56
|
+
}, [])
|
|
57
|
+
|
|
58
|
+
return <section className="user-list">
|
|
59
|
+
<input
|
|
60
|
+
type="text"
|
|
61
|
+
placeholder="Filter users..."
|
|
62
|
+
value={ filter }
|
|
63
|
+
onChange={ handleFilterChange } />
|
|
64
|
+
|
|
65
|
+
<ul>
|
|
66
|
+
{filtered.map(user =>
|
|
67
|
+
<UserListItem
|
|
68
|
+
key={ user.id }
|
|
69
|
+
{ ...user }
|
|
70
|
+
onSelect={ onSelect } />)}
|
|
71
|
+
</ul>
|
|
72
|
+
</section>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
export default UserList
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
const shortName = 'hello'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
const longValue = 'world'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataProcessor {
|
|
12
|
+
static defaultConfig = { verbose: false }
|
|
13
|
+
|
|
14
|
+
private name: string
|
|
15
|
+
|
|
16
|
+
private data: string[]
|
|
17
|
+
|
|
18
|
+
constructor (name: string) {
|
|
19
|
+
this.name = name
|
|
20
|
+
|
|
21
|
+
this.data = []
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
process (input: string) {
|
|
25
|
+
const trimmed = input.trim()
|
|
26
|
+
|
|
27
|
+
const processed = trimmed.toLowerCase()
|
|
28
|
+
|
|
29
|
+
this.data.push(processed)
|
|
30
|
+
|
|
31
|
+
return processed
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
function formatOutput (value: string): string {
|
|
37
|
+
const prefix = '[OUT]'
|
|
38
|
+
|
|
39
|
+
const result = `${prefix} ${value}`
|
|
40
|
+
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
function parseInput (raw: string): string[] {
|
|
46
|
+
return raw.split('\n').filter(Boolean)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
export { DataProcessor, formatOutput, parseInput }
|
package/test/test-runner.mjs
CHANGED
|
@@ -9,32 +9,44 @@ import { fileURLToPath } from 'url'
|
|
|
9
9
|
const execAsync = promisify(exec)
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
11
11
|
|
|
12
|
+
|
|
12
13
|
/**
|
|
13
14
|
* Test runner for ESLint configuration
|
|
14
15
|
* Validates that the config works with various code samples
|
|
16
|
+
*
|
|
17
|
+
* Fixture naming conventions:
|
|
18
|
+
* *.valid.{ext} — must produce zero errors AND zero warnings
|
|
19
|
+
* *.invalid.{ext} — must produce expected warnings (annotated with expect-warning: rule-id)
|
|
20
|
+
* *.<ext> — must produce zero errors (warnings are tolerated)
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
class ESLintConfigTester {
|
|
19
25
|
constructor () {
|
|
20
26
|
this.fixturesDir = path.join(__dirname, 'fixtures')
|
|
21
|
-
this.configPath
|
|
22
|
-
this.results
|
|
27
|
+
this.configPath = path.join(__dirname, '../index.mjs')
|
|
28
|
+
this.results = {
|
|
23
29
|
passed: 0,
|
|
24
30
|
failed: 0,
|
|
25
|
-
errors: []
|
|
31
|
+
errors: [],
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
|
|
29
36
|
async runTests () {
|
|
30
|
-
console.log('
|
|
37
|
+
console.log('\n Running ESLint configuration tests...\n')
|
|
31
38
|
|
|
32
39
|
try {
|
|
33
40
|
const fixtures = await readdir(this.fixturesDir)
|
|
34
|
-
const testFiles = fixtures.filter(file =>
|
|
41
|
+
const testFiles = fixtures.filter(file =>
|
|
42
|
+
file.endsWith('.js') ||
|
|
43
|
+
file.endsWith('.jsx') ||
|
|
44
|
+
file.endsWith('.ts') ||
|
|
45
|
+
file.endsWith('.tsx') ||
|
|
46
|
+
file.endsWith('.mjs'))
|
|
35
47
|
|
|
36
48
|
if (testFiles.length === 0) {
|
|
37
|
-
console.log('
|
|
49
|
+
console.log(' No test fixtures found in test/fixtures/')
|
|
38
50
|
return
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -44,25 +56,50 @@ class ESLintConfigTester {
|
|
|
44
56
|
this.printSummary()
|
|
45
57
|
}
|
|
46
58
|
catch (error) {
|
|
47
|
-
console.error('
|
|
59
|
+
console.error(' Test runner failed:', error.message)
|
|
48
60
|
process.exit(1)
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
63
|
|
|
64
|
+
|
|
65
|
+
getFixtureMode (filename) {
|
|
66
|
+
if (filename.includes('.valid.'))
|
|
67
|
+
return 'valid'
|
|
68
|
+
|
|
69
|
+
if (filename.includes('.invalid.'))
|
|
70
|
+
return 'invalid'
|
|
71
|
+
|
|
72
|
+
return 'standard'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async parseExpectedWarnings (filepath) {
|
|
77
|
+
const content = await readFile(filepath, 'utf8')
|
|
78
|
+
const lines = content.split('\n')
|
|
79
|
+
const expected = []
|
|
80
|
+
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const match = line.match(/(?:\/\/|{\s*\/\*)\s*expect-warning:\s*([^\s*]+)/)
|
|
83
|
+
|
|
84
|
+
if (match)
|
|
85
|
+
expected.push(match[1].trim())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return expected
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
52
92
|
async testFile (filename) {
|
|
53
93
|
const filepath = path.join(this.fixturesDir, filename)
|
|
94
|
+
const mode = this.getFixtureMode(filename)
|
|
54
95
|
|
|
55
96
|
try {
|
|
56
|
-
console.log(
|
|
97
|
+
console.log(` Testing ${filename} [${mode}]...`)
|
|
57
98
|
|
|
58
|
-
|
|
59
|
-
const { stdout, stderr } = await execAsync(
|
|
99
|
+
const { stdout } = await execAsync(
|
|
60
100
|
`npx eslint "${filepath}" --config "${this.configPath}" --format json`,
|
|
61
|
-
{ cwd: path.join(__dirname, '..') }
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
if (stderr && stderr.trim())
|
|
65
|
-
throw new Error(`ESLint stderr: ${stderr}`)
|
|
101
|
+
{ cwd: path.join(__dirname, '..'), maxBuffer: 1024 * 1024 }
|
|
102
|
+
).catch(e => ({ stdout: e.stdout, stderr: e.stderr }))
|
|
66
103
|
|
|
67
104
|
const results = JSON.parse(stdout)
|
|
68
105
|
const fileResult = results[0]
|
|
@@ -72,56 +109,103 @@ class ESLintConfigTester {
|
|
|
72
109
|
|
|
73
110
|
const errorCount = fileResult.errorCount
|
|
74
111
|
const warningCount = fileResult.warningCount
|
|
112
|
+
const messages = fileResult.messages
|
|
75
113
|
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.results.errors.push({
|
|
84
|
-
file: filename,
|
|
85
|
-
errors: fileResult.messages.filter(m => m.severity === 2)
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
console.log(` ✅ 0 errors, ${warningCount} warnings`)
|
|
90
|
-
this.results.passed++
|
|
91
|
-
}
|
|
114
|
+
if (mode === 'valid')
|
|
115
|
+
return this.assertValid(filename, errorCount, warningCount, messages)
|
|
116
|
+
|
|
117
|
+
if (mode === 'invalid')
|
|
118
|
+
return await this.assertInvalid(filename, filepath, errorCount, warningCount, messages)
|
|
119
|
+
|
|
120
|
+
return this.assertStandard(filename, errorCount, warningCount, messages)
|
|
92
121
|
}
|
|
93
122
|
catch (error) {
|
|
94
|
-
console.log(`
|
|
123
|
+
console.log(` FAIL: ${error.message}`)
|
|
124
|
+
this.results.failed++
|
|
125
|
+
this.results.errors.push({ file: filename, error: error.message })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
assertStandard (filename, errorCount, warningCount, messages) {
|
|
131
|
+
if (errorCount > 0) {
|
|
132
|
+
console.log(` FAIL — ${errorCount} errors, ${warningCount} warnings`)
|
|
133
|
+
messages.filter(m => m.severity === 2).forEach(msg =>
|
|
134
|
+
console.log(` Error: ${msg.message} (${msg.ruleId}) [line ${msg.line}]`))
|
|
135
|
+
this.results.failed++
|
|
136
|
+
this.results.errors.push({ file: filename, errors: messages.filter(m => m.severity === 2) })
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
console.log(` PASS — 0 errors, ${warningCount} warnings`)
|
|
140
|
+
this.results.passed++
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
assertValid (filename, errorCount, warningCount, messages) {
|
|
146
|
+
if (errorCount > 0 || warningCount > 0) {
|
|
147
|
+
console.log(` FAIL — expected 0 issues, got ${errorCount} errors, ${warningCount} warnings`)
|
|
148
|
+
messages.forEach(msg =>
|
|
149
|
+
console.log(` ${msg.severity === 2 ? 'Error' : 'Warn'}: ${msg.message} (${msg.ruleId}) [line ${msg.line}]`))
|
|
95
150
|
this.results.failed++
|
|
96
|
-
this.results.errors.push({
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
151
|
+
this.results.errors.push({ file: filename, errors: messages })
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
console.log(` PASS — clean (0 errors, 0 warnings)`)
|
|
155
|
+
this.results.passed++
|
|
100
156
|
}
|
|
101
157
|
}
|
|
102
158
|
|
|
159
|
+
|
|
160
|
+
async assertInvalid (filename, filepath, errorCount, warningCount, messages) {
|
|
161
|
+
const expected = await this.parseExpectedWarnings(filepath)
|
|
162
|
+
const warningRuleIds = messages.filter(m => m.severity === 1).map(m => m.ruleId)
|
|
163
|
+
|
|
164
|
+
if (errorCount > 0) {
|
|
165
|
+
console.log(` FAIL — unexpected errors found`)
|
|
166
|
+
messages.filter(m => m.severity === 2).forEach(msg =>
|
|
167
|
+
console.log(` Error: ${msg.message} (${msg.ruleId}) [line ${msg.line}]`))
|
|
168
|
+
this.results.failed++
|
|
169
|
+
this.results.errors.push({ file: filename, errors: messages.filter(m => m.severity === 2) })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const missing = expected.filter(rule => !warningRuleIds.includes(rule))
|
|
174
|
+
|
|
175
|
+
if (missing.length > 0) {
|
|
176
|
+
console.log(` FAIL — expected warnings not found: ${missing.join(', ')}`)
|
|
177
|
+
this.results.failed++
|
|
178
|
+
this.results.errors.push({ file: filename, error: `Missing expected warnings: ${missing.join(', ')}` })
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
console.log(` PASS — ${warningCount} expected warnings from ${expected.length} rules`)
|
|
182
|
+
this.results.passed++
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
103
187
|
printSummary () {
|
|
104
|
-
console.log('\n
|
|
105
|
-
console.log(
|
|
106
|
-
console.log(
|
|
188
|
+
console.log('\n Test Summary:')
|
|
189
|
+
console.log(` Passed: ${this.results.passed}`)
|
|
190
|
+
console.log(` Failed: ${this.results.failed}`)
|
|
107
191
|
|
|
108
192
|
if (this.results.failed > 0) {
|
|
109
|
-
console.log('\n
|
|
110
|
-
this.results.errors.forEach(error =>
|
|
111
|
-
console.log(`
|
|
112
|
-
})
|
|
193
|
+
console.log('\n Failures:')
|
|
194
|
+
this.results.errors.forEach(error =>
|
|
195
|
+
console.log(` ${error.file}: ${error.error || 'ESLint errors'}`))
|
|
113
196
|
process.exit(1)
|
|
114
197
|
}
|
|
115
198
|
else
|
|
116
|
-
console.log('\n
|
|
199
|
+
console.log('\n All tests passed!\n')
|
|
117
200
|
}
|
|
118
201
|
}
|
|
119
202
|
|
|
120
203
|
|
|
121
|
-
// Run tests if this file is executed directly
|
|
122
204
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
123
205
|
const tester = new ESLintConfigTester()
|
|
206
|
+
|
|
124
207
|
tester.runTests()
|
|
125
208
|
}
|
|
126
209
|
|
|
210
|
+
|
|
127
211
|
export default ESLintConfigTester
|