Skip to content

Commit 9c21fdd

Browse files
authored
perf: compat (#680)
1 parent 367fc02 commit 9c21fdd

2 files changed

Lines changed: 209 additions & 22 deletions

File tree

src/rules/compat.ts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Rule } from "eslint";
99
import findUp from "find-up";
1010
import fs from "fs";
1111
import memoize from "lodash.memoize";
12+
import path from "path";
1213
import {
1314
determineTargetsFromConfig,
1415
lintCallExpression,
@@ -91,24 +92,27 @@ const babelConfigs = [
9192

9293
/**
9394
* Determine if a user has a babel config, which we use to infer if the linted code is polyfilled.
95+
* Memoized by directory so multiple files in the same project reuse the result.
9496
*/
95-
function isUsingTranspiler(context: Context): boolean {
96-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
97-
const dir = (context as any).filename ?? (context as any).getFilename();
98-
const configPath = findUp.sync(babelConfigs, {
99-
cwd: dir,
100-
});
101-
if (configPath) return true;
102-
const pkgPath = findUp.sync("package.json", {
103-
cwd: dir,
104-
});
105-
// Check if babel property exists
106-
if (pkgPath) {
107-
const pkg = JSON.parse(fs.readFileSync(pkgPath).toString());
108-
return !!pkg.babel;
109-
}
110-
return false;
111-
}
97+
const isUsingTranspiler = memoize(
98+
(filePath: string): boolean => {
99+
const dir = path.dirname(filePath);
100+
const configPath = findUp.sync(babelConfigs, {
101+
cwd: dir,
102+
});
103+
if (configPath) return true;
104+
const pkgPath = findUp.sync("package.json", {
105+
cwd: dir,
106+
});
107+
// Check if babel property exists
108+
if (pkgPath) {
109+
const pkg = JSON.parse(fs.readFileSync(pkgPath).toString());
110+
return !!pkg.babel;
111+
}
112+
return false;
113+
},
114+
(filePath: string) => path.resolve(path.dirname(filePath))
115+
);
112116

113117
type RulesFilteredByTargets = {
114118
CallExpression: AstMetadataApiWithTargetsResolver[];
@@ -181,14 +185,14 @@ export default {
181185
const browserslistOpts: BrowsersListOpts | undefined =
182186
context.settings?.browserslistOpts;
183187

188+
const browserslistDir =
189+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
190+
(context as any).filename ?? (context as any).getFilename();
184191
const lintAllEsApis: boolean =
185192
context.settings?.lintAllEsApis === true ||
186193
// Attempt to infer polyfilling of ES APIs from babel config
187194
(!context.settings?.polyfills?.includes("es:all") &&
188-
!isUsingTranspiler(context));
189-
const browserslistDir =
190-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
191-
(context as any).filename ?? (context as any).getFilename();
195+
!isUsingTranspiler(browserslistDir));
192196
const browserslistTargets = parseBrowsersListVersion(
193197
determineTargetsFromConfig(
194198
browserslistDir,
@@ -210,6 +214,21 @@ export default {
210214

211215
const errors: Error[] = [];
212216

217+
// Cache getUnsupportedTargets per rule; targets are fixed for this context.
218+
const unsupportedTargetsByRule = new Map<string, string>();
219+
const getUnsupportedTargetsMessage = (
220+
rule: AstMetadataApiWithTargetsResolver
221+
): string => {
222+
let message = unsupportedTargetsByRule.get(rule.id);
223+
if (message === undefined) {
224+
message = rule
225+
.getUnsupportedTargets(rule, browserslistTargets)
226+
.join(", ");
227+
unsupportedTargetsByRule.set(rule.id, message);
228+
}
229+
return message;
230+
};
231+
213232
const handleFailingRule: HandleFailingRule = (
214233
node: AstMetadataApiWithTargetsResolver,
215234
eslintNode: ESLintNode
@@ -220,7 +239,7 @@ export default {
220239
message: [
221240
generateErrorName(node),
222241
"is not supported in",
223-
node.getUnsupportedTargets(node, browserslistTargets).join(", "),
242+
getUnsupportedTargetsMessage(node),
224243
].join(" "),
225244
});
226245
};
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* Tests for isUsingTranspiler behavior: babel config detection, memoization by directory,
3+
* and backwards compatibility with file path (not directory) input.
4+
*/
5+
import { promises as fs } from "fs";
6+
import os from "os";
7+
import path from "path";
8+
import { ESLint } from "eslint";
9+
import compat from "../src";
10+
11+
describe("isUsingTranspiler (babel config detection)", () => {
12+
const eslintBaseConfig = {
13+
overrideConfigFile: true,
14+
ignore: false,
15+
baseConfig: [
16+
compat.configs["flat/recommended"],
17+
{
18+
languageOptions: {
19+
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
20+
},
21+
settings: {
22+
browsers: ["ie 10"],
23+
},
24+
},
25+
],
26+
};
27+
28+
const codeWithEsApi = "Array.from([1, 2, 3]);";
29+
30+
it("does not report ES APIs when babel config exists in directory (polyfilled)", async () => {
31+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "compat-babel-"));
32+
try {
33+
await fs.writeFile(
34+
path.join(tmpDir, "babel.config.json"),
35+
JSON.stringify({ presets: ["@babel/env"] })
36+
);
37+
const filePath = path.join(tmpDir, "src", "index.js");
38+
await fs.mkdir(path.dirname(filePath), { recursive: true });
39+
40+
// @ts-expect-error Bug? ESLint flat config types
41+
const eslint = new ESLint({
42+
...eslintBaseConfig,
43+
cwd: tmpDir,
44+
});
45+
const results = await eslint.lintText(codeWithEsApi, {
46+
filePath,
47+
});
48+
49+
expect(results[0].messages).toHaveLength(0);
50+
} finally {
51+
await fs.rm(tmpDir, { recursive: true, force: true });
52+
}
53+
});
54+
55+
it("reports ES APIs when lintAllEsApis is true (backwards compatible behavior)", async () => {
56+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "compat-lintAllEsApis-"));
57+
try {
58+
const filePath = path.join(tmpDir, "index.js");
59+
await fs.writeFile(filePath, codeWithEsApi);
60+
61+
const eslint = new ESLint({
62+
...eslintBaseConfig,
63+
baseConfig: [
64+
compat.configs["flat/recommended"],
65+
{
66+
languageOptions: {
67+
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
68+
},
69+
settings: {
70+
browsers: ["ie 8"],
71+
lintAllEsApis: true,
72+
},
73+
},
74+
],
75+
overrideConfigFile: true,
76+
ignore: false,
77+
cwd: tmpDir,
78+
});
79+
const results = await eslint.lintFiles([filePath]);
80+
81+
expect(results[0].messages.length).toBeGreaterThan(0);
82+
expect(results[0].messages[0].ruleId).toBe("compat/compat");
83+
} finally {
84+
await fs.rm(tmpDir, { recursive: true, force: true });
85+
}
86+
});
87+
88+
it("accepts file path (not directory) and correctly infers directory via path.dirname", async () => {
89+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "compat-filepath-"));
90+
try {
91+
await fs.writeFile(
92+
path.join(tmpDir, "babel.config.json"),
93+
JSON.stringify({ presets: ["@babel/env"] })
94+
);
95+
const filePath = path.join(tmpDir, "deep", "nested", "file.js");
96+
await fs.mkdir(path.dirname(filePath), { recursive: true });
97+
98+
// @ts-expect-error Bug? ESLint flat config types
99+
const eslint = new ESLint({
100+
...eslintBaseConfig,
101+
cwd: tmpDir,
102+
});
103+
const results = await eslint.lintText(codeWithEsApi, {
104+
filePath,
105+
});
106+
107+
expect(results[0].messages).toHaveLength(0);
108+
} finally {
109+
await fs.rm(tmpDir, { recursive: true, force: true });
110+
}
111+
});
112+
113+
it("memoizes by directory: multiple files in same directory share cached result", async () => {
114+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "compat-memo-"));
115+
try {
116+
await fs.writeFile(
117+
path.join(tmpDir, "babel.config.json"),
118+
JSON.stringify({ presets: ["@babel/env"] })
119+
);
120+
const file1 = path.join(tmpDir, "src", "a.js");
121+
const file2 = path.join(tmpDir, "src", "b.js");
122+
await fs.mkdir(path.dirname(file1), { recursive: true });
123+
124+
// @ts-expect-error Bug? ESLint flat config types
125+
const eslint = new ESLint({
126+
...eslintBaseConfig,
127+
cwd: tmpDir,
128+
});
129+
const [results1, results2] = await Promise.all([
130+
eslint.lintText(codeWithEsApi, { filePath: file1 }),
131+
eslint.lintText(codeWithEsApi, { filePath: file2 }),
132+
]);
133+
134+
expect(results1[0].messages).toHaveLength(0);
135+
expect(results2[0].messages).toHaveLength(0);
136+
} finally {
137+
await fs.rm(tmpDir, { recursive: true, force: true });
138+
}
139+
});
140+
141+
it("detects babel via package.json babel property when no babel config file", async () => {
142+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "compat-pkgbabel-"));
143+
try {
144+
await fs.writeFile(
145+
path.join(tmpDir, "package.json"),
146+
JSON.stringify({
147+
name: "test",
148+
version: "1.0.0",
149+
babel: { presets: ["@babel/env"] },
150+
})
151+
);
152+
const filePath = path.join(tmpDir, "index.js");
153+
154+
// @ts-expect-error Bug? ESLint flat config types
155+
const eslint = new ESLint({
156+
...eslintBaseConfig,
157+
cwd: tmpDir,
158+
});
159+
const results = await eslint.lintText(codeWithEsApi, {
160+
filePath,
161+
});
162+
163+
expect(results[0].messages).toHaveLength(0);
164+
} finally {
165+
await fs.rm(tmpDir, { recursive: true, force: true });
166+
}
167+
});
168+
});

0 commit comments

Comments
 (0)