react-doctor 0.0.46 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ const KNIP_CONFIG_LOCATIONS = [
26
26
  "knip.config.ts",
27
27
  "knip.config.js"
28
28
  ];
29
+ const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
29
30
  const IGNORED_DIRECTORIES = new Set([
30
31
  "node_modules",
31
32
  "dist",
@@ -35,197 +36,6 @@ const IGNORED_DIRECTORIES = new Set([
35
36
  const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
36
37
  const buildNoReactDependencyError = (directory) => `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`;
37
38
  //#endregion
38
- //#region src/utils/match-glob-pattern.ts
39
- const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
40
- const compileGlobPattern = (pattern) => {
41
- const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
42
- let regexSource = "^";
43
- let characterIndex = 0;
44
- while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
45
- regexSource += "(?:.+/)?";
46
- characterIndex += 3;
47
- } else {
48
- regexSource += ".*";
49
- characterIndex += 2;
50
- }
51
- else if (normalizedPattern[characterIndex] === "*") {
52
- regexSource += "[^/]*";
53
- characterIndex++;
54
- } else if (normalizedPattern[characterIndex] === "?") {
55
- regexSource += "[^/]";
56
- characterIndex++;
57
- } else {
58
- regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
59
- characterIndex++;
60
- }
61
- regexSource += "$";
62
- return new RegExp(regexSource);
63
- };
64
- //#endregion
65
- //#region src/utils/is-ignored-file.ts
66
- const toRelativePath = (filePath, rootDirectory) => {
67
- const normalizedFilePath = filePath.replace(/\\/g, "/");
68
- const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
69
- if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
70
- return normalizedFilePath.replace(/^\.\//, "");
71
- };
72
- const compileIgnoredFilePatterns = (userConfig) => {
73
- const files = userConfig?.ignore?.files;
74
- if (!Array.isArray(files)) return [];
75
- return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
76
- };
77
- const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
78
- if (patterns.length === 0) return false;
79
- const relativePath = toRelativePath(filePath, rootDirectory);
80
- return patterns.some((pattern) => pattern.test(relativePath));
81
- };
82
- //#endregion
83
- //#region src/utils/filter-diagnostics.ts
84
- const resolveCandidateReadPath = (rootDirectory, filePath) => {
85
- const normalizedFile = filePath.replace(/\\/g, "/");
86
- if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
87
- return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
88
- };
89
- const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
90
- const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
91
- const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
92
- const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
93
- const cache = /* @__PURE__ */ new Map();
94
- return (filePath) => {
95
- const cached = cache.get(filePath);
96
- if (cached !== void 0) return cached;
97
- const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
98
- cache.set(filePath, lines);
99
- return lines;
100
- };
101
- };
102
- const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
103
- for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
104
- const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
105
- if (!match) continue;
106
- const fullTagName = match[1];
107
- const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
108
- return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
109
- }
110
- return false;
111
- };
112
- const isRuleSuppressed = (commentRules, ruleId) => {
113
- if (!commentRules?.trim()) return true;
114
- return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
115
- };
116
- const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
117
- const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
118
- const ignoredFilePatterns = compileIgnoredFilePatterns(config);
119
- const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
120
- const hasTextComponents = textComponentNames.size > 0;
121
- const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
122
- return diagnostics.filter((diagnostic) => {
123
- const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
124
- if (ignoredRules.has(ruleIdentifier)) return false;
125
- if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
126
- if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
127
- const lines = getFileLines(diagnostic.filePath);
128
- if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
129
- }
130
- return true;
131
- });
132
- };
133
- const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
134
- const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
135
- return diagnostics.filter((diagnostic) => {
136
- if (diagnostic.line <= 0) return true;
137
- const lines = getFileLines(diagnostic.filePath);
138
- if (!lines) return true;
139
- const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
140
- const currentLine = lines[diagnostic.line - 1];
141
- if (currentLine) {
142
- const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
143
- if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
144
- }
145
- if (diagnostic.line >= 2) {
146
- const previousLine = lines[diagnostic.line - 2];
147
- if (previousLine) {
148
- const nextLineMatch = previousLine.match(DISABLE_NEXT_LINE_PATTERN);
149
- if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
150
- }
151
- }
152
- return true;
153
- });
154
- };
155
- //#endregion
156
- //#region src/utils/merge-and-filter-diagnostics.ts
157
- const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync) => {
158
- return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics, directory, readFileLinesSync);
159
- };
160
- //#endregion
161
- //#region src/core/build-result.ts
162
- const buildDiagnoseTimedResult = async (input) => {
163
- const diagnostics = mergeAndFilterDiagnostics(input.mergedDiagnostics, input.rootDirectory, input.userConfig, input.readFileLinesSync);
164
- const elapsedMilliseconds = globalThis.performance.now() - input.startTime;
165
- return {
166
- diagnostics,
167
- score: input.score !== void 0 ? input.score : await input.calculateDiagnosticsScore(diagnostics),
168
- elapsedMilliseconds
169
- };
170
- };
171
- //#endregion
172
- //#region src/utils/jsx-include-paths.ts
173
- const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
174
- //#endregion
175
- //#region src/core/diagnose-core.ts
176
- const diagnoseCore = async (deps, options = {}) => {
177
- const { includePaths = [] } = options;
178
- const isDiffMode = includePaths.length > 0;
179
- const startTime = globalThis.performance.now();
180
- const resolvedDirectory = deps.rootDirectory;
181
- const projectInfo = deps.discoverProjectInfo();
182
- const userConfig = deps.loadUserConfig();
183
- const effectiveLint = options.lint ?? userConfig?.lint ?? true;
184
- const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
185
- if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(deps.rootDirectory));
186
- const lintIncludePaths = options.lintIncludePaths !== void 0 ? options.lintIncludePaths : computeJsxIncludePaths(includePaths);
187
- const { runLint, runDeadCode } = deps.createRunners({
188
- resolvedDirectory,
189
- projectInfo,
190
- userConfig,
191
- lintIncludePaths,
192
- isDiffMode
193
- });
194
- const emptyDiagnostics = [];
195
- const lintPromise = effectiveLint ? runLint().catch((error) => {
196
- console.error("Lint failed:", error);
197
- return emptyDiagnostics;
198
- }) : Promise.resolve(emptyDiagnostics);
199
- const deadCodePromise = effectiveDeadCode && !isDiffMode ? runDeadCode().catch((error) => {
200
- console.error("Dead code analysis failed:", error);
201
- return emptyDiagnostics;
202
- }) : Promise.resolve(emptyDiagnostics);
203
- const [lintSettled, deadCodeSettled] = await Promise.allSettled([lintPromise, deadCodePromise]);
204
- const lintDiagnostics = lintSettled.status === "fulfilled" ? lintSettled.value : emptyDiagnostics;
205
- const deadCodeDiagnostics = deadCodeSettled.status === "fulfilled" ? deadCodeSettled.value : emptyDiagnostics;
206
- if (lintSettled.status === "rejected") console.error("Lint rejected:", lintSettled.reason);
207
- if (deadCodeSettled.status === "rejected") console.error("Dead code rejected:", deadCodeSettled.reason);
208
- const environmentDiagnostics = deps.getExtraDiagnostics?.() ?? [];
209
- const timed = await buildDiagnoseTimedResult({
210
- mergedDiagnostics: [
211
- ...lintDiagnostics,
212
- ...deadCodeDiagnostics,
213
- ...environmentDiagnostics
214
- ],
215
- rootDirectory: resolvedDirectory,
216
- userConfig,
217
- readFileLinesSync: deps.readFileLinesSync,
218
- startTime,
219
- calculateDiagnosticsScore: deps.calculateDiagnosticsScore
220
- });
221
- return {
222
- diagnostics: timed.diagnostics,
223
- score: timed.score,
224
- project: projectInfo,
225
- elapsedMilliseconds: timed.elapsedMilliseconds
226
- };
227
- };
228
- //#endregion
229
39
  //#region src/utils/summarize-diagnostics.ts
230
40
  const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
231
41
  let errorCount = 0;
@@ -359,7 +169,192 @@ const buildJsonReportError = (input) => {
359
169
  };
360
170
  };
361
171
  //#endregion
172
+ //#region src/utils/calculate-score-locally.ts
173
+ const getScoreLabel = (score) => {
174
+ if (score >= 75) return "Great";
175
+ if (score >= 50) return "Needs work";
176
+ return "Critical";
177
+ };
178
+ const countUniqueRules = (diagnostics) => {
179
+ const errorRules = /* @__PURE__ */ new Set();
180
+ const warningRules = /* @__PURE__ */ new Set();
181
+ for (const diagnostic of diagnostics) {
182
+ const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
183
+ if (diagnostic.severity === "error") errorRules.add(ruleKey);
184
+ else warningRules.add(ruleKey);
185
+ }
186
+ return {
187
+ errorRuleCount: errorRules.size,
188
+ warningRuleCount: warningRules.size
189
+ };
190
+ };
191
+ const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
192
+ const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
193
+ return Math.max(0, Math.round(100 - penalty));
194
+ };
195
+ const calculateScoreLocally = (diagnostics) => {
196
+ const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
197
+ const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
198
+ return {
199
+ score,
200
+ label: getScoreLabel(score)
201
+ };
202
+ };
203
+ //#endregion
204
+ //#region src/utils/try-score-from-api.ts
205
+ const parseScoreResult = (value) => {
206
+ if (typeof value !== "object" || value === null) return null;
207
+ if (!("score" in value) || !("label" in value)) return null;
208
+ const scoreValue = Reflect.get(value, "score");
209
+ const labelValue = Reflect.get(value, "label");
210
+ if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
211
+ return {
212
+ score: scoreValue,
213
+ label: labelValue
214
+ };
215
+ };
216
+ const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
217
+ const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
218
+ const describeFailure = (error) => {
219
+ if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
220
+ if (error instanceof Error && error.message) return error.message;
221
+ return String(error);
222
+ };
223
+ const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
224
+ if (typeof fetchImplementation !== "function") return null;
225
+ const controller = new AbortController();
226
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
227
+ try {
228
+ const response = await fetchImplementation(SCORE_API_URL, {
229
+ method: "POST",
230
+ headers: { "Content-Type": "application/json" },
231
+ body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
232
+ signal: controller.signal
233
+ });
234
+ if (!response.ok) {
235
+ console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText} — using local scoring`);
236
+ return null;
237
+ }
238
+ return parseScoreResult(await response.json());
239
+ } catch (error) {
240
+ console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)}) — using local scoring`);
241
+ return null;
242
+ } finally {
243
+ clearTimeout(timeoutId);
244
+ }
245
+ };
246
+ //#endregion
247
+ //#region src/utils/proxy-fetch.ts
248
+ const getGlobalProcess = () => {
249
+ const candidate = globalThis.process;
250
+ return candidate?.versions?.node ? candidate : void 0;
251
+ };
252
+ const getProxyUrl = () => {
253
+ const proc = getGlobalProcess();
254
+ if (!proc?.env) return void 0;
255
+ return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
256
+ };
257
+ const createProxyDispatcher = async (proxyUrl) => {
258
+ try {
259
+ const { ProxyAgent } = await import("undici");
260
+ return new ProxyAgent(proxyUrl);
261
+ } catch {
262
+ return null;
263
+ }
264
+ };
265
+ const proxyFetch = async (url, init) => {
266
+ const proxyUrl = getProxyUrl();
267
+ const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
268
+ const fetchInit = {
269
+ ...init,
270
+ ...dispatcher ? { dispatcher } : {}
271
+ };
272
+ return fetch(url, fetchInit);
273
+ };
274
+ //#endregion
275
+ //#region src/utils/calculate-score.ts
276
+ const calculateScore = async (diagnostics) => await tryScoreFromApi(diagnostics, proxyFetch) ?? calculateScoreLocally(diagnostics);
277
+ //#endregion
362
278
  //#region src/plugin/constants.ts
