diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index b0c4827..3006aff 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -167,8 +167,14 @@ export interface ReactDoctorIgnoreConfig { files?: string[]; } +export interface ReactDoctorOverride { + files: string[]; + ignore: { rules: string[] }; +} + export interface ReactDoctorConfig { ignore?: ReactDoctorIgnoreConfig; + overrides?: ReactDoctorOverride[]; lint?: boolean; deadCode?: boolean; verbose?: boolean; diff --git a/packages/react-doctor/src/utils/filter-diagnostics.ts b/packages/react-doctor/src/utils/filter-diagnostics.ts index ce0280d..06e8c0c 100644 --- a/packages/react-doctor/src/utils/filter-diagnostics.ts +++ b/packages/react-doctor/src/utils/filter-diagnostics.ts @@ -1,6 +1,31 @@ import type { Diagnostic, ReactDoctorConfig } from "../types.js"; import { compileGlobPattern } from "./match-glob-pattern.js"; +interface CompiledOverride { + filePatterns: RegExp[]; + ignoredRules: Set; +} + +const compileOverrides = (config: ReactDoctorConfig): CompiledOverride[] => { + if (!Array.isArray(config.overrides)) return []; + + return config.overrides.map((override) => ({ + filePatterns: override.files.map(compileGlobPattern), + ignoredRules: new Set(override.ignore.rules), + })); +}; + +const isRuleIgnoredByOverride = ( + normalizedPath: string, + ruleIdentifier: string, + compiledOverrides: CompiledOverride[], +): boolean => + compiledOverrides.some( + (override) => + override.ignoredRules.has(ruleIdentifier) && + override.filePatterns.some((pattern) => pattern.test(normalizedPath)), + ); + export const filterIgnoredDiagnostics = ( diagnostics: Diagnostic[], config: ReactDoctorConfig, @@ -9,8 +34,12 @@ export const filterIgnoredDiagnostics = ( const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : []; + const compiledOverrides = compileOverrides(config); - if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) { + const hasNoFilters = + ignoredRules.size === 0 && ignoredFilePatterns.length === 0 && compiledOverrides.length === 0; + + if (hasNoFilters) { return diagnostics; } @@ -25,6 +54,10 @@ export const filterIgnoredDiagnostics = ( return false; } + if (isRuleIgnoredByOverride(normalizedPath, ruleIdentifier, compiledOverrides)) { + return false; + } + return true; }); }; diff --git a/packages/react-doctor/tests/filter-diagnostics.test.ts b/packages/react-doctor/tests/filter-diagnostics.test.ts index e74e489..f0c3c96 100644 --- a/packages/react-doctor/tests/filter-diagnostics.test.ts +++ b/packages/react-doctor/tests/filter-diagnostics.test.ts @@ -127,4 +127,153 @@ describe("filterIgnoredDiagnostics", () => { expect(filtered).toHaveLength(1); expect(filtered[0].rule).toBe("files"); }); + + describe("overrides", () => { + it("ignores a specific rule only for matching files", () => { + const diagnostics = [ + createDiagnostic({ + plugin: "react-doctor", + rule: "no-giant-component", + filePath: "src/legacy/Dashboard.tsx", + }), + createDiagnostic({ + plugin: "react-doctor", + rule: "no-giant-component", + filePath: "src/components/App.tsx", + }), + ]; + const config: ReactDoctorConfig = { + overrides: [ + { + files: ["src/legacy/**"], + ignore: { rules: ["react-doctor/no-giant-component"] }, + }, + ], + }; + + const filtered = filterIgnoredDiagnostics(diagnostics, config); + expect(filtered).toHaveLength(1); + expect(filtered[0].filePath).toBe("src/components/App.tsx"); + }); + + it("applies multiple overrides to different file patterns", () => { + const diagnostics = [ + createDiagnostic({ + plugin: "react", + rule: "no-danger", + filePath: "src/legacy/Old.tsx", + }), + createDiagnostic({ + plugin: "knip", + rule: "exports", + filePath: "src/generated/api.tsx", + }), + createDiagnostic({ + plugin: "react-doctor", + rule: "no-giant-component", + filePath: "src/components/App.tsx", + }), + ]; + const config: ReactDoctorConfig = { + overrides: [ + { + files: ["src/legacy/**"], + ignore: { rules: ["react/no-danger"] }, + }, + { + files: ["src/generated/**"], + ignore: { rules: ["knip/exports"] }, + }, + ], + }; + + const filtered = filterIgnoredDiagnostics(diagnostics, config); + expect(filtered).toHaveLength(1); + expect(filtered[0].filePath).toBe("src/components/App.tsx"); + }); + + it("combines overrides with global ignore.rules and ignore.files", () => { + const diagnostics = [ + createDiagnostic({ + plugin: "react", + rule: "no-danger", + filePath: "src/app.tsx", + }), + createDiagnostic({ + plugin: "knip", + rule: "exports", + filePath: "src/generated/api.tsx", + }), + createDiagnostic({ + plugin: "react-doctor", + rule: "no-giant-component", + filePath: "src/legacy/Dashboard.tsx", + }), + createDiagnostic({ + plugin: "jsx-a11y", + rule: "no-autofocus", + filePath: "src/components/Search.tsx", + }), + ]; + const config: ReactDoctorConfig = { + ignore: { + rules: ["react/no-danger"], + files: ["src/generated/**"], + }, + overrides: [ + { + files: ["src/legacy/**"], + ignore: { rules: ["react-doctor/no-giant-component"] }, + }, + ], + }; + + const filtered = filterIgnoredDiagnostics(diagnostics, config); + expect(filtered).toHaveLength(1); + expect(filtered[0].rule).toBe("no-autofocus"); + }); + + it("has no effect when overrides array is empty", () => { + const diagnostics = [ + createDiagnostic({ plugin: "react", rule: "no-danger" }), + createDiagnostic({ plugin: "knip", rule: "exports" }), + ]; + const config: ReactDoctorConfig = { overrides: [] }; + + const filtered = filterIgnoredDiagnostics(diagnostics, config); + expect(filtered).toHaveLength(2); + }); + + it("supports multiple file globs in a single override", () => { + const diagnostics = [ + createDiagnostic({ + plugin: "react", + rule: "no-danger", + filePath: "src/legacy/Old.tsx", + }), + createDiagnostic({ + plugin: "react", + rule: "no-danger", + filePath: "src/deprecated/Stale.tsx", + }), + createDiagnostic({ + plugin: "react", + rule: "no-danger", + filePath: "src/components/App.tsx", + }), + ]; + const config: ReactDoctorConfig = { + overrides: [ + { + files: ["src/legacy/**", "src/deprecated/**"], + ignore: { rules: ["react/no-danger"] }, + }, + ], + }; + + const filtered = filterIgnoredDiagnostics(diagnostics, config); + expect(filtered).toHaveLength(1); + expect(filtered[0].filePath).toBe("src/components/App.tsx"); + }); + }); });