diff --git a/README.md b/README.md index 93891fa..c0e7821 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (54 total) +## Available Rules (55 total) ### Expo Router Rules @@ -242,6 +242,11 @@ const backendRules = getRulesForPlatform('backend'); | `no-sync-fs` | error | backend | Use fs.promises or fs/promises instead of sync fs methods | | `no-unrestricted-loop-in-serverless` | error | backend | Unbounded loops (while(true), for(;;)) cause serverless timeouts | | `prefer-promise-all` | warning | universal | Use Promise.all instead of sequential await in for...of loops | +| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons | +| `no-react-native-in-web` | error | web | Don't import react-native in web modules (causes ESM failures) | +| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons | +| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) | +| `no-deprecated-url-parse` | warning | backend | Use `new URL()` instead of deprecated `url.parse()` | --- diff --git a/src/rules/index.ts b/src/rules/index.ts index e803798..dbb72a1 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -54,6 +54,7 @@ import { noReactNativeInWeb } from './no-react-native-in-web'; import { noModuleLevelNew } from './no-module-level-new'; import { noUnrestrictedLoopInServerless } from './no-unrestricted-loop-in-serverless'; import { preferPromiseAll } from './prefer-promise-all'; +import { noDeprecatedUrlParse } from './no-deprecated-url-parse'; export const rules: Record = { 'no-relative-paths': noRelativePaths, @@ -111,4 +112,5 @@ export const rules: Record = { 'no-module-level-new': noModuleLevelNew, 'no-unrestricted-loop-in-serverless': noUnrestrictedLoopInServerless, 'prefer-promise-all': preferPromiseAll, + 'no-deprecated-url-parse': noDeprecatedUrlParse, }; diff --git a/src/rules/meta.ts b/src/rules/meta.ts index f17b80f..11f5631 100644 --- a/src/rules/meta.ts +++ b/src/rules/meta.ts @@ -52,6 +52,7 @@ export const rulePlatforms: Partial> = { 'sql-no-nested-calls': ['backend'], 'no-sync-fs': ['backend'], 'no-unrestricted-loop-in-serverless': ['backend'], + 'no-deprecated-url-parse': ['backend'], // Universal rules (NOT listed here): prefer-guard-clauses, no-type-assertion, // no-string-coerce-error diff --git a/src/rules/no-deprecated-url-parse.ts b/src/rules/no-deprecated-url-parse.ts new file mode 100644 index 0000000..9d01d46 --- /dev/null +++ b/src/rules/no-deprecated-url-parse.ts @@ -0,0 +1,72 @@ +import traverse from '@babel/traverse'; +import * as t from '@babel/types'; +import type { File } from '@babel/types'; +import type { LintResult } from '../types'; + +const RULE_NAME = 'no-deprecated-url-parse'; + +export function noDeprecatedUrlParse(ast: File, _code: string): LintResult[] { + const results: LintResult[] = []; + + // Track identifiers imported/required from 'url' or 'node:url' + const urlImportedNames = new Set(); + + traverse(ast, { + ImportDeclaration(path) { + const source = path.node.source.value; + if (source !== 'url' && source !== 'node:url') return; + + for (const specifier of path.node.specifiers) { + if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) { + const imported = t.isIdentifier(specifier.imported) + ? specifier.imported.name + : specifier.imported.value; + if (imported === 'parse') { + urlImportedNames.add(specifier.local.name); + } + } + if (t.isImportDefaultSpecifier(specifier) || t.isImportNamespaceSpecifier(specifier)) { + urlImportedNames.add(specifier.local.name); + } + } + }, + + CallExpression(path) { + const { callee, loc } = path.node; + + // Pattern 1: url.parse(...) + if ( + t.isMemberExpression(callee) && + t.isIdentifier(callee.property) && + callee.property.name === 'parse' + ) { + const objectName = t.isIdentifier(callee.object) ? callee.object.name : null; + // Note: only the lowercase legacy `url` module is deprecated. `URL.parse` + // is the modern static method on the WHATWG URL constructor (Node 22+, + // browsers) and is a valid replacement — do not flag it. + if (objectName === 'url' || urlImportedNames.has(objectName ?? '')) { + results.push({ + rule: RULE_NAME, + message: "url.parse() is deprecated. Use 'new URL(input, base)' instead", + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'warning', + }); + } + } + + // Pattern 2: parse(...) — direct named import from 'url' + if (t.isIdentifier(callee) && urlImportedNames.has(callee.name)) { + results.push({ + rule: RULE_NAME, + message: "url.parse() is deprecated. Use 'new URL(input, base)' instead", + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'warning', + }); + } + }, + }); + + return results; +} diff --git a/tests/config-modes.test.ts b/tests/config-modes.test.ts index ff43264..def6195 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(55); + expect(ruleNames.length).toBe(56); }); }); }); diff --git a/tests/no-deprecated-url-parse.test.ts b/tests/no-deprecated-url-parse.test.ts new file mode 100644 index 0000000..4bae5aa --- /dev/null +++ b/tests/no-deprecated-url-parse.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const config = { rules: ['no-deprecated-url-parse'] }; + +describe('no-deprecated-url-parse rule', () => { + it('should detect url.parse() with default import', () => { + const code = ` + import url from 'url'; + const parsed = url.parse('https://example.com'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe('no-deprecated-url-parse'); + expect(results[0].message).toContain('deprecated'); + expect(results[0].message).toContain('new URL'); + expect(results[0].severity).toBe('warning'); + }); + + it('should detect url.parse() with namespace import', () => { + const code = ` + import * as url from 'node:url'; + const parsed = url.parse(req.url); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('should detect named import of parse from url', () => { + const code = ` + import { parse } from 'url'; + const parsed = parse('https://example.com'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('should detect aliased named import', () => { + const code = ` + import { parse as urlParse } from 'url'; + const parsed = urlParse('https://example.com'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('should allow new URL()', () => { + const code = ` + const parsed = new URL('https://example.com'); + const withBase = new URL('/path', 'https://example.com'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow url.format() and other url methods', () => { + const code = ` + import url from 'url'; + const formatted = url.format({ protocol: 'https', hostname: 'example.com' }); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag parse() from non-url modules', () => { + const code = ` + import { parse } from 'path'; + const result = parse('/foo/bar.txt'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not flag URL.parse static method (modern WHATWG URL API)', () => { + const code = ` + const result = URL.parse('https://example.com'); + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); +});