279
+ const FETCH_CALLEE_NAMES = new Set([
280
+ "fetch",
281
+ "ky",
282
+ "got",
283
+ "wretch",
284
+ "ofetch"
285
+ ]);
286
+ const FETCH_MEMBER_OBJECTS = new Set([
287
+ "axios",
288
+ "ky",
289
+ "got",
290
+ "ofetch",
291
+ "wretch",
292
+ "request"
293
+ ]);
294
+ const TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES = new Set([
295
+ "setTimeout",
296
+ "setInterval",
297
+ "requestAnimationFrame",
298
+ "requestIdleCallback",
299
+ "queueMicrotask"
300
+ ]);
301
+ const SUBSCRIPTION_METHOD_NAMES = new Set([
302
+ "subscribe",
303
+ "addEventListener",
304
+ "addListener",
305
+ "on",
306
+ "watch",
307
+ "listen",
308
+ "sub"
309
+ ]);
310
+ new Set([
311
+ ...new Set([
312
+ "unsubscribe",
313
+ "removeEventListener",
314
+ "removeListener",
315
+ "off",
316
+ "unwatch",
317
+ "unlisten",
318
+ "unsub"
319
+ ]),
320
+ "cleanup",
321
+ "dispose",
322
+ "destroy",
323
+ "teardown"
324
+ ]);
325
+ new Set([
326
+ ...SUBSCRIPTION_METHOD_NAMES,
327
+ "connect",
328
+ "disconnect",
329
+ "open",
330
+ "close",
331
+ "fetch",
332
+ "post",
333
+ "put",
334
+ "patch"
335
+ ]);
336
+ new Set([
337
+ ...FETCH_MEMBER_OBJECTS,
338
+ "api",
339
+ "client",
340
+ "http",
341
+ "fetcher"
342
+ ]);
343
+ new Set([...FETCH_CALLEE_NAMES, ...TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES]);
344
+ new Set([
345
+ ...FETCH_CALLEE_NAMES,
346
+ "post",
347
+ "put",
348
+ "patch",
349
+ "navigate",
350
+ "navigateTo",
351
+ "showNotification",
352
+ "toast",
353
+ "alert",
354
+ "confirm",
355
+ "logVisit",
356
+ "captureEvent"
357
+ ]);
363
358
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
364
359
  //#endregion
365
360
  //#region src/utils/is-file.ts
@@ -915,7 +910,7 @@ const hasCompilerInConfigFile = (filePath) => {
915
910
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
916
911
  };
