diff --git a/README.md b/README.md index 89390df..403adc3 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (50 total) +## Available Rules (51 total) ### Expo Router Rules @@ -155,6 +155,7 @@ const backendRules = getRulesForPlatform('backend'); | ---------------------------- | -------- | -------- | ----------------------------------------------------------------- | | `require-use-client` | error | web | Files using client-only features must have "use client" directive | | `no-server-import-in-client` | error | web | "use client" files must not import server-only modules | +| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) | ### React / JSX Rules diff --git a/src/rules/index.ts b/src/rules/index.ts index dd01b2a..753f44d 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -49,6 +49,7 @@ import { noSyncFs } from './no-sync-fs'; import { preferNamedParams } from './prefer-named-params'; import { requireUseClient } from './require-use-client'; import { noServerImportInClient } from './no-server-import-in-client'; +import { noModuleLevelNew } from './no-module-level-new'; export const rules: Record = { 'no-relative-paths': noRelativePaths, @@ -101,4 +102,5 @@ export const rules: Record = { 'prefer-named-params': preferNamedParams, 'require-use-client': requireUseClient, 'no-server-import-in-client': noServerImportInClient, + 'no-module-level-new': noModuleLevelNew, }; diff --git a/src/rules/meta.ts b/src/rules/meta.ts index ef0a579..9d8c30e 100644 --- a/src/rules/meta.ts +++ b/src/rules/meta.ts @@ -32,6 +32,7 @@ export const rulePlatforms: Partial> = { 'no-tailwind-animation-classes': ['web'], 'require-use-client': ['web'], 'no-server-import-in-client': ['web'], + 'no-module-level-new': ['web'], // Expo + Web (shared frontend) 'no-relative-paths': ['expo', 'web'], diff --git a/src/rules/no-module-level-new.ts b/src/rules/no-module-level-new.ts new file mode 100644 index 0000000..0b903b9 --- /dev/null +++ b/src/rules/no-module-level-new.ts @@ -0,0 +1,95 @@ +import traverse from '@babel/traverse'; +import type { File } from '@babel/types'; +import type { LintResult } from '../types'; + +const RULE_NAME = 'no-module-level-new'; + +const SAFE_CONSTRUCTORS = new Set([ + 'Error', + 'TypeError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'URIError', + 'URL', + 'URLSearchParams', + 'RegExp', + 'Map', + 'Set', + 'WeakMap', + 'WeakSet', + 'Date', + 'Promise', + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array', + 'Float32Array', + 'Float64Array', + 'BigInt64Array', + 'BigUint64Array', + 'ArrayBuffer', + 'SharedArrayBuffer', + 'DataView', + 'TextEncoder', + 'TextDecoder', +]); + +export function noModuleLevelNew(ast: File, _code: string): LintResult[] { + const results: LintResult[] = []; + + traverse(ast, { + NewExpression(path) { + const { callee, loc } = path.node; + + let name: string; + if (callee.type === 'Identifier') { + name = callee.name; + } else if ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' + ) { + name = `${callee.object.name}.${ + callee.property.type === 'Identifier' ? callee.property.name : '...' + }`; + } else { + name = 'Expression'; + } + + if (SAFE_CONSTRUCTORS.has(name)) { + return; + } + + let currentPath = path.parentPath; + while (currentPath) { + const { type } = currentPath.node; + if ( + type === 'FunctionDeclaration' || + type === 'FunctionExpression' || + type === 'ArrowFunctionExpression' || + type === 'ClassDeclaration' || + type === 'ClassExpression' || + type === 'ClassMethod' || + type === 'ClassPrivateMethod' || + type === 'ObjectMethod' + ) { + return; + } + currentPath = currentPath.parentPath as typeof currentPath; + } + + results.push({ + rule: RULE_NAME, + message: `\`new ${name}()\` at module level will execute during SSR and may crash or cause side effects. Move it inside a component, useEffect, or wrap it in a factory function.`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + }, + }); + + return results; +} diff --git a/tests/config-modes.test.ts b/tests/config-modes.test.ts index 112bd5e..81092d3 100644 --- a/tests/config-modes.test.ts +++ b/tests/config-modes.test.ts @@ -123,7 +123,7 @@ describe('config modes', () => { expect(ruleNames).toContain('no-relative-paths'); expect(ruleNames).toContain('expo-image-import'); expect(ruleNames).toContain('no-stylesheet-create'); - expect(ruleNames.length).toBe(50); + expect(ruleNames.length).toBe(51); }); }); }); diff --git a/tests/no-module-level-new.test.ts b/tests/no-module-level-new.test.ts new file mode 100644 index 0000000..216a571 --- /dev/null +++ b/tests/no-module-level-new.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const config = { rules: ['no-module-level-new'] }; + +describe('no-module-level-new rule', () => { + it('should flag new QueryClient() at module level', () => { + const code = ` + import { QueryClient } from '@tanstack/react-query'; + const queryClient = new QueryClient(); + export default function Layout({ children }) { + return
{children}
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe('no-module-level-new'); + expect(results[0].message).toContain('new QueryClient()'); + expect(results[0].severity).toBe('error'); + }); + + it('should flag new IntersectionObserver() at module level', () => { + const code = ` + const observer = new IntersectionObserver(callback); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('IntersectionObserver'); + }); + + it('should flag unknown constructors at module level', () => { + const code = ` + const thing = new SomeUnknownClass(); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('SomeUnknownClass'); + }); + + it('should not flag new QueryClient() inside a function body', () => { + const code = ` + function createClient() { + return new QueryClient(); + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag new QueryClient() inside useEffect callback', () => { + const code = ` + function Component() { + useEffect(() => { + const qc = new QueryClient(); + }, []); + return
Hello
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag new QueryClient() inside arrow function', () => { + const code = ` + const makeClient = () => { + return new QueryClient(); + }; + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag new Error() at module level', () => { + const code = ` + const NOT_FOUND = new Error('Not found'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag new URL() at module level', () => { + const code = ` + const baseUrl = new URL('https://example.com'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag new Map() at module level', () => { + const code = ` + const cache = new Map(); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag new RegExp() at module level', () => { + const code = ` + const pattern = new RegExp('\\\\d+'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag new Set() at module level', () => { + const code = ` + const seen = new Set(); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag new Date() at module level', () => { + const code = ` + const start = new Date(); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag constructors inside class methods', () => { + const code = ` + class MyService { + init() { + this.client = new QueryClient(); + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag constructors inside class bodies via property initializer', () => { + const code = ` + class MyService { + createClient() { + return new QueryClient(); + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should flag multiple module-level new expressions', () => { + const code = ` + const a = new QueryClient(); + const b = new WebSocket('ws://localhost'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(2); + }); +});