Skip to content

Commit f809408

Browse files
authored
Eslint legacy detection & warning (#7831)
* Eslint legacy detection & warning * jscpd * fix test class
1 parent 6725b65 commit f809408

8 files changed

Lines changed: 259 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const { FlatCompat } = require('@eslint/eslintrc');
2+
const js = require('@eslint/js');
3+
4+
const compat = new FlatCompat({
5+
baseDirectory: __dirname,
6+
resolvePluginsRelativeTo: __dirname,
7+
recommendedConfig: js.configs.recommended,
8+
allConfig: js.configs.all,
9+
});
10+
11+
module.exports = [
12+
...compat.config(require('./.eslintrc.json')),
13+
];

.github/linters/.jscpd.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"**/megalinter/tests/test_megalinter/mega_linter*",
2626
"**/megalinter/tests/test_megalinter/plugins_test.py*",
2727
"**/megalinter/tests/test_megalinter/config_test.py",
28+
"**/megalinter/tests/test_megalinter/eslint_linter_test.py",
2829
"**/megalinter/llm_provider/*.py",
2930
"**/test_llm_advisor_integration.py",
3031
"**/TrivySbomLinter.py",

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88

99
Note: Can be used with `oxsecurity/megalinter@beta` in your GitHub Action mega-linter.yml file, or with `oxsecurity/megalinter:beta` docker image
1010

11+
- (Not) breaking changes, but has to be handled
12+
- **ESlint-based linters have been upgraded to v10+**, so legacy .eslintrc-based config files are no longer supported, to keep using ESLint in MegaLinter you need to [migrate to flat-config](https://eslint.org/docs/latest/use/configure/migration-guide).
13+
1114
- Core
1215
- Security: add [more default hidden environment variables](https://megalinter.io/beta/config-variables-security/), so in case one of the 100+ linters is hacked, the attacker won't get your secrets anyway
1316
- Upgrade GO version to 1.26.3

megalinter/MegaLinter.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ def __init__(self, params=None):
128128
self.default_linter_activation = True
129129
self.output_sarif = False
130130
self.result_message = ""
131+
# Migration notices raised by linters during activation
132+
# (e.g. ESLint v10 flat-config migration). Surfaced in console logs
133+
# and PR comment reporters.
134+
self.migration_warnings: list[str] = []
131135

132136
# Get enable / disable vars
133137
self.enable_descriptors = config.get_list(self.request_id, "ENABLE", [])

megalinter/linters/EslintLinter.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,113 @@
33
Use Eslint to check so many file formats :)
44
"""
55

6+
import json
7+
import logging
8+
import os
9+
610
from megalinter import Linter
711

12+
LEGACY_ESLINTRC_FILES = (
13+
".eslintrc",
14+
".eslintrc.json",
15+
".eslintrc.yml",
16+
".eslintrc.yaml",
17+
".eslintrc.js",
18+
".eslintrc.cjs",
19+
)
20+
21+
FLAT_ESLINT_FILES = (
22+
"eslint.config.js",
23+
"eslint.config.mjs",
24+
"eslint.config.cjs",
25+
"eslint.config.ts",
26+
"eslint.config.mts",
27+
"eslint.config.cts",
28+
)
29+
30+
ESLINT_FLAT_CONFIG_MIGRATION_URL = (
31+
"https://eslint.org/docs/latest/use/configure/migration-guide"
32+
)
33+
834

935
class EslintLinter(Linter):
36+
def __init__(self, params=None, linter_config=None):
37+
super().__init__(params, linter_config)
38+
self._gate_on_eslint10_config(params)
39+
40+
# ESLint v10 dropped support for the legacy ".eslintrc.*" format.
41+
# If only a legacy config is found, disable the linter and surface a
42+
# migration notice (also propagated to the master so PR reporters can
43+
# include it in their summary).
44+
def _gate_on_eslint10_config(self, params):
45+
workspace = params.get("workspace") if params else None
46+
if not workspace:
47+
return
48+
49+
flat_found = next(
50+
(
51+
name
52+
for name in FLAT_ESLINT_FILES
53+
if os.path.isfile(os.path.join(workspace, name))
54+
),
55+
None,
56+
)
57+
if flat_found is not None:
58+
return
59+
60+
legacy_found = next(
61+
(
62+
name
63+
for name in LEGACY_ESLINTRC_FILES
64+
if os.path.isfile(os.path.join(workspace, name))
65+
),
66+
None,
67+
)
68+
if legacy_found is None:
69+
package_json = os.path.join(workspace, "package.json")
70+
if os.path.isfile(package_json):
71+
try:
72+
with open(package_json, "r", encoding="utf-8") as fh:
73+
package_data = json.load(fh)
74+
except (OSError, ValueError):
75+
package_data = None
76+
if isinstance(package_data, dict) and "eslintConfig" in package_data:
77+
legacy_found = "package.json#eslintConfig"
78+
79+
if legacy_found is None:
80+
return
81+
82+
self.is_active = False
83+
self.disabled = True
84+
self.disabled_reason = (
85+
f"ESLint v10 requires the flat config format. "
86+
f"Only legacy `{legacy_found}` was found in this repository. "
87+
f"Migrate to `eslint.config.mjs` ({ESLINT_FLAT_CONFIG_MIGRATION_URL})."
88+
)
89+
logging.warning(
90+
f"[Activation] {self.name} has been disabled: ESLint v10 only supports "
91+
f"the flat config format (eslint.config.*). Detected legacy "
92+
f"`{legacy_found}` only. Please migrate. "
93+
f"See {ESLINT_FLAT_CONFIG_MIGRATION_URL}"
94+
)
95+
96+
master = params.get("master") if params else None
97+
if master is not None:
98+
if (
99+
not hasattr(master, "migration_warnings")
100+
or master.migration_warnings is None
101+
):
102+
master.migration_warnings = []
103+
pr_message = (
104+
f"⚠️ **{self.name}**: migrate to ESLint v10 flat config. "
105+
f"Detected legacy `{legacy_found}` only — ESLint v10 dropped support "
106+
f"for `.eslintrc.*`. `{self.name}` is disabled until the project "
107+
f"migrates to `eslint.config.mjs`. "
108+
f"See the [ESLint migration guide]({ESLINT_FLAT_CONFIG_MIGRATION_URL})."
109+
)
110+
if pr_message not in master.migration_warnings:
111+
master.migration_warnings.append(pr_message)
112+
10113
# Drop ESLint v8-only flags that v9 removed.
11114
# Keep --no-ignore stripping when an explicit ignore source is present.
12115
def build_lint_command(self, file=None):

megalinter/reporters/ConsoleReporter.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ def produce_report(self):
126126
for table_line in table.table.splitlines():
127127
logging.info(table_line)
128128
logging.info("")
129+
migration_warnings = getattr(self.master, "migration_warnings", None)
130+
if migration_warnings:
131+
logging.warning(blue("Migration notices:"))
132+
for warning in migration_warnings:
133+
logging.warning(blue(f"- {warning}"))
134+
logging.info("")
129135
if self.master.flavor_suggestions is not None:
130136
active_linter_names = [linter.name for linter in self.master.active_linters]
131137
custom_flavor_command = (
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Unit tests for EslintLinter ESLint v10 activation gate
4+
"""
5+
6+
import json
7+
import os
8+
import tempfile
9+
import unittest
10+
11+
from megalinter.linters.EslintLinter import (
12+
ESLINT_FLAT_CONFIG_MIGRATION_URL,
13+
EslintLinter,
14+
)
15+
16+
17+
class _Master:
18+
def __init__(self):
19+
self.migration_warnings = []
20+
21+
22+
def _make_linter(name="TEST_ESLINT"):
23+
linter = EslintLinter.__new__(EslintLinter)
24+
linter.name = name
25+
linter.is_active = True
26+
linter.disabled = False
27+
linter.disabled_reason = None
28+
return linter
29+
30+
31+
class EslintLinterTest(unittest.TestCase):
32+
def test_gate_with_flat_config_keeps_linter_active(self):
33+
with tempfile.TemporaryDirectory() as workspace:
34+
with open(
35+
os.path.join(workspace, "eslint.config.mjs"), "w", encoding="utf-8"
36+
) as fh:
37+
fh.write("export default [];\n")
38+
with open(
39+
os.path.join(workspace, ".eslintrc.json"), "w", encoding="utf-8"
40+
) as fh:
41+
fh.write("{}\n")
42+
43+
linter = _make_linter()
44+
master = _Master()
45+
linter._gate_on_eslint10_config(
46+
{"workspace": workspace, "master": master}
47+
)
48+
49+
self.assertTrue(linter.is_active)
50+
self.assertFalse(linter.disabled)
51+
self.assertEqual(master.migration_warnings, [])
52+
53+
def test_gate_with_legacy_eslintrc_disables_and_warns(self):
54+
with tempfile.TemporaryDirectory() as workspace:
55+
with open(
56+
os.path.join(workspace, ".eslintrc.json"), "w", encoding="utf-8"
57+
) as fh:
58+
fh.write("{}\n")
59+
60+
linter = _make_linter("JAVASCRIPT_ES")
61+
master = _Master()
62+
linter._gate_on_eslint10_config(
63+
{"workspace": workspace, "master": master}
64+
)
65+
66+
self.assertFalse(linter.is_active)
67+
self.assertTrue(linter.disabled)
68+
self.assertIn(".eslintrc.json", linter.disabled_reason or "")
69+
self.assertEqual(len(master.migration_warnings), 1)
70+
self.assertIn("JAVASCRIPT_ES", master.migration_warnings[0])
71+
self.assertIn(
72+
ESLINT_FLAT_CONFIG_MIGRATION_URL, master.migration_warnings[0]
73+
)
74+
75+
def test_gate_with_package_json_eslintconfig_disables_and_warns(self):
76+
with tempfile.TemporaryDirectory() as workspace:
77+
with open(
78+
os.path.join(workspace, "package.json"), "w", encoding="utf-8"
79+
) as fh:
80+
json.dump({"name": "demo", "eslintConfig": {"rules": {}}}, fh)
81+
82+
linter = _make_linter("TYPESCRIPT_ES")
83+
master = _Master()
84+
linter._gate_on_eslint10_config(
85+
{"workspace": workspace, "master": master}
86+
)
87+
88+
self.assertFalse(linter.is_active)
89+
self.assertTrue(linter.disabled)
90+
self.assertIn("package.json", linter.disabled_reason or "")
91+
92+
def test_gate_with_no_config_files_is_noop(self):
93+
with tempfile.TemporaryDirectory() as workspace:
94+
linter = _make_linter()
95+
master = _Master()
96+
linter._gate_on_eslint10_config(
97+
{"workspace": workspace, "master": master}
98+
)
99+
100+
self.assertTrue(linter.is_active)
101+
self.assertFalse(linter.disabled)
102+
self.assertEqual(master.migration_warnings, [])
103+
104+
def test_gate_deduplicates_warnings(self):
105+
with tempfile.TemporaryDirectory() as workspace:
106+
with open(
107+
os.path.join(workspace, ".eslintrc.yml"), "w", encoding="utf-8"
108+
) as fh:
109+
fh.write("rules: {}\n")
110+
111+
master = _Master()
112+
for _ in range(3):
113+
linter = _make_linter("JAVASCRIPT_ES")
114+
linter._gate_on_eslint10_config(
115+
{"workspace": workspace, "master": master}
116+
)
117+
118+
self.assertEqual(len(master.migration_warnings), 1)
119+
120+
121+
if __name__ == "__main__":
122+
unittest.main()

megalinter/utils_reporter.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ def build_markdown_summary_footer(reporter_self, action_run_url=""):
177177
if reporter_self.master.result_message != "":
178178
footer += reporter_self.master.result_message + os.linesep
179179

180+
migration_warnings = getattr(reporter_self.master, "migration_warnings", None)
181+
if migration_warnings:
182+
footer += os.linesep + "### Migration notices" + os.linesep + os.linesep
183+
for warning in migration_warnings:
184+
footer += f"- {warning}" + os.linesep
185+
footer += os.linesep
186+
180187
if action_run_url != "":
181188
footer += (
182189
"See detailed reports in [MegaLinter artifacts"

0 commit comments

Comments
 (0)