917
912
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
918
- const isProjectBoundary$1 = (directory) => {
913
+ const isProjectBoundary$2 = (directory) => {
919
914
  if (fs.existsSync(path.join(directory, ".git"))) return true;
920
915
  return isMonorepoRoot(directory);
921
916
  };
@@ -925,14 +920,14 @@ const detectReactCompiler = (directory, packageJson) => {
925
920
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
926
921
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
927
922
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
928
- if (isProjectBoundary$1(directory)) return false;
923
+ if (isProjectBoundary$2(directory)) return false;
929
924
  let ancestorDirectory = path.dirname(directory);
930
925
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
931
926
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
932
927
  if (isFile(ancestorPackagePath)) {
933
928
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
934
929
  }
935
- if (isProjectBoundary$1(ancestorDirectory)) return false;
930
+ if (isProjectBoundary$2(ancestorDirectory)) return false;
936
931
  ancestorDirectory = path.dirname(ancestorDirectory);
937
932
  }
938
933
  return false;
@@ -986,6 +981,9 @@ const discoverProject = (directory) => {
986
981
  return projectInfo;
987
982
  };
988
983
  //#endregion
984
+ //#region src/utils/jsx-include-paths.ts
985
+ const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
986
+ //#endregion
989
987
  //#region src/utils/validate-config-types.ts
990
988
  const BOOLEAN_FIELD_NAMES = [
991
989
  "lint",
@@ -993,22 +991,23 @@ const BOOLEAN_FIELD_NAMES = [
993
991
  "verbose",
994
992
  "customRulesOnly",
995
993
  "share",
996
- "respectInlineDisables"
994
+ "respectInlineDisables",
995
+ "adoptExistingLintConfig"
997
996
  ];
998
- const warnConfigField = (message) => {
997
+ const warnConfigField$1 = (message) => {
999
998
  process.stderr.write(`[react-doctor] ${message}\n`);
1000
999
  };
1001
1000
  const coerceMaybeBooleanString = (fieldName, value) => {
1002
1001
  if (typeof value === "boolean" || value === void 0) return value;
1003
1002
  if (value === "true") {
1004
- warnConfigField(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
1003
+ warnConfigField$1(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
1005
1004
  return true;
1006
1005
  }
1007
1006
  if (value === "false") {
1008
- warnConfigField(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
1007
+ warnConfigField$1(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
1009
1008
  return false;
1010
1009
  }
1011
- warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
1010
+ warnConfigField$1(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
1012
1011
  };
1013
1012
  const validateConfigTypes = (config) => {
1014
1013
  const validated = { ...config };
@@ -1048,38 +1047,383 @@ const loadConfigFromDirectory = (directory) => {
1048
1047
  }
1049
1048
  return null;
1050
1049
  };
1051
- const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1050
+ const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1052
1051
  const cachedConfigs = /* @__PURE__ */ new Map();
1053
1052
  const clearConfigCache = () => {
1054
1053
  cachedConfigs.clear();
1055
1054
  };
1056
- const loadConfig = (rootDirectory) => {
1057
- const cached = cachedConfigs.get(rootDirectory);
1058
- if (cached !== void 0) return cached;
1059
- const localConfig = loadConfigFromDirectory(rootDirectory);
1060
- if (localConfig) {
1061
- cachedConfigs.set(rootDirectory, localConfig);
1062
- return localConfig;
1063
- }
1064
- if (isProjectBoundary(rootDirectory)) {
1065
- cachedConfigs.set(rootDirectory, null);
1066
- return null;
1055
+ const loadConfig = (rootDirectory) => {
1056
+ const cached = cachedConfigs.get(rootDirectory);
1057
+ if (cached !== void 0) return cached;
1058
+ const localConfig = loadConfigFromDirectory(rootDirectory);
1059
+ if (localConfig) {
1060
+ cachedConfigs.set(rootDirectory, localConfig);
1061
+ return localConfig;
1062
+ }
1063
+ if (isProjectBoundary$1(rootDirectory)) {
1064
+ cachedConfigs.set(rootDirectory, null);
1065
+ return null;
1066
+ }
1067
+ let ancestorDirectory = path.dirname(rootDirectory);
1068
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1069
+ const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
1070
+ if (ancestorConfig) {
1071
+ cachedConfigs.set(rootDirectory, ancestorConfig);
1072
+ return ancestorConfig;
1073
+ }
1074
+ if (isProjectBoundary$1(ancestorDirectory)) {
1075
+ cachedConfigs.set(rootDirectory, null);
1076
+ return null;
1077
+ }
1078
+ ancestorDirectory = path.dirname(ancestorDirectory);
1079
+ }
1080
+ cachedConfigs.set(rootDirectory, null);
1081
+ return null;
1082
+ };
1083
+ //#endregion
1084
+ //#region src/utils/match-glob-pattern.ts
1085
+ const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
1086
+ const compileGlobPattern = (pattern) => {
1087
+ const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
1088
+ let regexSource = "^";
1089
+ let characterIndex = 0;
1090
+ while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
1091
+ regexSource += "(?:.+/)?";
1092
+ characterIndex += 3;
1093
+ } else {
1094
+ regexSource += ".*";
1095
+ characterIndex += 2;
1096
+ }
1097
+ else if (normalizedPattern[characterIndex] === "*") {
1098
+ regexSource += "[^/]*";
1099
+ characterIndex++;
1100
+ } else if (normalizedPattern[characterIndex] === "?") {
1101
+ regexSource += "[^/]";
1102
+ characterIndex++;
1103
+ } else {
1104
+ regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
1105
+ characterIndex++;
1106
+ }
1107
+ regexSource += "$";
1108
+ return new RegExp(regexSource);
1109
+ };
1110
+ //#endregion
1111
+ //#region src/utils/to-relative-path.ts
1112
+ const toRelativePath = (filePath, rootDirectory) => {
1113
+ const normalizedFilePath = filePath.replace(/\\/g, "/");
1114
+ const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
1115
+ if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
1116
+ return normalizedFilePath.replace(/^\.\//, "");
1117
+ };
1118
+ //#endregion
1119
+ //#region src/utils/apply-ignore-overrides.ts
1120
+ const warnConfigField = (message) => {
1121
+ process.stderr.write(`[react-doctor] ${message}\n`);
1122
+ };
1123
+ const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
1124
+ const collectStringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
1125
+ const validateOverrideEntry = (entry, index) => {
1126
+ if (!isPlainObject(entry)) {
1127
+ warnConfigField(`ignore.overrides[${index}] must be an object with { files, rules }; ignoring this entry.`);
1128
+ return null;
1129
+ }
1130
+ if (!isStringArray(entry.files)) {
1131
+ warnConfigField(`ignore.overrides[${index}].files must be an array of strings; ignoring this entry.`);
1132
+ return null;
1133
+ }
1134
+ if (entry.rules !== void 0 && !isStringArray(entry.rules)) {
1135
+ warnConfigField(`ignore.overrides[${index}].rules must be an array of "plugin/rule" strings or omitted; treating as missing (override would suppress every rule for the matched files).`);
1136
+ return { files: entry.files };
1137
+ }
1138
+ return entry.rules === void 0 ? { files: entry.files } : {
1139
+ files: entry.files,
1140
+ rules: entry.rules
1141
+ };
1142
+ };
1143
+ const compileIgnoreOverrides = (userConfig) => {
1144
+ const overrides = userConfig?.ignore?.overrides;
1145
+ if (overrides === void 0) return [];
1146
+ if (!Array.isArray(overrides)) {
1147
+ warnConfigField(`ignore.overrides must be an array of { files, rules } entries; ignoring.`);
1148
+ return [];
1149
+ }
1150
+ return overrides.flatMap((entry, index) => {
1151
+ const validated = validateOverrideEntry(entry, index);
1152
+ if (!validated) return [];
1153
+ const filePatterns = collectStringList(validated.files).map(compileGlobPattern);
1154
+ if (filePatterns.length === 0) return [];
1155
+ return [{
1156
+ filePatterns,
1157
+ ruleIds: new Set(collectStringList(validated.rules))
1158
+ }];
1159
+ });
1160
+ };
1161
+ const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) => {
1162
+ if (overrides.length === 0) return false;
1163
+ const relativeFilePath = toRelativePath(diagnostic.filePath, rootDirectory);
1164
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
1165
+ return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
1166
+ };
1167
+ //#endregion
1168
+ //#region src/utils/find-jsx-opener-span.ts
1169
+ const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
1170
+ const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
1171
+ const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
1172
+ let stringDelimiter = null;
1173
+ for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
1174
+ const character = line[charIndex];
1175
+ if (stringDelimiter !== null) {
1176
+ if (character === "\\") {
1177
+ charIndex++;
1178
+ continue;
1179
+ }
1180
+ if (character === stringDelimiter) stringDelimiter = null;
1181
+ continue;
1182
+ }
1183
+ if (character === "\"" || character === "'" || character === "`") {
1184
+ stringDelimiter = character;
1185
+ continue;
1186
+ }
1187
+ if (character === "/" && line[charIndex + 1] === "/") return true;
1188
+ }
1189
+ return false;
1190
+ };
1191
+ const findOpenerTagOnLine = (line) => {
1192
+ for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
1193
+ if (match.index === void 0) continue;
1194
+ if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
1195
+ }
1196
+ return null;
1197
+ };
1198
+ const findJsxOpenerSpan = (lines, openerLineIndex) => {
1199
+ const openerLine = lines[openerLineIndex];
1200
+ if (openerLine === void 0) return null;
1201
+ const opener = findOpenerTagOnLine(openerLine);
1202
+ if (!opener) return null;
1203
+ const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
1204
+ let braceDepth = 0;
1205
+ let innerAngleDepth = 0;
1206
+ let stringDelimiter = null;
1207
+ for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
1208
+ const currentLine = lines[lineIndex];
1209
+ const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
1210
+ for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
1211
+ const character = currentLine[charIndex];
1212
+ if (stringDelimiter !== null) {
1213
+ if (character === "\\") {
1214
+ charIndex++;
1215
+ continue;
1216
+ }
1217
+ if (character === stringDelimiter) stringDelimiter = null;
1218
+ continue;
1219
+ }
1220
+ if (character === "\"" || character === "'" || character === "`") {
1221
+ stringDelimiter = character;
1222
+ continue;
1223
+ }
1224
+ if (character === "{") {
1225
+ braceDepth++;
1226
+ continue;
1227
+ }
1228
+ if (character === "}") {
1229
+ braceDepth--;
1230
+ continue;
1231
+ }
1232
+ if (braceDepth !== 0) continue;
1233
+ if (character === "<") {
1234
+ const followCharacter = currentLine[charIndex + 1];
1235
+ if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
1236
+ continue;
1237
+ }
1238
+ if (character !== ">") continue;
1239
+ const previousCharacter = currentLine[charIndex - 1];
1240
+ const nextCharacter = currentLine[charIndex + 1];
1241
+ if (previousCharacter === "=" || nextCharacter === "=") continue;
1242
+ if (innerAngleDepth > 0) {
1243
+ innerAngleDepth--;
1244
+ continue;
1245
+ }
1246
+ return lineIndex;
1247
+ }
1248
+ }
1249
+ return null;
1250
+ };
1251
+ //#endregion
1252
+ //#region src/utils/find-enclosing-jsx-opener.ts
1253
+ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
1254
+ for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
1255
+ const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
1256
+ if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
1257
+ }
1258
+ return null;
1259
+ };
1260
+ //#endregion
1261
+ //#region src/utils/find-stacked-disable-comments.ts
1262
+ const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
1263
+ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
1264
+ const collected = [];
1265
+ let isStillInChain = true;
1266
+ for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
1267
+ const candidateLine = lines[candidateIndex];
1268
+ if (candidateLine === void 0) break;
1269
+ const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
1270
+ if (match) {
1271
+ collected.push({
1272
+ commentLineIndex: candidateIndex,
1273
+ ruleList: match[1],
1274
+ isInChain: isStillInChain
1275
+ });
1276
+ continue;
1277
+ }
1278
+ isStillInChain = false;
1279
+ }
1280
+ return collected;
1281
+ };
1282
+ //#endregion
1283
+ //#region src/utils/is-rule-listed-in-comment.ts
1284
+ const isRuleListedInComment = (ruleList, ruleId) => {
1285
+ if (!ruleList?.trim()) return true;
1286
+ return ruleList.split(/[,\s]+/).some((token) => token.trim() === ruleId);
1287
+ };
1288
+ //#endregion
1289
+ //#region src/utils/evaluate-suppression.ts
1290
+ const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
1291
+ const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
1292
+ const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
1293
+ const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
1294
+ const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
1295
+ const buildAdjacentMismatchHint = (comment, ruleId) => {
1296
+ const ruleListText = comment.ruleList?.trim() ?? "";
1297
+ return `An adjacent react-doctor-disable-next-line at line ${comment.commentLineIndex + 1} lists "${ruleListText}" — ${ruleId} is not in that list. Use the comma form: react-doctor-disable-next-line ${ruleListText}, ${ruleId}`;
1298
+ };
1299
+ const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
1300
+ const commentLineNumber = comment.commentLineIndex + 1;
1301
+ const diagnosticLineNumber = diagnosticLineIndex + 1;
1302
+ return `A react-doctor-disable-next-line for ${ruleId} sits at line ${commentLineNumber}, but ${formatLineGap(diagnosticLineNumber - commentLineNumber - 1)} of code separate it from the diagnostic on line ${diagnosticLineNumber}. Move the comment immediately above line ${diagnosticLineNumber}, or extract the surrounding code into a helper so the suppression is adjacent.`;
1303
+ };
1304
+ const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
1305
+ for (const comments of commentsByAnchor) {
1306
+ const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
1307
+ if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
1308
+ const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
1309
+ if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
1310
+ }
1311
+ return null;
1312
+ };
1313
+ const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
1314
+ const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
1315
+ if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
1316
+ isSuppressed: true,
1317
+ nearMissHint: null
1318
+ };
1319
+ const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
1320
+ if (hasChainSuppressor(directComments, ruleId)) return {
1321
+ isSuppressed: true,
1322
+ nearMissHint: null
1323
+ };
1324
+ const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
1325
+ const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
1326
+ if (hasChainSuppressor(openerComments, ruleId)) return {
1327
+ isSuppressed: true,
1328
+ nearMissHint: null
1329
+ };
1330
+ return {
1331
+ isSuppressed: false,
1332
+ nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
1333
+ };
1334
+ };
1335
+ //#endregion
1336
+ //#region src/utils/is-ignored-file.ts
1337
+ const compileIgnoredFilePatterns = (userConfig) => {
1338
+ const files = userConfig?.ignore?.files;
1339
+ if (!Array.isArray(files)) return [];
1340
+ return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
1341
+ };
1342
+ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
1343
+ if (patterns.length === 0) return false;
1344
+ const relativePath = toRelativePath(filePath, rootDirectory);
1345
+ return patterns.some((pattern) => pattern.test(relativePath));
1346
+ };
1347
+ //#endregion
1348
+ //#region src/utils/filter-diagnostics.ts
1349
+ const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
1350
+ const resolveCandidateReadPath = (rootDirectory, filePath) => {
1351
+ const normalizedFile = filePath.replace(/\\/g, "/");
1352
+ if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
1353
+ return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
1354
+ };
1355
+ const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
1356
+ const cache = /* @__PURE__ */ new Map();
1357
+ return (filePath) => {
1358
+ const cached = cache.get(filePath);
1359
+ if (cached !== void 0) return cached;
1360
+ const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
1361
+ cache.set(filePath, lines);
1362
+ return lines;
1363
+ };
1364
+ };
1365
+ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
1366
+ for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
1367
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
1368
+ if (!match) continue;
1369
+ const fullTagName = match[1];
1370
+ const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
1371
+ return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
1067
1372
  }
