diff --git a/README.md b/README.md index d9d0fe3..c1ef4ea 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This writes a `.claude/settings.json` with a `PostToolUse` hook that runs after ### Configuring Rules -By default, all 43 rules run. To customize, create a `laint.config.json` in your project root: +By default, all 44 rules run. To customize, create a `laint.config.json` in your project root: ```json // Only run these specific rules (include mode) @@ -88,7 +88,7 @@ const results = lintJsxCode(code, { exclude: true, }); -// Run all 43 rules +// Run all 44 rules const allResults = lintJsxCode(code, { rules: [], exclude: true, @@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (43 total) +## Available Rules (44 total) ### Expo Router Rules @@ -203,6 +203,7 @@ const backendRules = getRulesForPlatform('backend'); | ------------------------ | -------- | --------- | ---------------------------------------------------------------- | | `prefer-guard-clauses` | warning | universal | Use early returns instead of nesting if statements | | `no-type-assertion` | warning | universal | Avoid `as` type casts; use type narrowing or proper types | +| `no-loose-equality` | warning | universal | Use === and !== instead of == and != (except == null) | | `no-magic-env-strings` | warning | universal | Use centralized enum for env variable names, not magic strings | | `no-nested-try-catch` | warning | universal | Avoid nested try-catch blocks, extract to separate functions | | `no-string-coerce-error` | warning | universal | Use JSON.stringify instead of String() for unknown caught errors | @@ -466,6 +467,26 @@ if (typeof data === 'string') { const user: User = response.data; ``` +### `no-loose-equality` + +```typescript +// Bad - loose equality +if (a == b) { +} +if (x != 'hello') { +} + +// Good - strict equality +if (a === b) { +} +if (x !== 'hello') { +} + +// OK - == null is idiomatic for null/undefined check +if (value == null) { +} +``` + ### `no-magic-env-strings` ````typescript diff --git a/src/rules/index.ts b/src/rules/index.ts index 88446e2..9559c1b 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -32,6 +32,7 @@ import { transitionSharedTagMismatch } from './transition-shared-tag-mismatch'; import { transitionPreferBlankStack } from './transition-prefer-blank-stack'; import { preferGuardClauses } from './prefer-guard-clauses'; import { noTypeAssertion } from './no-type-assertion'; +import { noLooseEquality } from './no-loose-equality'; import { noMagicEnvStrings } from './no-magic-env-strings'; import { urlParamsMustEncode } from './url-params-must-encode'; import { catchMustLogToSentry } from './catch-must-log-to-sentry'; @@ -77,6 +78,7 @@ export const rules: Record = { 'transition-prefer-blank-stack': transitionPreferBlankStack, 'prefer-guard-clauses': preferGuardClauses, 'no-type-assertion': noTypeAssertion, + 'no-loose-equality': noLooseEquality, 'no-magic-env-strings': noMagicEnvStrings, 'url-params-must-encode': urlParamsMustEncode, 'catch-must-log-to-sentry': catchMustLogToSentry, diff --git a/src/rules/no-loose-equality.ts b/src/rules/no-loose-equality.ts new file mode 100644 index 0000000..d189c8f --- /dev/null +++ b/src/rules/no-loose-equality.ts @@ -0,0 +1,33 @@ +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-loose-equality'; + +export function noLooseEquality(ast: File, _code: string): LintResult[] { + const results: LintResult[] = []; + + traverse(ast, { + BinaryExpression(path) { + const { operator, left, right } = path.node; + + if (operator !== '==' && operator !== '!=') return; + + // Allow == null and != null (idiomatic null/undefined check) + if (t.isNullLiteral(right) || t.isNullLiteral(left)) return; + + const strict = operator === '==' ? '===' : '!=='; + const { loc } = path.node; + results.push({ + rule: RULE_NAME, + message: `Use '${strict}' instead of '${operator}' for strict equality comparison.`, + 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 ef7e823..b9b6f5c 100644 --- a/tests/config-modes.test.ts +++ b/tests/config-modes.test.ts @@ -124,7 +124,7 @@ describe('config modes', () => { expect(ruleNames).toContain('expo-image-import'); expect(ruleNames).toContain('no-stylesheet-create'); - expect(ruleNames.length).toBe(43); + expect(ruleNames.length).toBe(44); }); }); }); diff --git a/tests/no-loose-equality.test.ts b/tests/no-loose-equality.test.ts new file mode 100644 index 0000000..daaf68c --- /dev/null +++ b/tests/no-loose-equality.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const RULE = 'no-loose-equality'; + +function lint(code: string) { + return lintJsxCode(code, { rules: [RULE] }); +} + +describe(RULE, () => { + it('flags == comparison', () => { + const code = `if (a == b) {}`; + const results = lint(code); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe(RULE); + expect(results[0].message).toContain('==='); + }); + + it('flags != comparison', () => { + const code = `if (a != b) {}`; + const results = lint(code); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('!=='); + }); + + it('flags == with string literal', () => { + const code = `if (x == 'hello') {}`; + const results = lint(code); + expect(results).toHaveLength(1); + }); + + it('flags == with number', () => { + const code = `if (count == 0) {}`; + const results = lint(code); + expect(results).toHaveLength(1); + }); + + it('allows == null (idiomatic null check)', () => { + const code = `if (value == null) {}`; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows != null (idiomatic null check)', () => { + const code = `if (value != null) {}`; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows null == value (reverse null check)', () => { + const code = `if (null == value) {}`; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows === comparison', () => { + const code = `if (a === b) {}`; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows !== comparison', () => { + const code = `if (a !== b) {}`; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('flags multiple loose comparisons', () => { + const code = ` + if (a == b) {} + if (c != d) {} + `; + const results = lint(code); + expect(results).toHaveLength(2); + }); +});