From b4015083b7d56014b2b5b5a6354d7ba46530ef08 Mon Sep 17 00:00:00 2001 From: Zobeir Hamid Date: Wed, 25 Feb 2026 12:25:20 -0800 Subject: [PATCH] feat: adds nextjs lint --- README.md | 52 ++++++++- src/rules/index.ts | 4 + src/rules/meta.ts | 2 + src/rules/no-server-import-in-client.ts | 77 +++++++++++++ src/rules/require-use-client.ts | 79 +++++++++++++ tests/config-modes.test.ts | 2 +- tests/no-server-import-in-client.test.ts | 106 ++++++++++++++++++ tests/require-use-client.test.ts | 135 +++++++++++++++++++++++ 8 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 src/rules/no-server-import-in-client.ts create mode 100644 src/rules/require-use-client.ts create mode 100644 tests/no-server-import-in-client.test.ts create mode 100644 tests/require-use-client.test.ts diff --git a/README.md b/README.md index 6ed0853..89390df 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (45 total) +## Available Rules (50 total) ### Expo Router Rules @@ -149,6 +149,13 @@ const backendRules = getRulesForPlatform('backend'); | `glass-interactive-prop` | warning | expo | GlassView in pressables needs `isInteractive={true}` | | `glass-no-opacity-animation` | warning | expo | No opacity animations on GlassView | +### Next.js Rules + +| Rule | Severity | Platform | Description | +| ---------------------------- | -------- | -------- | ----------------------------------------------------------------- | +| `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 | + ### React / JSX Rules | Rule | Severity | Platform | Description | @@ -320,6 +327,49 @@ When using NativeTabs from expo-router/unstable-native-tabs, each screen needs 6 ``` +### `require-use-client` + +```tsx +// Bad - uses hooks without "use client" +import { useState } from 'react'; + +export function Counter() { + const [count, setCount] = useState(0); + return ; +} + +// Good - has "use client" directive +('use client'); + +import { useState } from 'react'; + +export function Counter() { + const [count, setCount] = useState(0); + return ; +} +``` + +### `no-server-import-in-client` + +```tsx +// Bad - server-only module in client file +'use client'; + +import { cookies } from 'next/headers'; + +export function UserMenu() { + return
Menu
; +} + +// Good - use server-only modules only in server components +import { cookies } from 'next/headers'; + +export function UserMenu() { + const session = cookies().get('session'); + return
Menu
; +} +``` + ### `no-complex-jsx-expressions` ```jsx diff --git a/src/rules/index.ts b/src/rules/index.ts index 1f89a10..dd01b2a 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -47,6 +47,8 @@ import { noManualRetryLoop } from './no-manual-retry-loop'; import { noEmojiIcons } from './no-emoji-icons'; 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'; export const rules: Record = { 'no-relative-paths': noRelativePaths, @@ -97,4 +99,6 @@ export const rules: Record = { 'no-emoji-icons': noEmojiIcons, 'no-sync-fs': noSyncFs, 'prefer-named-params': preferNamedParams, + 'require-use-client': requireUseClient, + 'no-server-import-in-client': noServerImportInClient, }; diff --git a/src/rules/meta.ts b/src/rules/meta.ts index c93fc7e..ef0a579 100644 --- a/src/rules/meta.ts +++ b/src/rules/meta.ts @@ -30,6 +30,8 @@ export const rulePlatforms: Partial> = { 'no-inline-script-code': ['web'], 'browser-api-in-useeffect': ['web'], 'no-tailwind-animation-classes': ['web'], + 'require-use-client': ['web'], + 'no-server-import-in-client': ['web'], // Expo + Web (shared frontend) 'no-relative-paths': ['expo', 'web'], diff --git a/src/rules/no-server-import-in-client.ts b/src/rules/no-server-import-in-client.ts new file mode 100644 index 0000000..bc438d9 --- /dev/null +++ b/src/rules/no-server-import-in-client.ts @@ -0,0 +1,77 @@ +import traverse from '@babel/traverse'; +import type { File } from '@babel/types'; +import type { LintResult } from '../types'; + +const RULE_NAME = 'no-server-import-in-client'; + +const SERVER_ONLY_MODULES = ['server-only', 'next/headers']; + +function isServerOnlyModule(source: string): boolean { + return SERVER_ONLY_MODULES.some((mod) => source === mod); +} + +export function noServerImportInClient(ast: File, _code: string): LintResult[] { + const hasUseClient = ast.program.directives.some((d) => d.value.value === 'use client'); + + if (!hasUseClient) { + return []; + } + + const results: LintResult[] = []; + + traverse(ast, { + ImportDeclaration(path) { + // Skip type-only imports (erased at compile time) + if (path.node.importKind === 'type') return; + + const source = path.node.source.value; + const { loc } = path.node; + + if (isServerOnlyModule(source)) { + results.push({ + rule: RULE_NAME, + message: `'${source}' is a server-only module and cannot be imported in a "use client" file.`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + }, + + ExportNamedDeclaration(path) { + const { source, loc } = path.node; + if (!source) return; + + if (path.node.exportKind === 'type') return; + + if (isServerOnlyModule(source.value)) { + results.push({ + rule: RULE_NAME, + message: `'${source.value}' is a server-only module and cannot be re-exported from a "use client" file.`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + }, + + ExportAllDeclaration(path) { + const source = path.node.source.value; + const { loc } = path.node; + + if (path.node.exportKind === 'type') return; + + if (isServerOnlyModule(source)) { + results.push({ + rule: RULE_NAME, + message: `'${source}' is a server-only module and cannot be re-exported from a "use client" file.`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + }, + }); + + return results; +} diff --git a/src/rules/require-use-client.ts b/src/rules/require-use-client.ts new file mode 100644 index 0000000..338188c --- /dev/null +++ b/src/rules/require-use-client.ts @@ -0,0 +1,79 @@ +import traverse from '@babel/traverse'; +import type { File } from '@babel/types'; +import type { LintResult } from '../types'; + +const RULE_NAME = 'require-use-client'; + +export function requireUseClient(ast: File, _code: string): LintResult[] { + const hasDirective = ast.program.directives.some( + (d) => d.value.value === 'use client' || d.value.value === 'use server', + ); + + if (hasDirective) { + return []; + } + + const results: LintResult[] = []; + + traverse(ast, { + CallExpression(path) { + const { callee, loc } = path.node; + + // React hooks: useState(), useEffect(), useRef(), etc. + if (callee.type === 'Identifier' && /^use[A-Z]/.test(callee.name)) { + results.push({ + rule: RULE_NAME, + message: `'${callee.name}()' is a client-only hook. Add "use client" at the top of this file.`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + + // createContext() + if (callee.type === 'Identifier' && callee.name === 'createContext') { + results.push({ + rule: RULE_NAME, + message: `'createContext()' is client-only. Add "use client" at the top of this file.`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + + // React.createContext() + if ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'React' && + callee.property.type === 'Identifier' && + callee.property.name === 'createContext' + ) { + results.push({ + rule: RULE_NAME, + message: `'React.createContext()' is client-only. Add "use client" at the top of this file.`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + }, + + JSXAttribute(path) { + const { name, loc } = path.node; + + // Event handler props: onClick, onChange, onSubmit, etc. + if (name.type === 'JSXIdentifier' && /^on[A-Z]/.test(name.name)) { + results.push({ + rule: RULE_NAME, + message: `'${name.name}' is a client-only event handler. Add "use client" at the top of this file.`, + 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 9a3e1ea..112bd5e 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(48); + expect(ruleNames.length).toBe(50); }); }); }); diff --git a/tests/no-server-import-in-client.test.ts b/tests/no-server-import-in-client.test.ts new file mode 100644 index 0000000..7003810 --- /dev/null +++ b/tests/no-server-import-in-client.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const config = { rules: ['no-server-import-in-client'] }; + +describe('no-server-import-in-client rule', () => { + it('should flag server-only import in "use client" file', () => { + const code = ` + "use client"; + import 'server-only'; + export function Component() { + return
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe('no-server-import-in-client'); + expect(results[0].message).toContain('server-only'); + expect(results[0].severity).toBe('error'); + }); + + it('should flag next/headers import in "use client" file', () => { + const code = ` + "use client"; + import { headers } from 'next/headers'; + export function Component() { + return
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('next/headers'); + }); + + it('should flag multiple server-only imports', () => { + const code = ` + "use client"; + import 'server-only'; + import { headers } from 'next/headers'; + export function Component() { + return
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(2); + }); + + it('should flag re-exports from server-only modules', () => { + const code = ` + "use client"; + export { headers } from 'next/headers'; + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('re-exported'); + }); + + it('should allow server-only imports without "use client" directive', () => { + const code = ` + import 'server-only'; + import { headers } from 'next/headers'; + export function ServerComponent() { + return
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow server-only imports in "use server" files', () => { + const code = ` + "use server"; + import { headers } from 'next/headers'; + export async function getHeaders() { + return headers(); + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow type-only imports from server modules', () => { + const code = ` + "use client"; + import type { HeadersFunction } from 'next/headers'; + export function Component() { + return
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow normal imports in "use client" files', () => { + const code = ` + "use client"; + import { useState } from 'react'; + import Link from 'next/link'; + export function Component() { + return Home; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); +}); diff --git a/tests/require-use-client.test.ts b/tests/require-use-client.test.ts new file mode 100644 index 0000000..1c31d74 --- /dev/null +++ b/tests/require-use-client.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const config = { rules: ['require-use-client'] }; + +describe('require-use-client rule', () => { + it('should flag useState without directive', () => { + const code = ` + import { useState } from 'react'; + function Counter() { + const [count, setCount] = useState(0); + return
{count}
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe('require-use-client'); + expect(results[0].message).toContain('useState()'); + expect(results[0].severity).toBe('error'); + }); + + it('should flag useEffect without directive', () => { + const code = ` + import { useEffect } from 'react'; + function Component() { + useEffect(() => {}, []); + return
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('useEffect()'); + }); + + it('should flag onClick event handler without directive', () => { + const code = ` + function Button() { + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('onClick'); + }); + + it('should flag multiple event handlers', () => { + const code = ` + function Form() { + return ( +
{}}> + {}} /> +
+ ); + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(2); + }); + + it('should flag createContext without directive', () => { + const code = ` + import { createContext } from 'react'; + const MyContext = createContext(null); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('createContext()'); + }); + + it('should flag React.createContext without directive', () => { + const code = ` + import React from 'react'; + const MyContext = React.createContext(null); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('React.createContext()'); + }); + + it('should flag multiple client-only features', () => { + const code = ` + import { useState } from 'react'; + function Counter() { + const [count, setCount] = useState(0); + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(2); + }); + + it('should allow code with "use client" directive', () => { + const code = ` + "use client"; + import { useState } from 'react'; + function Counter() { + const [count, setCount] = useState(0); + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow code with "use server" directive', () => { + const code = ` + "use server"; + export async function submitForm() { + const result = useFormState(); + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow code with no client-only features', () => { + const code = ` + export function ServerComponent() { + return
Hello World
; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag custom hook definitions (only calls)', () => { + const code = ` + export function useCustomHook() { + return 42; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); +});