1068
- let ancestorDirectory = path.dirname(rootDirectory);
1069
- while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1070
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
1071
- if (ancestorConfig) {
1072
- cachedConfigs.set(rootDirectory, ancestorConfig);
1073
- return ancestorConfig;
1074
- }
1075
- if (isProjectBoundary(ancestorDirectory)) {
1076
- cachedConfigs.set(rootDirectory, null);
1077
- return null;
1373
+ return false;
1374
+ };
1375
+ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
1376
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
1377
+ const ignoredFilePatterns = compileIgnoredFilePatterns(config);
1378
+ const compiledOverrides = compileIgnoreOverrides(config);
1379
+ const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
1380
+ const hasTextComponents = textComponentNames.size > 0;
1381
+ const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
1382
+ return diagnostics.filter((diagnostic) => {
1383
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
1384
+ if (ignoredRules.has(ruleIdentifier)) return false;
1385
+ if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
1386
+ if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
1387
+ if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
1388
+ const lines = getFileLines(diagnostic.filePath);
1389
+ if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
1078
1390
  }
1079
- ancestorDirectory = path.dirname(ancestorDirectory);
1080
- }
1081
- cachedConfigs.set(rootDirectory, null);
1082
- return null;
1391
+ return true;
1392
+ });
1393
+ };
1394
+ const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
1395
+ const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
1396
+ return diagnostics.flatMap((diagnostic) => {
1397
+ if (diagnostic.line <= 0) return [diagnostic];
1398
+ const lines = getFileLines(diagnostic.filePath);
1399
+ if (!lines) return [diagnostic];
1400
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
1401
+ const evaluation = evaluateSuppression(lines, diagnostic.line - 1, ruleIdentifier);
1402
+ if (evaluation.isSuppressed) return [];
1403
+ return evaluation.nearMissHint ? [{
1404
+ ...diagnostic,
1405
+ suppressionHint: evaluation.nearMissHint
1406
+ }] : [diagnostic];
1407
+ });
1408
+ };
1409
+ //#endregion
1410
+ //#region src/utils/merge-and-filter-diagnostics.ts
1411
+ const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync, options = {}) => {
1412
+ const filtered = userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics;
1413
+ if (options.respectInlineDisables === false) return filtered;
1414
+ return filterInlineSuppressions(filtered, directory, readFileLinesSync);
1415
+ };
1416
+ //#endregion
1417
+ //#region src/utils/parse-react-major.ts
1418
+ const parseReactMajor = (reactVersion) => {
1419
+ if (typeof reactVersion !== "string") return null;
1420
+ const trimmed = reactVersion.trim();
1421
+ if (trimmed.length === 0) return null;
1422
+ const match = trimmed.match(/(\d+)/);
1423
+ if (!match) return null;
1424
+ const major = Number.parseInt(match[1], 10);
1425
+ if (!Number.isFinite(major) || major <= 0) return null;
1426
+ return major;
1083
1427
  };
1084
1428
  //#endregion
1085
1429
  //#region src/utils/read-file-lines-node.ts
@@ -1138,116 +1482,6 @@ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
1138
1482
  });
1139
1483
  };
1140
1484
  //#endregion
1141
- //#region src/core/calculate-score-locally.ts
1142
- const getScoreLabel = (score) => {
1143
- if (score >= 75) return "Great";
1144
- if (score >= 50) return "Needs work";
1145
- return "Critical";
1146
- };
1147
- const countUniqueRules = (diagnostics) => {
1148
- const errorRules = /* @__PURE__ */ new Set();
1149
- const warningRules = /* @__PURE__ */ new Set();
1150
- for (const diagnostic of diagnostics) {
1151
- const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
1152
- if (diagnostic.severity === "error") errorRules.add(ruleKey);
1153
- else warningRules.add(ruleKey);
1154
- }
1155
- return {
1156
- errorRuleCount: errorRules.size,
1157
- warningRuleCount: warningRules.size
1158
- };
1159
- };
1160
- const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
1161
- const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
1162
- return Math.max(0, Math.round(100 - penalty));
1163
- };
1164
- const calculateScoreLocally = (diagnostics) => {
1165
- const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
1166
- const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
1167
- return {
1168
- score,
1169
- label: getScoreLabel(score)
1170
- };
1171
- };
1172
- //#endregion
1173
- //#region src/core/try-score-from-api.ts
1174
- const parseScoreResult = (value) => {
1175
- if (typeof value !== "object" || value === null) return null;
1176
- if (!("score" in value) || !("label" in value)) return null;
1177
- const scoreValue = Reflect.get(value, "score");
1178
- const labelValue = Reflect.get(value, "label");
1179
- if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
1180
- return {
1181
- score: scoreValue,
1182
- label: labelValue
1183
- };
1184
- };
1185
- const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
1186
- const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
1187
- const describeFailure = (error) => {
1188
- if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
1189
- if (error instanceof Error && error.message) return error.message;
1190
- return String(error);
1191
- };
1192
- const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
1193
- if (typeof fetchImplementation !== "function") return null;
1194
- const controller = new AbortController();
1195
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1196
- try {
1197
- const response = await fetchImplementation(SCORE_API_URL, {
1198
- method: "POST",
1199
- headers: { "Content-Type": "application/json" },
1200
- body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
1201
- signal: controller.signal
1202
- });
1203
- if (!response.ok) {
1204
- console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText} — using local scoring`);
1205
- return null;
1206
- }
1207
- return parseScoreResult(await response.json());
1208
- } catch (error) {
1209
- console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)}) — using local scoring`);
1210
- return null;
1211
- } finally {
1212
- clearTimeout(timeoutId);
1213
- }
1214
- };
1215
- //#endregion
1216
- //#region src/utils/calculate-score-browser.ts
1217
- const getGlobalFetch = () => typeof fetch === "function" ? fetch : void 0;
1218
- const calculateScore$1 = async (diagnostics, fetchImplementation = getGlobalFetch()) => await tryScoreFromApi(diagnostics, fetchImplementation) ?? calculateScoreLocally(diagnostics);
1219
- //#endregion
1220
- //#region src/utils/proxy-fetch.ts
1221
- const getGlobalProcess = () => {
1222
- const candidate = globalThis.process;
1223
- return candidate?.versions?.node ? candidate : void 0;
1224
- };
1225
- const getProxyUrl = () => {
1226
- const proc = getGlobalProcess();
1227
- if (!proc?.env) return void 0;
1228
- return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
1229
- };
1230
- const createProxyDispatcher = async (proxyUrl) => {
1231
- try {
1232
- const { ProxyAgent } = await import("undici");
1233
- return new ProxyAgent(proxyUrl);
1234
- } catch {
1235
- return null;
1236
- }
1237
- };
1238
- const proxyFetch = async (url, init) => {
1239
- const proxyUrl = getProxyUrl();
1240
- const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
1241
- const fetchInit = {
1242
- ...init,
1243
- ...dispatcher ? { dispatcher } : {}
1244
- };
1245
- return fetch(url, fetchInit);
1246
- };
1247
- //#endregion
1248
- //#region src/utils/calculate-score-node.ts
1249
- const calculateScore = (diagnostics) => calculateScore$1(diagnostics, proxyFetch);
1250
- //#endregion
1251
1485
  //#region src/utils/collect-unused-file-paths.ts
1252
1486
  const collectUnusedFilePaths = (filesIssues) => {
1253
1487
  if (filesIssues instanceof Set) return [...filesIssues];
@@ -1277,36 +1511,57 @@ const extractFailedPluginName = (error) => {
1277
1511
  //#region src/utils/has-knip-config.ts
1278
1512
  const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
1279
1513
  //#endregion
1514
+ //#region src/utils/sanitize-knip-config-patterns.ts
1515
+ const isMeaningfulPattern = (value) => typeof value !== "string" || value.trim().length > 0;
1516
+ const sanitizeStringArray = (values) => values.filter((entry) => typeof entry === "string" ? entry.trim().length > 0 : true);
1517
+ const sanitizeKnipConfigPatterns = (parsedConfig) => {
1518
+ for (const [key, value] of Object.entries(parsedConfig)) {
1519
+ if (typeof value === "string") {
1520
+ if (!isMeaningfulPattern(value)) delete parsedConfig[key];
1521
+ continue;
1522
+ }
1523
+ if (Array.isArray(value)) {
1524
+ if (value.length === 0) continue;
1525
+ const sanitized = sanitizeStringArray(value);
1526
+ if (sanitized.length === value.length) continue;
1527
+ if (sanitized.length === 0) delete parsedConfig[key];
1528
+ else parsedConfig[key] = sanitized;
1529
+ continue;
1530
+ }
1531
+ if (isPlainObject(value)) sanitizeKnipConfigPatterns(value);
1532
+ }
1533
+ };
1534
+ //#endregion
1280
1535
  //#region src/utils/run-knip.ts
1281
- const KNIP_ISSUE_TYPE_DESCRIPTORS = {
1282
- files: {
1536
+ const KNIP_ISSUE_TYPE_DESCRIPTORS = new Map([
1537
+ ["files", {
1283
1538
  category: "Dead Code",
1284
1539
  message: "Unused file",
1285
1540
  severity: "warning"
1286
- },
1287
- exports: {
1541
+ }],
1542
+ ["exports", {
1288
1543
  category: "Dead Code",
1289
1544
  message: "Unused export",
1290
1545
  severity: "warning"
1291
- },
1292
- types: {
1546
+ }],
1547
+ ["types", {
1293
1548
  category: "Dead Code",
1294
1549
  message: "Unused type",
1295
1550
  severity: "warning"
1296
- },
1297
- duplicates: {
1551
+ }],
1552
+ ["duplicates", {
1298
1553
  category: "Dead Code",
1299
1554
  message: "Duplicate export",
1300
1555
  severity: "warning"
1301
- }
1302
- };
1556
+ }]
1557
+ ]);
1303
1558
  const FALLBACK_KNIP_DESCRIPTOR = {
1304
1559
  category: "Dead Code",
1305
1560
  message: "Issue",
1306
1561
  severity: "warning"
1307
1562
  };
1308
1563
  const collectIssueRecords = (records, issueType, rootDirectory) => {
1309
- const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS[issueType] ?? FALLBACK_KNIP_DESCRIPTOR;
1564
+ const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get(issueType) ?? FALLBACK_KNIP_DESCRIPTOR;
1310
1565
  const diagnostics = [];
1311
1566
  for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
1312
1567
  filePath: path.relative(rootDirectory, issue.filePath),
@@ -1344,7 +1599,7 @@ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
1344
1599
  const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
1345
1600
  const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
1346
1601
  const failedPlugin = extractFailedPluginName(error);
1347
- if (!failedPlugin || !(failedPlugin in parsedConfig) || disabledPlugins.has(failedPlugin)) return false;
1602
+ if (!failedPlugin || !Object.hasOwn(parsedConfig, failedPlugin) || disabledPlugins.has(failedPlugin)) return false;
1348
1603
  disabledPlugins.add(failedPlugin);
1349
1604
  parsedConfig[failedPlugin] = false;
1350
1605
  return true;
@@ -1358,6 +1613,7 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
1358
1613
  ...tsConfigFile ? { tsConfigFile } : {}
1359
1614
  }));
