diff --git a/README.md b/README.md index c1ef4ea..dbc6b7e 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 44 rules run. To customize, create a `laint.config.json` in your project root: +By default, all 45 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 44 rules +// Run all 45 rules const allResults = lintJsxCode(code, { rules: [], exclude: true, @@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (44 total) +## Available Rules (45 total) ### Expo Router Rules @@ -196,6 +196,7 @@ const backendRules = getRulesForPlatform('backend'); | Rule | Severity | Description | | -------------------------- | -------- | ------------------------------------------------------------------ | | `catch-must-log-to-sentry` | warning | Catch blocks with logger.error/console.error must also call Sentry | +| `no-new-error-in-err` | warning | Don't construct Error objects inside neverthrow err() | ### Code Style Rules @@ -467,6 +468,19 @@ if (typeof data === 'string') { const user: User = response.data; ``` +### `no-new-error-in-err` + +```typescript +// Bad - constructing Error inside neverthrow err() +return err(new Error('Failed to detect pull request', { cause: error })); + +// Good - use a plain object +return err({ message: 'Failed to detect pull request', cause: error }); + +// Good - use a string +return err('Failed to detect pull request'); +``` + ### `no-loose-equality` ```typescript diff --git a/src/rules/index.ts b/src/rules/index.ts index 9559c1b..7e353ec 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 { noNewErrorInErr } from './no-new-error-in-err'; import { noLooseEquality } from './no-loose-equality'; import { noMagicEnvStrings } from './no-magic-env-strings'; import { urlParamsMustEncode } from './url-params-must-encode'; @@ -78,6 +79,7 @@ export const rules: Record = { 'transition-prefer-blank-stack': transitionPreferBlankStack, 'prefer-guard-clauses': preferGuardClauses, 'no-type-assertion': noTypeAssertion, + 'no-new-error-in-err': noNewErrorInErr, 'no-loose-equality': noLooseEquality, 'no-magic-env-strings': noMagicEnvStrings, 'url-params-must-encode': urlParamsMustEncode, diff --git a/src/rules/no-new-error-in-err.ts b/src/rules/no-new-error-in-err.ts new file mode 100644 index 0000000..f0447bf --- /dev/null +++ b/src/rules/no-new-error-in-err.ts @@ -0,0 +1,37 @@ +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-new-error-in-err'; + +export function noNewErrorInErr(ast: File, _code: string): LintResult[] { + const results: LintResult[] = []; + + traverse(ast, { + CallExpression(path) { + const { callee, arguments: args } = path.node; + + // Match err(...) calls + if (!t.isIdentifier(callee, { name: 'err' })) return; + if (args.length === 0) return; + + const firstArg = args[0]; + + // Flag err(new Error(...)) + if (t.isNewExpression(firstArg) && t.isIdentifier(firstArg.callee, { name: 'Error' })) { + const { loc } = path.node; + results.push({ + rule: RULE_NAME, + message: + 'Avoid constructing Error objects inside err(). Use a plain object or custom error type instead (e.g. err({ message: "...", cause: error })).', + 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 b9b6f5c..b6610eb 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(44); + expect(ruleNames.length).toBe(45); }); }); }); diff --git a/tests/no-new-error-in-err.test.ts b/tests/no-new-error-in-err.test.ts new file mode 100644 index 0000000..27d9478 --- /dev/null +++ b/tests/no-new-error-in-err.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const RULE = 'no-new-error-in-err'; + +function lint(code: string) { + return lintJsxCode(code, { rules: [RULE] }); +} + +describe(RULE, () => { + it('flags err(new Error("message"))', () => { + const code = `function f() { return err(new Error('Something failed')); }`; + const results = lint(code); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe(RULE); + expect(results[0].message).toContain('plain object'); + }); + + it('flags err(new Error("message", { cause }))', () => { + const code = `function f() { return err(new Error('Failed to detect pull request', { cause: error })); }`; + const results = lint(code); + expect(results).toHaveLength(1); + }); + + it('flags err(new Error()) with no args', () => { + const code = `function f() { return err(new Error()); }`; + const results = lint(code); + expect(results).toHaveLength(1); + }); + + it('flags err(new Error()) in expression position', () => { + const code = `const result = err(new Error('failed'));`; + const results = lint(code); + expect(results).toHaveLength(1); + }); + + it('allows err("string message")', () => { + const code = `function f() { return err('Something failed'); }`; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows err({ message, cause })', () => { + const code = `function f() { return err({ message: 'Failed', cause: error }); }`; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows err(someVariable)', () => { + const code = `function f() { return err(existingError); }`; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows new Error outside err()', () => { + const code = `throw new Error('Something failed');`; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows other function calls with new Error', () => { + const code = `logError(new Error('Something failed'));`; + const results = lint(code); + expect(results).toHaveLength(0); + }); +});