main
ts 564 lines 15 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 /**
9 * Comparison test: runs every ESLint test case with both the TS and Rust
10 * backends and asserts the diagnostics (message + line) are identical.
11 *
12 * Uses ESLint's Linter API directly (not RuleTester) so we can capture
13 * the full list of diagnostics per backend without throwing on the first
14 * mismatch.
15 */
16
17 import {Linter} from 'eslint';
18 import {configs} from '../src/index';
19 import {
20 allRules,
21 recommendedRules,
22 mapErrorSeverityToESlint,
23 } from '../src/rules/ReactCompilerRule';
24
25 // --------------------------------------------------------------------------
26 // Check Rust availability
27 // --------------------------------------------------------------------------
28 let rustAvailable = false;
29 try {
30 require('babel-plugin-react-compiler-rust');
31 rustAvailable = true;
32 } catch {
33 // Rust native module not built — skip all comparison tests
34 }
35
36 const describeIfRust = rustAvailable ? describe : describe.skip;
37
38 // --------------------------------------------------------------------------
39 // Helpers
40 // --------------------------------------------------------------------------
41
42 /**
43 * Remove leading indentation (same as normalizeIndent in shared-utils).
44 */
45 function normalizeIndent(strings: TemplateStringsArray): string {
46 const codeLines = strings[0].split('\n');
47 const leftPadding = codeLines[1]?.match(/\s+/)?.[0] ?? '';
48 return codeLines.map(line => line.slice(leftPadding.length)).join('\n');
49 }
50
51 interface DiagnosticSummary {
52 message: string;
53 line: number | null;
54 }
55
56 /**
57 * Lint `code` using all recommended rules and return sorted diagnostics.
58 */
59 function lintWithBackend(
60 code: string,
61 filename: string,
62 useRust: boolean,
63 ): DiagnosticSummary[] {
64 const linter = new Linter();
65
66 // Register all rules from the plugin
67 for (const [name, {rule}] of Object.entries(allRules)) {
68 linter.defineRule(`react-compiler/${name}`, rule);
69 }
70
71 // Build the rule config: enable all recommended rules at their default severity
72 const ruleConfig: Record<string, Linter.RuleEntry> = {};
73 for (const [name, ruleEntry] of Object.entries(recommendedRules)) {
74 const severity = mapErrorSeverityToESlint(ruleEntry.severity);
75 if (severity === 'off') continue;
76 const opts: Record<string, unknown> = {};
77 if (useRust) {
78 opts.__unstable_useRustCompiler = true;
79 }
80 ruleConfig[`react-compiler/${name}`] = [severity, opts];
81 }
82
83 const messages = linter.verify(
84 code,
85 {
86 parser: 'hermes-eslint',
87 parserOptions: {
88 ecmaVersion: 2015,
89 sourceType: 'module',
90 enableExperimentalComponentSyntax: true,
91 },
92 rules: ruleConfig,
93 },
94 {filename},
95 );
96
97 // Filter out parser errors — only keep rule diagnostics
98 const diagnostics: DiagnosticSummary[] = messages
99 .filter(m => m.ruleId != null)
100 .map(m => ({
101 message: m.message,
102 line: m.line ?? null,
103 }));
104
105 // Sort deterministically by line then message
106 diagnostics.sort((a, b) => {
107 const lineDiff = (a.line ?? 0) - (b.line ?? 0);
108 if (lineDiff !== 0) return lineDiff;
109 return a.message.localeCompare(b.message);
110 });
111
112 return diagnostics;
113 }
114
115 /**
116 * Lint with a specific single rule (not recommended set).
117 */
118 function lintWithRule(
119 code: string,
120 filename: string,
121 ruleName: string,
122 useRust: boolean,
123 ): DiagnosticSummary[] {
124 const linter = new Linter();
125
126 const ruleEntry = allRules[ruleName];
127 if (!ruleEntry) throw new Error(`Unknown rule: ${ruleName}`);
128
129 linter.defineRule(`react-compiler/${ruleName}`, ruleEntry.rule);
130
131 const opts: Record<string, unknown> = {};
132 if (useRust) {
133 opts.__unstable_useRustCompiler = true;
134 }
135 const severity = mapErrorSeverityToESlint(ruleEntry.severity);
136 if (severity === 'off') return [];
137
138 const messages = linter.verify(
139 code,
140 {
141 parser: 'hermes-eslint',
142 parserOptions: {
143 ecmaVersion: 2015,
144 sourceType: 'module',
145 enableExperimentalComponentSyntax: true,
146 },
147 rules: {
148 [`react-compiler/${ruleName}`]: [severity, opts],
149 },
150 },
151 {filename},
152 );
153
154 const diagnostics: DiagnosticSummary[] = messages
155 .filter(m => m.ruleId != null)
156 .map(m => ({
157 message: m.message,
158 line: m.line ?? null,
159 }));
160
161 diagnostics.sort((a, b) => {
162 const lineDiff = (a.line ?? 0) - (b.line ?? 0);
163 if (lineDiff !== 0) return lineDiff;
164 return a.message.localeCompare(b.message);
165 });
166
167 return diagnostics;
168 }
169
170 // --------------------------------------------------------------------------
171 // Test case catalog — every test case from the existing test files
172 // --------------------------------------------------------------------------
173
174 interface ComparisonTestCase {
175 name: string;
176 code: string;
177 filename?: string;
178 /** Which rule to test in isolation, or 'recommended' for all */
179 rule: string;
180 expectedErrorCount: number;
181 }
182
183 // Gather all test cases from across the test suite. We replicate the test
184 // data here so the comparison is self-contained.
185
186 const testCases: ComparisonTestCase[] = [
187 // ---- PluginTest-test.ts (recommended rules) ----
188 {
189 name: '[PluginTest] Basic example with component syntax',
190 code: normalizeIndent`
191 export default component HelloWorld(
192 text: string = 'Hello!',
193 onClick: () => void,
194 ) {
195 return <div onClick={onClick}>{text}</div>;
196 }
197 `,
198 rule: 'recommended',
199 expectedErrorCount: 0,
200 },
201 {
202 name: '[PluginTest] [Invariant] Defined after use',
203 code: normalizeIndent`
204 function Component(props) {
205 let y = function () {
206 m(x);
207 };
208
209 let x = { a };
210 m(x);
211 return y;
212 }
213 `,
214 rule: 'recommended',
215 expectedErrorCount: 0,
216 },
217 {
218 name: "[PluginTest] Classes don't throw",
219 code: normalizeIndent`
220 class Foo {
221 #bar() {}
222 }
223 `,
224 rule: 'recommended',
225 expectedErrorCount: 0,
226 },
227 {
228 name: '[PluginTest] Multiple diagnostic kinds from the same function',
229 code: normalizeIndent`
230 import Child from './Child';
231 function Component() {
232 const result = cond ?? useConditionalHook();
233 return <>
234 {Child(result)}
235 </>;
236 }
237 `,
238 rule: 'recommended',
239 expectedErrorCount: 2,
240 },
241 {
242 name: '[PluginTest] Multiple diagnostics within the same file',
243 code: normalizeIndent`
244 function useConditional1() {
245 'use memo';
246 return cond ?? useConditionalHook();
247 }
248 function useConditional2(props) {
249 'use memo';
250 return props.cond && useConditionalHook();
251 }
252 `,
253 rule: 'recommended',
254 expectedErrorCount: 2,
255 },
256 {
257 name: "[PluginTest] 'use no forget' does not disable eslint rule",
258 code: normalizeIndent`
259 let count = 0;
260 function Component() {
261 'use no forget';
262 return cond ?? useConditionalHook();
263
264 }
265 `,
266 rule: 'recommended',
267 expectedErrorCount: 1,
268 },
269 {
270 name: '[PluginTest] Multiple non-fatal useMemo diagnostics',
271 code: normalizeIndent`
272 import {useMemo, useState} from 'react';
273
274 function Component({item, cond}) {
275 const [prevItem, setPrevItem] = useState(item);
276 const [state, setState] = useState(0);
277
278 useMemo(() => {
279 if (cond) {
280 setPrevItem(item);
281 setState(0);
282 }
283 }, [cond, item, init]);
284
285 return <Child x={state} />;
286 }
287 `,
288 rule: 'recommended',
289 expectedErrorCount: 4,
290 },
291
292 // ---- InvalidHooksRule-test.ts ----
293 {
294 name: '[InvalidHooks] Basic example (valid)',
295 code: normalizeIndent`
296 function Component() {
297 useHook();
298 return <div>Hello world</div>;
299 }
300 `,
301 rule: 'recommended',
302 expectedErrorCount: 0,
303 },
304 {
305 name: '[InvalidHooks] Violation with Flow suppression (valid)',
306 code: `
307 // Valid since error already suppressed with flow.
308 function useHook() {
309 if (cond) {
310 // $FlowFixMe[react-rule-hook]
311 useConditionalHook();
312 }
313 }
314 `,
315 rule: 'recommended',
316 expectedErrorCount: 0,
317 },
318 {
319 name: '[InvalidHooks] Simple violation',
320 code: normalizeIndent`
321 function useConditional() {
322 if (cond) {
323 useConditionalHook();
324 }
325 }
326 `,
327 rule: 'recommended',
328 expectedErrorCount: 1,
329 },
330 {
331 name: '[InvalidHooks] Multiple diagnostics within the same function',
332 code: normalizeIndent`
333 function useConditional() {
334 cond ?? useConditionalHook();
335 props.cond && useConditionalHook();
336 return <div>Hello world</div>;
337 }
338 `,
339 rule: 'recommended',
340 expectedErrorCount: 2,
341 },
342
343 // ---- ImpureFunctionCallsRule-test.ts ----
344 {
345 name: '[ImpureFunctionCalls] Known impure function calls are caught',
346 code: normalizeIndent`
347 function Component() {
348 const date = Date.now();
349 const now = performance.now();
350 const rand = Math.random();
351 return <Foo date={date} now={now} rand={rand} />;
352 }
353 `,
354 rule: 'recommended',
355 expectedErrorCount: 3,
356 },
357
358 // ---- NoCapitalizedCallsRule-test.ts ----
359 {
360 name: '[NoCapitalizedCalls] Simple violation',
361 code: normalizeIndent`
362 import Child from './Child';
363 function Component() {
364 return <>
365 {Child()}
366 </>;
367 }
368 `,
369 rule: 'recommended',
370 expectedErrorCount: 1,
371 },
372 {
373 name: '[NoCapitalizedCalls] Method call violation',
374 code: normalizeIndent`
375 import myModule from './MyModule';
376 function Component() {
377 return <>
378 {myModule.Child()}
379 </>;
380 }
381 `,
382 rule: 'recommended',
383 expectedErrorCount: 1,
384 },
385 {
386 name: '[NoCapitalizedCalls] Multiple diagnostics',
387 code: normalizeIndent`
388 import Child1 from './Child1';
389 import MyModule from './MyModule';
390 function Component() {
391 return <>
392 {Child1()}
393 {MyModule.Child2()}
394 </>;
395 }
396 `,
397 rule: 'recommended',
398 expectedErrorCount: 2,
399 },
400
401 // ---- NoAmbiguousJsxRule-test.ts ----
402 {
403 name: '[NoAmbiguousJsx] JSX in try blocks',
404 code: normalizeIndent`
405 function Component(props) {
406 let el;
407 try {
408 el = <Child />;
409 } catch {
410 return null;
411 }
412 return el;
413 }
414 `,
415 rule: 'recommended',
416 expectedErrorCount: 1,
417 },
418
419 // ---- NoRefAccessInRender-tests.ts ----
420 {
421 name: '[NoRefAccessInRender] Simple ref access in render',
422 code: normalizeIndent`
423 function Component(props) {
424 const ref = useRef(null);
425 const value = ref.current;
426 return value;
427 }
428 `,
429 rule: 'recommended',
430 expectedErrorCount: 1,
431 },
432
433 // ---- ReactCompilerRuleTypescript-test.ts ----
434 {
435 name: '[TypeScript] Basic example (valid)',
436 code: normalizeIndent`
437 function Button(props) {
438 return null;
439 }
440 `,
441 filename: 'test.tsx',
442 rule: 'recommended',
443 expectedErrorCount: 0,
444 },
445 {
446 name: '[TypeScript] Repro for hooks as normal values',
447 code: normalizeIndent`
448 function Button(props) {
449 const scrollview = React.useRef<ScrollView>(null);
450 return <Button thing={scrollview} />;
451 }
452 `,
453 filename: 'test.tsx',
454 rule: 'recommended',
455 expectedErrorCount: 0,
456 },
457 {
458 name: '[TypeScript] Mutating useState value',
459 code: `
460 import { useState } from 'react';
461 function Component(props) {
462 // typescript syntax that hermes-parser doesn't understand yet
463 const x: \`foo\${1}\` = 'foo1';
464 const [state, setState] = useState({a: 0});
465 state.a = 1;
466 return <div>{props.foo}</div>;
467 }
468 `,
469 filename: 'test.tsx',
470 rule: 'recommended',
471 expectedErrorCount: 1,
472 },
473 ];
474
475 // --------------------------------------------------------------------------
476 // Tests
477 // --------------------------------------------------------------------------
478
479 describeIfRust('TS vs Rust backend comparison', () => {
480 const results: Array<{
481 name: string;
482 ts: DiagnosticSummary[];
483 rust: DiagnosticSummary[];
484 match: boolean;
485 }> = [];
486
487 for (const tc of testCases) {
488 test(tc.name, () => {
489 const filename = tc.filename ?? 'test.js';
490 const tsDiags = lintWithBackend(tc.code, filename, false);
491 const rustDiags = lintWithBackend(tc.code, filename, true);
492
493 results.push({
494 name: tc.name,
495 ts: tsDiags,
496 rust: rustDiags,
497 match: JSON.stringify(tsDiags) === JSON.stringify(rustDiags),
498 });
499
500 // First check: both backends agree on error count
501 if (tsDiags.length !== rustDiags.length) {
502 const tsMessages = tsDiags
503 .map(d => ` L${d.line}: ${d.message}`)
504 .join('\n');
505 const rustMessages = rustDiags
506 .map(d => ` L${d.line}: ${d.message}`)
507 .join('\n');
508 console.log(
509 `\n⚠️ DIAGNOSTIC COUNT MISMATCH: ${tc.name}\n` +
510 ` TS (${tsDiags.length}):\n${tsMessages || ' (none)'}\n` +
511 ` Rust (${rustDiags.length}):\n${rustMessages || ' (none)'}\n`,
512 );
513 }
514
515 // Second check: messages match in content
516 // We compare sorted diagnostics — message text should be identical
517 const tsMessages = tsDiags.map(d => d.message);
518 const rustMessages = rustDiags.map(d => d.message);
519
520 // Log detailed diff for any mismatch
521 if (JSON.stringify(tsDiags) !== JSON.stringify(rustDiags)) {
522 console.log(
523 `\n⚠️ DIAGNOSTIC MISMATCH: ${tc.name}\n` +
524 ` TS diagnostics:\n${tsDiags.map(d => ` L${d.line}: ${d.message}`).join('\n') || ' (none)'}\n` +
525 ` Rust diagnostics:\n${rustDiags.map(d => ` L${d.line}: ${d.message}`).join('\n') || ' (none)'}\n`,
526 );
527 }
528
529 // Assert equality — both count and messages should match
530 expect(rustDiags.length).toBe(tsDiags.length);
531 expect(rustMessages).toEqual(tsMessages);
532 });
533 }
534
535 // Summary — printed once after all tests
536 afterAll(() => {
537 const total = results.length;
538 const matches = results.filter(r => r.match).length;
539 const mismatches = results.filter(r => !r.match);
540
541 console.log('\n' + '='.repeat(70));
542 console.log(`TS vs Rust ESLint Backend Comparison`);
543 console.log('='.repeat(70));
544 console.log(`Total test cases: ${total}`);
545 console.log(`Matching: ${matches}`);
546 console.log(`Mismatches: ${mismatches.length}`);
547
548 if (mismatches.length > 0) {
549 console.log('\nMismatched cases:');
550 for (const m of mismatches) {
551 console.log(`\n ❌ ${m.name}`);
552 console.log(
553 ` TS (${m.ts.length}): ${m.ts.map(d => `L${d.line}:${d.message.slice(0, 60)}`).join(' | ') || '(none)'}`,
554 );
555 console.log(
556 ` Rust(${m.rust.length}): ${m.rust.map(d => `L${d.line}:${d.message.slice(0, 60)}`).join(' | ') || '(none)'}`,
557 );
558 }
559 } else {
560 console.log('\n✅ All diagnostics match between TS and Rust backends!');
561 }
562 console.log('='.repeat(70) + '\n');
563 });
564 });