1360
1615
  const parsedConfig = options.parsedConfig;
1616
+ sanitizeKnipConfigPatterns(parsedConfig);
1361
1617
  const disabledPlugins = /* @__PURE__ */ new Set();
1362
1618
  let lastKnipError;
1363
1619
  for (let attempt = 0; attempt < 6; attempt++) try {
@@ -1389,7 +1645,7 @@ const runKnip = async (rootDirectory) => {
1389
1645
  if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
1390
1646
  const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
1391
1647
  const diagnostics = [];
1392
- const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
1648
+ const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get("files") ?? FALLBACK_KNIP_DESCRIPTOR;
1393
1649
  for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
1394
1650
  filePath: path.relative(rootDirectory, unusedFilePath),
1395
1651
  plugin: "knip",
@@ -1432,6 +1688,103 @@ const batchIncludePaths = (baseArgs, includePaths) => {
1432
1688
  return batches;
1433
1689
  };
1434
1690
  //#endregion
1691
+ //#region src/utils/can-oxlint-extend-config.ts
1692
+ const EXTENDS_LOCAL_PATH_PREFIXES = [
1693
+ "./",
1694
+ "../",
1695
+ "/"
1696
+ ];
1697
+ const isLocalPathExtend = (entry) => {
1698
+ for (const prefix of EXTENDS_LOCAL_PATH_PREFIXES) if (entry.startsWith(prefix)) return true;
1699
+ return false;
1700
+ };
1701
+ const stripJsoncComments = (raw) => {
1702
+ let result = "";
1703
+ let cursor = 0;
1704
+ let inString = false;
1705
+ let stringQuote = "";
1706
+ while (cursor < raw.length) {
1707
+ const character = raw[cursor];
1708
+ const nextCharacter = raw[cursor + 1];
1709
+ if (inString) {
1710
+ result += character;
1711
+ if (character === "\\" && cursor + 1 < raw.length) {
1712
+ result += nextCharacter;
1713
+ cursor += 2;
1714
+ continue;
1715
+ }
1716
+ if (character === stringQuote) inString = false;
1717
+ cursor += 1;
1718
+ continue;
1719
+ }
1720
+ if (character === "\"" || character === "'") {
1721
+ inString = true;
1722
+ stringQuote = character;
1723
+ result += character;
1724
+ cursor += 1;
1725
+ continue;
1726
+ }
1727
+ if (character === "/" && nextCharacter === "/") {
1728
+ const lineEndIndex = raw.indexOf("\n", cursor);
1729
+ cursor = lineEndIndex === -1 ? raw.length : lineEndIndex;
1730
+ continue;
1731
+ }
1732
+ if (character === "/" && nextCharacter === "*") {
1733
+ const blockEndIndex = raw.indexOf("*/", cursor + 2);
1734
+ cursor = blockEndIndex === -1 ? raw.length : blockEndIndex + 2;
1735
+ continue;
1736
+ }
1737
+ result += character;
1738
+ cursor += 1;
1739
+ }
1740
+ return result;
1741
+ };
1742
+ const parseJsonOrJsonc = (raw) => {
1743
+ try {
1744
+ return JSON.parse(raw);
1745
+ } catch {
1746
+ return JSON.parse(stripJsoncComments(raw));
1747
+ }
1748
+ };
1749
+ const canOxlintExtendConfig = (configPath) => {
1750
+ if (!configPath.endsWith(".eslintrc.json")) return true;
1751
+ let parsed;
1752
+ try {
1753
+ parsed = parseJsonOrJsonc(fs.readFileSync(configPath, "utf-8"));
1754
+ } catch {
1755
+ return true;
1756
+ }
1757
+ if (!isPlainObject(parsed)) return true;
1758
+ const extendsValue = parsed.extends;
1759
+ if (extendsValue === void 0 || extendsValue === null) return true;
1760
+ const extendsEntries = Array.isArray(extendsValue) ? extendsValue : [extendsValue];
1761
+ if (extendsEntries.length === 0) return true;
1762
+ return extendsEntries.some((entry) => typeof entry === "string" && isLocalPathExtend(entry));
1763
+ };
1764
+ //#endregion
1765
+ //#region src/utils/detect-user-lint-config.ts
1766
+ const findFirstLintConfigInDirectory = (directory) => {
1767
+ for (const filename of ADOPTABLE_LINT_CONFIG_FILENAMES) {
1768
+ const candidatePath = path.join(directory, filename);
1769
+ if (isFile(candidatePath)) return candidatePath;
1770
+ }
1771
+ return null;
1772
+ };
1773
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1774
+ const detectUserLintConfigPaths = (rootDirectory) => {
1775
+ const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
1776
+ if (directLintConfig) return [directLintConfig];
1777
+ if (isProjectBoundary(rootDirectory)) return [];
1778
+ let ancestorDirectory = path.dirname(rootDirectory);
1779
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1780
+ const ancestorLintConfig = findFirstLintConfigInDirectory(ancestorDirectory);
1781
+ if (ancestorLintConfig) return [ancestorLintConfig];
1782
+ if (isProjectBoundary(ancestorDirectory)) return [];
1783
+ ancestorDirectory = path.dirname(ancestorDirectory);
1784
+ }
1785
+ return [];
1786
+ };
1787
+ //#endregion
1435
1788
  //#region src/oxlint-config.ts
1436
1789
  const esmRequire$1 = createRequire(import.meta.url);
1437
1790
  const NEXTJS_RULES = {
@@ -1512,16 +1865,45 @@ const REACT_COMPILER_RULES = {
1512
1865
  "react-hooks-js/incompatible-library": "warn",
1513
1866
  "react-hooks-js/todo": "warn"
1514
1867
  };
1868
+ const readPluginRuleNames = (pluginSpecifier) => {
1869
+ try {
1870
+ const pluginModule = esmRequire$1(pluginSpecifier);
1871
+ const rules = pluginModule.rules ?? pluginModule.default?.rules;
1872
+ if (rules === void 0) return /* @__PURE__ */ new Set();
1873
+ return new Set(Object.keys(rules));
1874
+ } catch {
1875
+ return /* @__PURE__ */ new Set();
1876
+ }
1877
+ };
1515
1878
  const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
1516
1879
  if (!hasReactCompiler || customRulesOnly) return null;
1880
+ let pluginSpecifier;
1517
1881
  try {
1518
- return {
1519
- name: "react-hooks-js",
1520
- specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1521
- };
1882
+ pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-hooks");
1522
1883
  } catch {
1523
1884
  return null;
1524
1885
  }
1886
+ return {
1887
+ entry: {
1888
+ name: "react-hooks-js",
1889
+ specifier: pluginSpecifier
1890
+ },
1891
+ availableRuleNames: readPluginRuleNames(pluginSpecifier)
1892
+ };
1893
+ };
1894
+ const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
1895
+ if (availableRuleNames.size === 0) return rules;
1896
+ const ruleKeyPrefix = `${pluginNamespace}/`;
1897
+ const filtered = {};
1898
+ for (const [ruleKey, severity] of Object.entries(rules)) {
1899
+ if (!ruleKey.startsWith(ruleKeyPrefix)) {
1900
+ filtered[ruleKey] = severity;
1901
+ continue;
1902
+ }
1903
+ const ruleName = ruleKey.slice(ruleKeyPrefix.length);
1904
+ if (availableRuleNames.has(ruleName)) filtered[ruleKey] = severity;
1905
+ }
1906
+ return filtered;
1525
1907
  };
