@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.
@@ -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
@@ -1,3 +1,4 @@
1
1
  import { config } from './index.mjs'
2
2
 
3
+
3
4
  export default config
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': tsplugin,
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": "2.6.2",
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.31.0",
30
- "@stylistic/eslint-plugin": "^5.2.0",
31
- "@stylistic/eslint-plugin-jsx": "^2.13.0",
32
- "typescript-eslint": "^8.37.0",
33
- "globals": "^16.3.0",
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/compat": "^1.3.1",
45
- "@eslint/eslintrc": "^3.3.1",
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': [ 'warn', 'separate-lines', { checkJSDoc: false }],
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-symbol': [ 'error' ],
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
- 'whitespaced/consistent-line-spacing': [ 'off', {
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 - Migrated to @stylistic plugin namespace (consistent)
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 }
@@ -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 = path.join(__dirname, '../index.mjs')
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('🚀 Running ESLint configuration tests...\n')
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 => file.endsWith('.js') || file.endsWith('.tsx') || file.endsWith('.ts'))
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('⚠️ No test fixtures found in test/fixtures/')
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('Test runner failed:', error.message)
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(`📝 Testing ${filename}...`)
97
+ console.log(` Testing ${filename} [${mode}]...`)
57
98
 
58
- // Run ESLint on the test file
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 (errorCount > 0) {
77
- console.log(` ❌ ${errorCount} errors, ${warningCount} warnings`)
78
- fileResult.messages.forEach(msg => {
79
- if (msg.severity === 2)
80
- console.log(` Error: ${msg.message} (${msg.ruleId})`)
81
- })
82
- this.results.failed++
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(` ❌ Test failed: ${error.message}`)
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
- file: filename,
98
- error: error.message
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📊 Test Summary:')
105
- console.log(`✅ Passed: ${this.results.passed}`)
106
- console.log(`❌ Failed: ${this.results.failed}`)
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🔍 Failures:')
110
- this.results.errors.forEach(error => {
111
- console.log(` ${error.file}: ${error.error || 'ESLint errors'}`)
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🎉 All tests passed!')
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