1526
1908
  const TANSTACK_QUERY_RULES = {
1527
1909
  "react-doctor/query-stable-query-client": "warn",
@@ -1564,18 +1946,27 @@ const BUILTIN_A11Y_RULES = {
1564
1946
  const GLOBAL_REACT_DOCTOR_RULES = {
1565
1947
  "react-doctor/no-derived-state-effect": "warn",
1566
1948
  "react-doctor/no-fetch-in-effect": "warn",
1949
+ "react-doctor/no-mirror-prop-effect": "warn",
1950
+ "react-doctor/no-mutable-in-deps": "error",
1567
1951
  "react-doctor/no-cascading-set-state": "warn",
1952
+ "react-doctor/no-effect-chain": "warn",
1568
1953
  "react-doctor/no-effect-event-handler": "warn",
1569
1954
  "react-doctor/no-effect-event-in-deps": "error",
1955
+ "react-doctor/no-event-trigger-state": "warn",
1570
1956
  "react-doctor/no-prop-callback-in-effect": "warn",
1571
1957
  "react-doctor/no-derived-useState": "warn",
1958
+ "react-doctor/no-direct-state-mutation": "warn",
1959
+ "react-doctor/no-set-state-in-render": "warn",
1960
+ "react-doctor/prefer-use-effect-event": "warn",
1572
1961
  "react-doctor/prefer-useReducer": "warn",
1962
+ "react-doctor/prefer-use-sync-external-store": "warn",
1573
1963
  "react-doctor/rerender-lazy-state-init": "warn",
1574
1964
  "react-doctor/rerender-functional-setstate": "warn",
1575
1965
  "react-doctor/rerender-dependencies": "error",
1576
1966
  "react-doctor/rerender-state-only-in-handlers": "warn",
1577
1967
  "react-doctor/rerender-defer-reads-hook": "warn",
1578
1968
  "react-doctor/advanced-event-handler-refs": "warn",
1969
+ "react-doctor/effect-needs-cleanup": "error",
1579
1970
  "react-doctor/no-giant-component": "warn",
1580
1971
  "react-doctor/no-render-in-render": "warn",
1581
1972
  "react-doctor/no-many-boolean-props": "warn",
@@ -1583,6 +1974,10 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1583
1974
  "react-doctor/no-render-prop-children": "warn",
1584
1975
  "react-doctor/no-nested-component-definition": "error",
1585
1976
  "react-doctor/react-compiler-destructure-method": "warn",
1977
+ "react-doctor/no-legacy-class-lifecycles": "error",
1978
+ "react-doctor/no-legacy-context-api": "error",
1979
+ "react-doctor/no-default-props": "warn",
1980
+ "react-doctor/no-react-dom-deprecated-apis": "warn",
1586
1981
  "react-doctor/no-usememo-simple-expression": "warn",
1587
1982
  "react-doctor/no-layout-property-animation": "error",
1588
1983
  "react-doctor/rerender-memo-with-default-value": "warn",
@@ -1631,6 +2026,7 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1631
2026
  "react-doctor/rendering-conditional-render": "warn",
1632
2027
  "react-doctor/rendering-svg-precision": "warn",
1633
2028
  "react-doctor/no-prevent-default": "warn",
2029
+ "react-doctor/no-uncontrolled-input": "warn",
1634
2030
  "react-doctor/no-document-start-view-transition": "warn",
1635
2031
  "react-doctor/no-flush-sync": "warn",
1636
2032
  "react-doctor/server-auth-actions": "error",
@@ -1658,6 +2054,14 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1658
2054
  "react-doctor/no-disabled-zoom": "error",
1659
2055
  "react-doctor/no-outline-none": "warn",
1660
2056
  "react-doctor/no-long-transition-duration": "warn",
2057
+ "react-doctor/design-no-bold-heading": "warn",
2058
+ "react-doctor/design-no-redundant-padding-axes": "warn",
2059
+ "react-doctor/design-no-redundant-size-axes": "warn",
2060
+ "react-doctor/design-no-space-on-flex-children": "warn",
2061
+ "react-doctor/design-no-em-dash-in-jsx-text": "warn",
2062
+ "react-doctor/design-no-three-period-ellipsis": "warn",
2063
+ "react-doctor/design-no-default-tailwind-palette": "warn",
2064
+ "react-doctor/design-no-vague-button-label": "warn",
1661
2065
  "react-doctor/async-parallel": "warn"
1662
2066
  };
1663
2067
  const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
@@ -1667,9 +2071,38 @@ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
1667
2071
  ...Object.keys(TANSTACK_START_RULES),
1668
2072
  ...Object.keys(TANSTACK_QUERY_RULES)
1669
2073
  ]);
1670
- const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false }) => {
2074
+ const VERSION_GATED_RULE_IDS = new Map([
2075
+ ["react-doctor/no-react19-deprecated-apis", {
2076
+ minMajor: 19,
2077
+ mode: "deprecation-warning"
2078
+ }],
2079
+ ["react-doctor/no-default-props", {
2080
+ minMajor: 19,
2081
+ mode: "deprecation-warning"
2082
+ }],
2083
+ ["react-doctor/no-react-dom-deprecated-apis", {
2084
+ minMajor: 18,
2085
+ mode: "deprecation-warning"
2086
+ }],
2087
+ ["react-doctor/prefer-use-effect-event", {
2088
+ minMajor: 19,
2089
+ mode: "prefer-newer-api"
2090
+ }]
2091
+ ]);
2092
+ const filterRulesByReactMajor = (rules, reactMajorVersion) => {
2093
+ return Object.fromEntries(Object.entries(rules).filter(([ruleKey]) => {
2094
+ const gate = VERSION_GATED_RULE_IDS.get(ruleKey);
2095
+ if (gate === void 0) return true;
2096
+ if (gate.mode === "deprecation-warning") return true;
2097
+ if (reactMajorVersion === null) return true;
2098
+ return reactMajorVersion >= gate.minMajor;
2099
+ }));
2100
+ };
2101
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
1671
2102
  const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
2103
+ const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
1672
2104
  return {
2105
+ ...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
1673
2106
  categories: {
1674
2107
  correctness: "off",
1675
2108
  suspicious: "off",
@@ -1680,12 +2113,12 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
1680
2113
  nursery: "off"
1681
2114
  },
1682
2115
  plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
1683
- jsPlugins: reactHooksJsPlugin ? [reactHooksJsPlugin, pluginPath] : [pluginPath],
2116
+ jsPlugins: reactHooksJsPlugin ? [reactHooksJsPlugin.entry, pluginPath] : [pluginPath],
1684
2117
  rules: {
1685
2118
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
1686
2119
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
1687
- ...reactHooksJsPlugin ? REACT_COMPILER_RULES : {},
1688
- ...GLOBAL_REACT_DOCTOR_RULES,
2120
+ ...reactCompilerRules,
2121
+ ...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
1689
2122
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1690
2123
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
1691
2124
  ...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
@@ -1797,23 +2230,43 @@ const PLUGIN_CATEGORY_MAP = {
1797
2230
  "react-hooks-js": "React Compiler",
1798
2231
  "react-doctor": "Other",
1799
2232
  "jsx-a11y": "Accessibility",
1800
- knip: "Dead Code"
2233
+ knip: "Dead Code",
2234
+ eslint: "Correctness",
2235
+ oxc: "Correctness",
2236
+ typescript: "Correctness",
2237
+ unicorn: "Correctness",
2238
+ import: "Bundle Size",
2239
+ promise: "Correctness",
2240
+ n: "Correctness",
2241
+ node: "Correctness",
2242
+ vitest: "Correctness",
2243
+ jest: "Correctness",
2244
+ nextjs: "Next.js"
1801
2245
  };
1802
2246
  const RULE_CATEGORY_MAP = {
1803
2247
  "react-doctor/no-derived-state-effect": "State & Effects",
1804
2248
  "react-doctor/no-fetch-in-effect": "State & Effects",
2249
+ "react-doctor/no-mirror-prop-effect": "State & Effects",
2250
+ "react-doctor/no-mutable-in-deps": "State & Effects",
1805
2251
  "react-doctor/no-cascading-set-state": "State & Effects",
2252
+ "react-doctor/no-effect-chain": "State & Effects",
1806
2253
  "react-doctor/no-effect-event-handler": "State & Effects",
1807
2254
  "react-doctor/no-effect-event-in-deps": "State & Effects",
2255
+ "react-doctor/no-event-trigger-state": "State & Effects",
1808
2256
  "react-doctor/no-prop-callback-in-effect": "State & Effects",
1809
2257
  "react-doctor/no-derived-useState": "State & Effects",
2258
+ "react-doctor/no-direct-state-mutation": "State & Effects",
2259
+ "react-doctor/no-set-state-in-render": "State & Effects",
2260
+ "react-doctor/prefer-use-effect-event": "State & Effects",
1810
2261
  "react-doctor/prefer-useReducer": "State & Effects",
2262
+ "react-doctor/prefer-use-sync-external-store": "State & Effects",
1811
2263
  "react-doctor/rerender-lazy-state-init": "Performance",
1812
2264
  "react-doctor/rerender-functional-setstate": "Performance",
1813
2265
  "react-doctor/rerender-dependencies": "State & Effects",
1814
2266
  "react-doctor/rerender-state-only-in-handlers": "Performance",
1815
2267
  "react-doctor/rerender-defer-reads-hook": "Performance",
1816
2268
  "react-doctor/advanced-event-handler-refs": "Performance",
2269
+ "react-doctor/effect-needs-cleanup": "State & Effects",
1817
2270
  "react-doctor/no-generic-handler-names": "Architecture",
1818
2271
  "react-doctor/no-giant-component": "Architecture",
1819
2272
  "react-doctor/no-many-boolean-props": "Architecture",
@@ -1822,6 +2275,10 @@ const RULE_CATEGORY_MAP = {
1822
2275
  "react-doctor/no-render-in-render": "Architecture",
1823
2276
  "react-doctor/no-nested-component-definition": "Correctness",
1824
2277
  "react-doctor/react-compiler-destructure-method": "Architecture",
2278
+ "react-doctor/no-legacy-class-lifecycles": "Correctness",
2279
+ "react-doctor/no-legacy-context-api": "Correctness",
2280
+ "react-doctor/no-default-props": "Architecture",
2281
+ "react-doctor/no-react-dom-deprecated-apis": "Architecture",
1825
2282
  "react-doctor/no-usememo-simple-expression": "Performance",
1826
2283
  "react-doctor/no-layout-property-animation": "Performance",
1827
2284
  "react-doctor/rerender-memo-with-default-value": "Performance",
@@ -1855,6 +2312,7 @@ const RULE_CATEGORY_MAP = {
1855
2312
  "react-doctor/rendering-conditional-render": "Correctness",
1856
2313
  "react-doctor/rendering-svg-precision": "Performance",
1857
2314
  "react-doctor/no-prevent-default": "Correctness",
2315
+ "react-doctor/no-uncontrolled-input": "Correctness",
1858
2316
  "react-doctor/no-document-start-view-transition": "Correctness",
1859
2317
  "react-doctor/no-flush-sync": "Performance",
1860
2318
  "react-doctor/nextjs-no-img-element": "Next.js",
@@ -1904,6 +2362,14 @@ const RULE_CATEGORY_MAP = {
1904
2362
  "react-doctor/no-disabled-zoom": "Accessibility",
1905
2363
  "react-doctor/no-outline-none": "Accessibility",
1906
2364
  "react-doctor/no-long-transition-duration": "Performance",
2365
+ "react-doctor/design-no-bold-heading": "Architecture",
2366
+ "react-doctor/design-no-redundant-padding-axes": "Architecture",
2367
+ "react-doctor/design-no-redundant-size-axes": "Architecture",
2368
+ "react-doctor/design-no-space-on-flex-children": "Architecture",
2369
+ "react-doctor/design-no-em-dash-in-jsx-text": "Architecture",
2370
+ "react-doctor/design-no-three-period-ellipsis": "Architecture",
2371
+ "react-doctor/design-no-default-tailwind-palette": "Architecture",
2372
+ "react-doctor/design-no-vague-button-label": "Accessibility",
1907
2373
  "react-doctor/js-flatmap-filter": "Performance",
1908
2374
  "react-doctor/js-combine-iterations": "Performance",
1909
2375
  "react-doctor/js-tosorted-immutable": "Performance",
@@ -1961,10 +2427,18 @@ const RULE_CATEGORY_MAP = {
1961
2427
  const RULE_HELP_MAP = {
1962
2428
  "no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`. See https://react.dev/learn/you-might-not-need-an-effect",
1963
2429
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
2430
+ "no-mirror-prop-effect": "Delete both the `useState` and the `useEffect` and read the prop directly during render. Mirroring a prop into local state forces a stale first render before the effect re-syncs",
2431
+ "no-mutable-in-deps": "Read mutable values (`location.pathname`, `ref.current`) inside the effect body instead of in the deps array, or subscribe with `useSyncExternalStore`. Mutations to these don't trigger re-renders, so listing them in deps doesn't make the effect react to changes",
1964
2432
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
2433
+ "no-effect-chain": "Compute as much as possible during render (e.g. `const isGameOver = round > 5`) and write all related state inside the event handler that originally fires the chain. Each effect link adds an extra render and makes the code rigid as requirements evolve",
1965
2434
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
2435
+ "no-event-trigger-state": "Delete the trigger state (`useState(null)` plus the `useEffect` that watches it) and call the side-effect (`post(...)` / `navigate(...)` / `track(...)`) directly inside the event handler that previously called the setter. State should not exist purely to schedule effect runs",
1966
2436
  "no-derived-useState": "Remove useState and compute the value inline: `const value = transform(propName)`",
2437
+ "no-direct-state-mutation": "Replace the mutation with a setter call that produces a new reference: `setItems([...items, newItem])`, `setItems(items.filter(x => x !== target))`, `setItems(items.toSorted(...))`. React only re-renders on a new reference, so in-place updates are silently dropped",
2438
+ "no-set-state-in-render": "Move the setter call into a `useEffect`, an event handler, or replace the state with a value computed during render. Calling a setter at render time triggers another render, which calls the setter again — an infinite loop",
2439
+ "prefer-use-effect-event": "Wrap the callback with `useEffectEvent(callback)` (React 19+) and call the resulting binding from inside the sub-handler. The Effect Event captures the latest props/state without being a reactive dep, so the effect doesn't re-subscribe on every parent render. See https://react.dev/reference/react/useEffectEvent",
1967
2440
  "prefer-useReducer": "Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`",
2441
+ "prefer-use-sync-external-store": "Replace the `useState(getSnapshot())` + `useEffect(() => store.subscribe(() => setSnapshot(getSnapshot())))` pair with `useSyncExternalStore(store.subscribe, getSnapshot)`. The hook handles tearing during concurrent renders and SSR snapshots; the manual subscribe pattern doesn't",
1968
2442
  "rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
1969
2443
  "rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
1970
2444
  "rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
@@ -1973,7 +2447,11 @@ const RULE_HELP_MAP = {
1973
2447
  "no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
1974
2448
  "no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
1975
2449
  "no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
1976
- "no-react19-deprecated-apis": "Pass `ref` as a regular prop on function components — `forwardRef` is no longer needed in React 19+. Replace `useContext(X)` with `use(X)` for branch-aware context reads.",
2450
+ "no-react19-deprecated-apis": "Pass `ref` as a regular prop on function components — `forwardRef` is no longer needed in React 19+. Replace `useContext(X)` with `use(X)` for branch-aware context reads. Only enabled on projects detected as React 19+.",
2451
+ "no-legacy-class-lifecycles": "Move side effects in `componentWillMount` to `componentDidMount`; replace `componentWillReceiveProps` with `componentDidUpdate` (compare prevProps) or the static `getDerivedStateFromProps` for pure state derivation; replace `componentWillUpdate` with `getSnapshotBeforeUpdate` paired with `componentDidUpdate`. The `UNSAFE_` prefix only silences the warning — React 19 removes both forms.",
2452
+ "no-legacy-context-api": "Replace `childContextTypes` + `getChildContext` with `const MyContext = createContext(...)` + `<MyContext.Provider value={...}>`; replace `contextTypes` with `static contextType = MyContext` (single context) or `useContext()` / `use()` from a function component. The provider and every consumer must migrate together — partial migrations leave consumers reading the wrong context.",
2453
+ "no-default-props": "React 19 removes `Component.defaultProps` for function components. Move the defaults into the destructured props parameter: `function Foo({ size = \"md\", variant = \"primary\" })` instead of `Foo.defaultProps = { size: \"md\", variant: \"primary\" }`.",
2454
+ "no-react-dom-deprecated-apis": "Switch the legacy `react-dom` root API (`render` / `hydrate` / `unmountComponentAtNode`) to `createRoot` / `hydrateRoot` / `root.unmount()` from `react-dom/client`. Replace `findDOMNode` with a ref. The whole `react-dom/test-utils` entry point is removed in React 19 — use `act` from `react` and `fireEvent` / `render` from `@testing-library/react`. Only enabled on projects detected as React 18+.",
1977
2455
  "no-render-prop-children": "Replace `renderXxx` props with compound subcomponents (e.g. `<Modal.Header>`) or `children` so the parent doesn't dictate every customization point",
1978
2456
  "no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
1979
2457
  "no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
@@ -1988,6 +2466,7 @@ const RULE_HELP_MAP = {
1988
2466
  "rerender-defer-reads-hook": "Read the URL state inside the handler (e.g. `new URL(window.location.href).searchParams`) so the component doesn't subscribe and re-render on every URL change",
1989
2467
  "rerender-derived-state-from-hook": "Use a threshold/media-query hook (e.g. `useMediaQuery(\"(max-width: 767px)\")`) — the component re-renders only when the threshold flips, not every pixel",
1990
2468
  "advanced-event-handler-refs": "Store the handler in a ref and have the listener read `handlerRef.current()` — the subscription stays put while the latest handler is always called",
2469
+ "effect-needs-cleanup": "Return a cleanup function that releases the subscription / timer: `return () => target.removeEventListener(name, handler)` for listeners, `return () => clearInterval(id)` / `clearTimeout(id)` for timers, or `return unsubscribe` if the subscribe call already returned one",
1991
2470
  "async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
1992
2471
  "async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
1993
2472
  "react-compiler-destructure-method": "Destructure the method up front: `const { push } = useRouter()` then call `push(...)` directly — clearer dependency graph and easier for React Compiler to memoize",
@@ -2034,9 +2513,18 @@ const RULE_HELP_MAP = {
2034
2513
  "no-disabled-zoom": "Remove `user-scalable=no` and `maximum-scale` from the viewport meta tag. If your layout breaks at 200% zoom, fix the layout — don't punish users with disabilities",
2035
2514
  "no-outline-none": "Use `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px }` to show focus only for keyboard users while hiding it for mouse clicks",
2036
2515
  "no-long-transition-duration": "Keep UI transitions under 1s — 100-150ms for instant feedback, 200-300ms for state changes, 300-500ms for layout changes. Use longer durations only for page-load hero animations",
2516
+ "design-no-bold-heading": "Use `font-semibold` (600) or `font-medium` (500) on headings — 700+ crushes letter counter shapes at display sizes",
2517
+ "design-no-redundant-padding-axes": "Collapse `px-N py-N` to `p-N` when both axes match. Keep them split only when one axis varies at a breakpoint (`py-2 md:py-3`)",
2518
+ "design-no-redundant-size-axes": "Collapse `w-N h-N` to `size-N` (Tailwind v3.4+) when both axes match",
2519
+ "design-no-space-on-flex-children": "Use `gap-*` on the flex/grid parent. `space-x-*` / `space-y-*` produce phantom gaps when a sibling is conditionally rendered, lose vertical spacing on wrapped lines, and don't mirror in RTL",
2520
+ "design-no-em-dash-in-jsx-text": "Replace em dashes in JSX text with commas, colons, semicolons, periods, or parentheses — em dashes read as model-output filler",
2521
+ "design-no-three-period-ellipsis": "Use the typographic ellipsis \"…\" (or `&hellip;`) instead of three periods — pairs with action-with-followup labels (\"Rename…\", \"Loading…\")",
2522
+ "design-no-default-tailwind-palette": "Replace `indigo-*` / `gray-*` / `slate-*` with project tokens, your brand color, or a less-default neutral (`zinc`, `neutral`, `stone`)",
2523
+ "design-no-vague-button-label": "Name the action: \"Save changes\" instead of \"Continue\", \"Send invite\" instead of \"Submit\", \"Delete account\" instead of \"OK\". The label IS the button's accessible name",
2037
2524
  "no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
2038
2525
  "rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
2039
2526
  "no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
2527
+ "no-uncontrolled-input": "Pass an explicit initial value to `useState` (e.g. `useState(\"\")` instead of `useState()`), add `onChange` (or `readOnly` to opt out) when you supply `value`, and drop `defaultValue` on controlled inputs — React ignores it",
2040
2528
  "nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
2041
2529
  "nextjs-async-client-component": "Fetch data in a parent Server Component and pass it as props, or use useQuery/useSWR in the client component",
2042
2530
  "nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
@@ -2119,6 +2607,7 @@ const RULE_HELP_MAP = {
2119
2607
  };
2120
2608
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
2121
2609
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
2610
+ const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
2122
2611
  const cleanDiagnosticMessage = (message, help, plugin, rule) => {
2123
2612
  if (plugin === "react-hooks-js") return {
2124
2613
  message: REACT_COMPILER_MESSAGE,
@@ -2126,7 +2615,7 @@ const cleanDiagnosticMessage = (message, help, plugin, rule) => {
2126
2615
  };
2127
2616
  return {
2128
2617
  message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
2129
- help: help || RULE_HELP_MAP[rule] || ""
2618
+ help: help || lookupOwnString(RULE_HELP_MAP, rule) || ""
2130
2619
  };
2131
2620
  };
2132
2621
  const parseRuleCode = (code) => {
@@ -2154,7 +2643,7 @@ const resolvePluginPath = () => {
2154
2643
  return pluginPath;
2155
2644
  };
2156
2645
  const resolveDiagnosticCategory = (plugin, rule) => {
2157
- return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
2646
+ return lookupOwnString(RULE_CATEGORY_MAP, `${plugin}/${rule}`) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
2158
2647
  };
2159
2648
  const SANITIZED_ENV = (() => {
2160
2649
  const sanitized = {};
@@ -2245,7 +2734,7 @@ const parseOxlintOutput = (stdout) => {
2245
2734
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
2246
2735
  }
2247
2736
  if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
2248
- return parsed.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2737
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2249
2738
  const { plugin, rule } = parseRuleCode(diagnostic.code);
2250
2739
  const primaryLabel = diagnostic.labels[0];
2251
2740
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -2275,8 +2764,8 @@ const validateRuleRegistration = () => {
2275
2764
  const missingCategory = [];
2276
2765
  for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2277
2766
  const ruleName = fullKey.replace(/^react-doctor\//, "");
2278
- if (!(fullKey in RULE_CATEGORY_MAP)) missingCategory.push(fullKey);
2279
- if (!(ruleName in RULE_HELP_MAP)) missingHelp.push(fullKey);
2767
+ if (!Object.hasOwn(RULE_CATEGORY_MAP, fullKey)) missingCategory.push(fullKey);
2768
+ if (!Object.hasOwn(RULE_HELP_MAP, ruleName)) missingHelp.push(fullKey);
2280
2769
  }
2281
2770
  if (missingCategory.length > 0 || missingHelp.length > 0) {
2282
2771
  const detail = [missingCategory.length > 0 ? `Missing RULE_CATEGORY_MAP entries: ${missingCategory.join(", ")}` : null, missingHelp.length > 0 ? `Missing RULE_HELP_MAP entries: ${missingHelp.join(", ")}` : null].filter((entry) => entry !== null).join("; ");
@@ -2284,26 +2773,24 @@ const validateRuleRegistration = () => {
2284
2773
  }
2285
2774
  };
2286
2775
  const runOxlint = async (options) => {
2287
- const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
2776
+ const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, reactMajorVersion = null, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true } = options;
2288
2777
  validateRuleRegistration();
2289
2778
  if (includePaths !== void 0 && includePaths.length === 0) return [];
2290
2779
  const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
2291
2780
  const configPath = path.join(configDirectory, "oxlintrc.json");
2781
+ const pluginPath = resolvePluginPath();
2782
+ const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
2292
2783
  const config = createOxlintConfig({
2293
- pluginPath: resolvePluginPath(),
2784
+ pluginPath,
2294
2785
  framework,
2295
2786
  hasReactCompiler,
2296
2787
  hasTanStackQuery,
2297
- customRulesOnly
2788
+ customRulesOnly,
2789
+ reactMajorVersion,
2790
+ extendsPaths
2298
2791
  });
2299
2792
  const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
2300
2793
  try {
2301
- const fileHandle = fs.openSync(configPath, "wx", 384);
2302
- try {
2303
- fs.writeFileSync(fileHandle, JSON.stringify(config));
2304
- } finally {
2305
- fs.closeSync(fileHandle);
2306
- }
2307
2794
  const baseArgs = [
2308
2795
  resolveOxlintBinary(),
2309
2796
  "-c",
@@ -2322,12 +2809,41 @@ const runOxlint = async (options) => {
2322
2809
  baseArgs.push("--ignore-path", combinedIgnorePath);
2323
2810
  }
2324
2811
  const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
2325
- const allDiagnostics = [];
2326
- for (const batch of fileBatches) {
2327
- const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
2328
- allDiagnostics.push(...parseOxlintOutput(stdout));
2812
+ const writeOxlintConfig = (configToWrite) => {
2813
+ fs.rmSync(configPath, { force: true });
2814
+ const fileHandle = fs.openSync(configPath, "wx", 384);
2815
+ try {
2816
+ fs.writeFileSync(fileHandle, JSON.stringify(configToWrite));
2817
+ } finally {
2818
+ fs.closeSync(fileHandle);
2819
+ }
2820
+ };
2821
+ const spawnLintBatches = async () => {
2822
+ const allDiagnostics = [];
2823
+ for (const batch of fileBatches) {
2824
+ const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
2825
+ allDiagnostics.push(...parseOxlintOutput(stdout));
2826
+ }
2827
+ return allDiagnostics;
2828
+ };
2829
+ writeOxlintConfig(config);
2830
+ try {
2831
+ return await spawnLintBatches();
2832
+ } catch (error) {
2833
+ if (extendsPaths.length === 0) throw error;
2834
+ const reason = error instanceof Error ? error.message : String(error);
2835
+ process.stderr.write(`[react-doctor] could not adopt existing lint config (${reason.split("\n")[0]}); retrying without extends. Set "adoptExistingLintConfig": false to silence.\n`);
2836
+ writeOxlintConfig(createOxlintConfig({
2837
+ pluginPath,
2838
+ framework,
2839
+ hasReactCompiler,
2840
+ hasTanStackQuery,
2841
+ customRulesOnly,
2842
+ reactMajorVersion,
2843
+ extendsPaths: []
2844
+ }));
2845
+ return await spawnLintBatches();
2329
2846
  }
2330
- return allDiagnostics;
2331
2847
  } finally {
2332
2848
  restoreDisableDirectives();
2333
2849
  fs.rmSync(configDirectory, {
@@ -2479,36 +2995,60 @@ const toJsonReport = (result, options) => buildJsonReport({
2479
2995
  }],
2480
2996
  totalElapsedMilliseconds: result.elapsedMilliseconds
2481
2997
  });
2998
+ const EMPTY_DIAGNOSTICS = [];
2999
+ const settledOrEmpty = (settled, label) => {
3000
+ if (settled.status === "fulfilled") return settled.value;
3001
+ console.error(`${label} rejected:`, settled.reason);
3002
+ return EMPTY_DIAGNOSTICS;
3003
+ };
2482
3004
  const diagnose = async (directory, options = {}) => {
3005
+ const startTime = globalThis.performance.now();
2483
3006
  const resolvedDirectory = path.resolve(directory);
2484
3007
  const userConfig = loadConfig(resolvedDirectory);
2485
3008
  const includePaths = options.includePaths ?? [];
2486
3009
  const isDiffMode = includePaths.length > 0;
3010
+ const projectInfo = discoverProject(resolvedDirectory);
3011
+ if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(resolvedDirectory));
2487
3012
  const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
2488
- return diagnoseCore({
3013
+ const readFileLinesSync = createNodeReadFileLinesSync(resolvedDirectory);
3014
+ const effectiveLint = options.lint ?? userConfig?.lint ?? true;
3015
+ const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
3016
+ const effectiveRespectInlineDisables = options.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true;
3017
+ const lintPromise = effectiveLint ? runOxlint({
2489
3018
  rootDirectory: resolvedDirectory,
2490
- readFileLinesSync: createNodeReadFileLinesSync(resolvedDirectory),
2491
- loadUserConfig: () => userConfig,
2492
- discoverProjectInfo: () => discoverProject(resolvedDirectory),
2493
- calculateDiagnosticsScore: calculateScore,
2494
- getExtraDiagnostics: () => isDiffMode ? [] : checkReducedMotion(resolvedDirectory),
2495
- createRunners: ({ resolvedDirectory: projectRoot, projectInfo, userConfig: config }) => ({
2496
- runLint: () => runOxlint({
2497
- rootDirectory: projectRoot,
2498
- hasTypeScript: projectInfo.hasTypeScript,
2499
- framework: projectInfo.framework,
2500
- hasReactCompiler: projectInfo.hasReactCompiler,
2501
- hasTanStackQuery: projectInfo.hasTanStackQuery,
2502
- includePaths: lintIncludePaths,
2503
- customRulesOnly: config?.customRulesOnly ?? false,
2504
- respectInlineDisables: options.respectInlineDisables ?? config?.respectInlineDisables ?? true
2505
- }),
2506
- runDeadCode: () => runKnip(projectRoot)
2507
- })
2508
- }, {
2509
- ...options,
2510
- lintIncludePaths
2511
- });
3019
+ hasTypeScript: projectInfo.hasTypeScript,
3020
+ framework: projectInfo.framework,
3021
+ hasReactCompiler: projectInfo.hasReactCompiler,
3022
+ hasTanStackQuery: projectInfo.hasTanStackQuery,
3023
+ reactMajorVersion: parseReactMajor(projectInfo.reactVersion),
3024
+ includePaths: lintIncludePaths,
3025
+ customRulesOnly: userConfig?.customRulesOnly ?? false,
3026
+ respectInlineDisables: effectiveRespectInlineDisables,
3027
+ adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true
3028
+ }).catch((error) => {
3029
+ console.error("Lint failed:", error);
3030
+ return EMPTY_DIAGNOSTICS;
3031
+ }) : Promise.resolve(EMPTY_DIAGNOSTICS);
3032
+ const deadCodePromise = effectiveDeadCode && !isDiffMode ? runKnip(resolvedDirectory).catch((error) => {
3033
+ console.error("Dead code analysis failed:", error);
3034
+ return EMPTY_DIAGNOSTICS;
3035
+ }) : Promise.resolve(EMPTY_DIAGNOSTICS);
3036
+ const [lintSettled, deadCodeSettled] = await Promise.allSettled([lintPromise, deadCodePromise]);
3037
+ const lintDiagnostics = settledOrEmpty(lintSettled, "Lint");
3038
+ const deadCodeDiagnostics = settledOrEmpty(deadCodeSettled, "Dead code");
3039
+ const environmentDiagnostics = isDiffMode ? [] : checkReducedMotion(resolvedDirectory);
3040
+ const diagnostics = mergeAndFilterDiagnostics([
3041
+ ...lintDiagnostics,
3042
+ ...deadCodeDiagnostics,
3043
+ ...environmentDiagnostics
3044
+ ], resolvedDirectory, userConfig, readFileLinesSync, { respectInlineDisables: effectiveRespectInlineDisables });
3045
+ const elapsedMilliseconds = globalThis.performance.now() - startTime;
3046
+ return {
3047
+ diagnostics,
3048
+ score: await calculateScore(diagnostics),
3049
+ project: projectInfo,
3050
+ elapsedMilliseconds
3051
+ };
2512
3052
  };
2513
3053
  //#endregion
2514
3054
  export { buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, summarizeDiagnostics, toJsonReport };