react-doctor 0.0.47 → 0.1.1

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
@@ -492,7 +487,9 @@ const highlighter = {
492
487
  warn: pc.yellow,
493
488
  info: pc.cyan,
494
489
  success: pc.green,
495
- dim: pc.dim
490
+ dim: pc.dim,
491
+ gray: pc.gray,
492
+ bold: pc.bold
496
493
  };
497
494
  const logger = {
498
495
  error(...args) {
@@ -915,7 +912,7 @@ const hasCompilerInConfigFile = (filePath) => {
915
912
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
916
913
  };
917
914
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
918
- const isProjectBoundary$1 = (directory) => {
915
+ const isProjectBoundary$2 = (directory) => {
919
916
  if (fs.existsSync(path.join(directory, ".git"))) return true;
920
917
  return isMonorepoRoot(directory);
921
918
  };
@@ -925,14 +922,14 @@ const detectReactCompiler = (directory, packageJson) => {
925
922
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
926
923
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
927
924
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
928
- if (isProjectBoundary$1(directory)) return false;
925
+ if (isProjectBoundary$2(directory)) return false;
929
926
  let ancestorDirectory = path.dirname(directory);
930
927
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
931
928
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
932
929
  if (isFile(ancestorPackagePath)) {
933
930
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
934
931
  }
935
- if (isProjectBoundary$1(ancestorDirectory)) return false;
932
+ if (isProjectBoundary$2(ancestorDirectory)) return false;
936
933
  ancestorDirectory = path.dirname(ancestorDirectory);
937
934
  }
938
935
  return false;
@@ -986,6 +983,9 @@ const discoverProject = (directory) => {
986
983
  return projectInfo;
987
984
  };
988
985
  //#endregion
986
+ //#region src/utils/jsx-include-paths.ts
987
+ const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
988
+ //#endregion
989
989
  //#region src/utils/validate-config-types.ts
990
990
  const BOOLEAN_FIELD_NAMES = [
991
991
  "lint",
@@ -993,22 +993,23 @@ const BOOLEAN_FIELD_NAMES = [
993
993
  "verbose",
994
994
  "customRulesOnly",
995
995
  "share",
996
- "respectInlineDisables"
996
+ "respectInlineDisables",
997
+ "adoptExistingLintConfig"
997
998
  ];
998
- const warnConfigField = (message) => {
999
+ const warnConfigField$1 = (message) => {
999
1000
  process.stderr.write(`[react-doctor] ${message}\n`);
1000
1001
  };
1001
1002
  const coerceMaybeBooleanString = (fieldName, value) => {
1002
1003
  if (typeof value === "boolean" || value === void 0) return value;
1003
1004
  if (value === "true") {
1004
- warnConfigField(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
1005
+ warnConfigField$1(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
1005
1006
  return true;
1006
1007
  }
1007
1008
  if (value === "false") {
1008
- warnConfigField(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
1009
+ warnConfigField$1(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
1009
1010
  return false;
1010
1011
  }
1011
- warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
1012
+ warnConfigField$1(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
1012
1013
  };
1013
1014
  const validateConfigTypes = (config) => {
1014
1015
  const validated = { ...config };
@@ -1048,38 +1049,383 @@ const loadConfigFromDirectory = (directory) => {
1048
1049
  }
1049
1050
  return null;
1050
1051
  };
1051
- const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1052
- const cachedConfigs = /* @__PURE__ */ new Map();
1053
- const clearConfigCache = () => {
1054
- cachedConfigs.clear();
1052
+ const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1053
+ const cachedConfigs = /* @__PURE__ */ new Map();
1054
+ const clearConfigCache = () => {
1055
+ cachedConfigs.clear();
1056
+ };
1057
+ const loadConfig = (rootDirectory) => {
1058
+ const cached = cachedConfigs.get(rootDirectory);
1059
+ if (cached !== void 0) return cached;
1060
+ const localConfig = loadConfigFromDirectory(rootDirectory);
1061
+ if (localConfig) {
1062
+ cachedConfigs.set(rootDirectory, localConfig);
1063
+ return localConfig;
1064
+ }
1065
+ if (isProjectBoundary$1(rootDirectory)) {
1066
+ cachedConfigs.set(rootDirectory, null);
1067
+ return null;
1068
+ }
1069
+ let ancestorDirectory = path.dirname(rootDirectory);
1070
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1071
+ const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
1072
+ if (ancestorConfig) {
1073
+ cachedConfigs.set(rootDirectory, ancestorConfig);
1074
+ return ancestorConfig;
1075
+ }
1076
+ if (isProjectBoundary$1(ancestorDirectory)) {
1077
+ cachedConfigs.set(rootDirectory, null);
1078
+ return null;
1079
+ }
1080
+ ancestorDirectory = path.dirname(ancestorDirectory);
1081
+ }
1082
+ cachedConfigs.set(rootDirectory, null);
1083
+ return null;
1084
+ };
1085
+ //#endregion
1086
+ //#region src/utils/match-glob-pattern.ts
1087
+ const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
1088
+ const compileGlobPattern = (pattern) => {
1089
+ const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
1090
+ let regexSource = "^";
1091
+ let characterIndex = 0;
1092
+ while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
1093
+ regexSource += "(?:.+/)?";
1094
+ characterIndex += 3;
1095
+ } else {
1096
+ regexSource += ".*";
1097
+ characterIndex += 2;
1098
+ }
1099
+ else if (normalizedPattern[characterIndex] === "*") {
1100
+ regexSource += "[^/]*";
1101
+ characterIndex++;
1102
+ } else if (normalizedPattern[characterIndex] === "?") {
1103
+ regexSource += "[^/]";
1104
+ characterIndex++;
1105
+ } else {
1106
+ regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
1107
+ characterIndex++;
1108
+ }
1109
+ regexSource += "$";
1110
+ return new RegExp(regexSource);
1111
+ };
1112
+ //#endregion
1113
+ //#region src/utils/to-relative-path.ts
1114
+ const toRelativePath = (filePath, rootDirectory) => {
1115
+ const normalizedFilePath = filePath.replace(/\\/g, "/");
1116
+ const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
1117
+ if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
1118
+ return normalizedFilePath.replace(/^\.\//, "");
1119
+ };
1120
+ //#endregion
1121
+ //#region src/utils/apply-ignore-overrides.ts
1122
+ const warnConfigField = (message) => {
1123
+ process.stderr.write(`[react-doctor] ${message}\n`);
1124
+ };
1125
+ const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
1126
+ const collectStringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
1127
+ const validateOverrideEntry = (entry, index) => {
1128
+ if (!isPlainObject(entry)) {
1129
+ warnConfigField(`ignore.overrides[${index}] must be an object with { files, rules }; ignoring this entry.`);
1130
+ return null;
1131
+ }
1132
+ if (!isStringArray(entry.files)) {
1133
+ warnConfigField(`ignore.overrides[${index}].files must be an array of strings; ignoring this entry.`);
1134
+ return null;
1135
+ }
1136
+ if (entry.rules !== void 0 && !isStringArray(entry.rules)) {
1137
+ 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).`);
1138
+ return { files: entry.files };
1139
+ }
1140
+ return entry.rules === void 0 ? { files: entry.files } : {
1141
+ files: entry.files,
1142
+ rules: entry.rules
1143
+ };
1144
+ };
1145
+ const compileIgnoreOverrides = (userConfig) => {
1146
+ const overrides = userConfig?.ignore?.overrides;
1147
+ if (overrides === void 0) return [];
1148
+ if (!Array.isArray(overrides)) {
1149
+ warnConfigField(`ignore.overrides must be an array of { files, rules } entries; ignoring.`);
1150
+ return [];
1151
+ }
1152
+ return overrides.flatMap((entry, index) => {
1153
+ const validated = validateOverrideEntry(entry, index);
1154
+ if (!validated) return [];
1155
+ const filePatterns = collectStringList(validated.files).map(compileGlobPattern);
1156
+ if (filePatterns.length === 0) return [];
1157
+ return [{
1158
+ filePatterns,
1159
+ ruleIds: new Set(collectStringList(validated.rules))
1160
+ }];
1161
+ });
1162
+ };
1163
+ const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) => {
1164
+ if (overrides.length === 0) return false;
1165
+ const relativeFilePath = toRelativePath(diagnostic.filePath, rootDirectory);
1166
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
1167
+ return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
1168
+ };
1169
+ //#endregion
1170
+ //#region src/utils/find-jsx-opener-span.ts
1171
+ const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
1172
+ const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
1173
+ const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
1174
+ let stringDelimiter = null;
1175
+ for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
1176
+ const character = line[charIndex];
1177
+ if (stringDelimiter !== null) {
1178
+ if (character === "\\") {
1179
+ charIndex++;
1180
+ continue;
1181
+ }
1182
+ if (character === stringDelimiter) stringDelimiter = null;
1183
+ continue;
1184
+ }
1185
+ if (character === "\"" || character === "'" || character === "`") {
1186
+ stringDelimiter = character;
1187
+ continue;
1188
+ }
1189
+ if (character === "/" && line[charIndex + 1] === "/") return true;
1190
+ }
1191
+ return false;
1192
+ };
1193
+ const findOpenerTagOnLine = (line) => {
1194
+ for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
1195
+ if (match.index === void 0) continue;
1196
+ if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
1197
+ }
1198
+ return null;
1199
+ };
1200
+ const findJsxOpenerSpan = (lines, openerLineIndex) => {
1201
+ const openerLine = lines[openerLineIndex];
1202
+ if (openerLine === void 0) return null;
1203
+ const opener = findOpenerTagOnLine(openerLine);
1204
+ if (!opener) return null;
1205
+ const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
1206
+ let braceDepth = 0;
1207
+ let innerAngleDepth = 0;
1208
+ let stringDelimiter = null;
1209
+ for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
1210
+ const currentLine = lines[lineIndex];
1211
+ const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
1212
+ for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
1213
+ const character = currentLine[charIndex];
1214
+ if (stringDelimiter !== null) {
1215
+ if (character === "\\") {
1216
+ charIndex++;
1217
+ continue;
1218
+ }
1219
+ if (character === stringDelimiter) stringDelimiter = null;
1220
+ continue;
1221
+ }
1222
+ if (character === "\"" || character === "'" || character === "`") {
1223
+ stringDelimiter = character;
1224
+ continue;
1225
+ }
1226
+ if (character === "{") {
1227
+ braceDepth++;
1228
+ continue;
1229
+ }
1230
+ if (character === "}") {
1231
+ braceDepth--;
1232
+ continue;
1233
+ }
1234
+ if (braceDepth !== 0) continue;
1235
+ if (character === "<") {
1236
+ const followCharacter = currentLine[charIndex + 1];
1237
+ if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
1238
+ continue;
1239
+ }
1240
+ if (character !== ">") continue;
1241
+ const previousCharacter = currentLine[charIndex - 1];
1242
+ const nextCharacter = currentLine[charIndex + 1];
1243
+ if (previousCharacter === "=" || nextCharacter === "=") continue;
1244
+ if (innerAngleDepth > 0) {
1245
+ innerAngleDepth--;
1246
+ continue;
1247
+ }
1248
+ return lineIndex;
1249
+ }
1250
+ }
1251
+ return null;
1252
+ };
1253
+ //#endregion
1254
+ //#region src/utils/find-enclosing-jsx-opener.ts
1255
+ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
1256
+ for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
1257
+ const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
1258
+ if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
1259
+ }
1260
+ return null;
1261
+ };
1262
+ //#endregion
1263
+ //#region src/utils/find-stacked-disable-comments.ts
1264
+ const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
1265
+ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
1266
+ const collected = [];
1267
+ let isStillInChain = true;
1268
+ for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
1269
+ const candidateLine = lines[candidateIndex];
1270
+ if (candidateLine === void 0) break;
1271
+ const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
1272
+ if (match) {
1273
+ collected.push({
1274
+ commentLineIndex: candidateIndex,
1275
+ ruleList: match[1],
1276
+ isInChain: isStillInChain
1277
+ });
1278
+ continue;
1279
+ }
1280
+ isStillInChain = false;
1281
+ }
1282
+ return collected;
1283
+ };
1284
+ //#endregion
1285
+ //#region src/utils/is-rule-listed-in-comment.ts
1286
+ const isRuleListedInComment = (ruleList, ruleId) => {
1287
+ if (!ruleList?.trim()) return true;
1288
+ return ruleList.split(/[,\s]+/).some((token) => token.trim() === ruleId);
1289
+ };
1290
+ //#endregion
1291
+ //#region src/utils/evaluate-suppression.ts
1292
+ const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
1293
+ const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
1294
+ const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
1295
+ const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
1296
+ const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
1297
+ const buildAdjacentMismatchHint = (comment, ruleId) => {
1298
+ const ruleListText = comment.ruleList?.trim() ?? "";
1299
+ 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}`;
1300
+ };
1301
+ const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
1302
+ const commentLineNumber = comment.commentLineIndex + 1;
1303
+ const diagnosticLineNumber = diagnosticLineIndex + 1;
1304
+ 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.`;
1305
+ };
1306
+ const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
1307
+ for (const comments of commentsByAnchor) {
1308
+ const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
1309
+ if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
1310
+ const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
1311
+ if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
1312
+ }
1313
+ return null;
1314
+ };
1315
+ const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
1316
+ const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
1317
+ if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
1318
+ isSuppressed: true,
1319
+ nearMissHint: null
1320
+ };
1321
+ const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
1322
+ if (hasChainSuppressor(directComments, ruleId)) return {
1323
+ isSuppressed: true,
1324
+ nearMissHint: null
1325
+ };
1326
+ const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
1327
+ const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
1328
+ if (hasChainSuppressor(openerComments, ruleId)) return {
1329
+ isSuppressed: true,
1330
+ nearMissHint: null
1331
+ };
1332
+ return {
1333
+ isSuppressed: false,
1334
+ nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
1335
+ };
1336
+ };
1337
+ //#endregion
1338
+ //#region src/utils/is-ignored-file.ts
1339
+ const compileIgnoredFilePatterns = (userConfig) => {
1340
+ const files = userConfig?.ignore?.files;
1341
+ if (!Array.isArray(files)) return [];
1342
+ return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
1343
+ };
1344
+ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
1345
+ if (patterns.length === 0) return false;
1346
+ const relativePath = toRelativePath(filePath, rootDirectory);
1347
+ return patterns.some((pattern) => pattern.test(relativePath));
1348
+ };
1349
+ //#endregion
1350
+ //#region src/utils/filter-diagnostics.ts
1351
+ const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
1352
+ const resolveCandidateReadPath = (rootDirectory, filePath) => {
1353
+ const normalizedFile = filePath.replace(/\\/g, "/");
1354
+ if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
1355
+ return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
1356
+ };
1357
+ const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
1358
+ const cache = /* @__PURE__ */ new Map();
1359
+ return (filePath) => {
1360
+ const cached = cache.get(filePath);
1361
+ if (cached !== void 0) return cached;
1362
+ const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
1363
+ cache.set(filePath, lines);
1364
+ return lines;
1365
+ };
1055
1366
  };
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;
1367
+ const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
1368
+ for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
1369
+ const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
1370
+ if (!match) continue;
1371
+ const fullTagName = match[1];
1372
+ const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
1373
+ return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
1067
1374
  }
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;
1375
+ return false;
1376
+ };
1377
+ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
1378
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
1379
+ const ignoredFilePatterns = compileIgnoredFilePatterns(config);
1380
+ const compiledOverrides = compileIgnoreOverrides(config);
1381
+ const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
1382
+ const hasTextComponents = textComponentNames.size > 0;
1383
+ const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
1384
+ return diagnostics.filter((diagnostic) => {
1385
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
1386
+ if (ignoredRules.has(ruleIdentifier)) return false;
1387
+ if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
1388
+ if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
1389
+ if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
1390
+ const lines = getFileLines(diagnostic.filePath);
1391
+ if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
1078
1392
  }
1079
- ancestorDirectory = path.dirname(ancestorDirectory);
1080
- }
1081
- cachedConfigs.set(rootDirectory, null);
1082
- return null;
1393
+ return true;
1394
+ });
1395
+ };
1396
+ const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
1397
+ const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
1398
+ return diagnostics.flatMap((diagnostic) => {
1399
+ if (diagnostic.line <= 0) return [diagnostic];
1400
+ const lines = getFileLines(diagnostic.filePath);
1401
+ if (!lines) return [diagnostic];
1402
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
1403
+ const evaluation = evaluateSuppression(lines, diagnostic.line - 1, ruleIdentifier);
1404
+ if (evaluation.isSuppressed) return [];
1405
+ return evaluation.nearMissHint ? [{
1406
+ ...diagnostic,
1407
+ suppressionHint: evaluation.nearMissHint
1408
+ }] : [diagnostic];
1409
+ });
1410
+ };
1411
+ //#endregion
1412
+ //#region src/utils/merge-and-filter-diagnostics.ts
1413
+ const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync, options = {}) => {
1414
+ const filtered = userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics;
1415
+ if (options.respectInlineDisables === false) return filtered;
1416
+ return filterInlineSuppressions(filtered, directory, readFileLinesSync);
1417
+ };
1418
+ //#endregion
1419
+ //#region src/utils/parse-react-major.ts
1420
+ const parseReactMajor = (reactVersion) => {
1421
+ if (typeof reactVersion !== "string") return null;
1422
+ const trimmed = reactVersion.trim();
1423
+ if (trimmed.length === 0) return null;
1424
+ const match = trimmed.match(/(\d+)/);
1425
+ if (!match) return null;
1426
+ const major = Number.parseInt(match[1], 10);
1427
+ if (!Number.isFinite(major) || major <= 0) return null;
1428
+ return major;
1083
1429
  };
1084
1430
  //#endregion
1085
1431
  //#region src/utils/read-file-lines-node.ts
@@ -1138,116 +1484,6 @@ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
1138
1484
  });
1139
1485
  };
1140
1486
  //#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
1487
  //#region src/utils/collect-unused-file-paths.ts
1252
1488
  const collectUnusedFilePaths = (filesIssues) => {
1253
1489
  if (filesIssues instanceof Set) return [...filesIssues];
@@ -1277,36 +1513,57 @@ const extractFailedPluginName = (error) => {
1277
1513
  //#region src/utils/has-knip-config.ts
1278
1514
  const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
1279
1515
  //#endregion
1516
+ //#region src/utils/sanitize-knip-config-patterns.ts
1517
+ const isMeaningfulPattern = (value) => typeof value !== "string" || value.trim().length > 0;
1518
+ const sanitizeStringArray = (values) => values.filter((entry) => typeof entry === "string" ? entry.trim().length > 0 : true);
1519
+ const sanitizeKnipConfigPatterns = (parsedConfig) => {
1520
+ for (const [key, value] of Object.entries(parsedConfig)) {
1521
+ if (typeof value === "string") {
1522
+ if (!isMeaningfulPattern(value)) delete parsedConfig[key];
1523
+ continue;
1524
+ }
1525
+ if (Array.isArray(value)) {
1526
+ if (value.length === 0) continue;
1527
+ const sanitized = sanitizeStringArray(value);
1528
+ if (sanitized.length === value.length) continue;
1529
+ if (sanitized.length === 0) delete parsedConfig[key];
1530
+ else parsedConfig[key] = sanitized;
1531
+ continue;
1532
+ }
1533
+ if (isPlainObject(value)) sanitizeKnipConfigPatterns(value);
1534
+ }
1535
+ };
1536
+ //#endregion
1280
1537
  //#region src/utils/run-knip.ts
1281
- const KNIP_ISSUE_TYPE_DESCRIPTORS = {
1282
- files: {
1538
+ const KNIP_ISSUE_TYPE_DESCRIPTORS = new Map([
1539
+ ["files", {
1283
1540
  category: "Dead Code",
1284
1541
  message: "Unused file",
1285
1542
  severity: "warning"
1286
- },
1287
- exports: {
1543
+ }],
1544
+ ["exports", {
1288
1545
  category: "Dead Code",
1289
1546
  message: "Unused export",
1290
1547
  severity: "warning"
1291
- },
1292
- types: {
1548
+ }],
1549
+ ["types", {
1293
1550
  category: "Dead Code",
1294
1551
  message: "Unused type",
1295
1552
  severity: "warning"
1296
- },
1297
- duplicates: {
1553
+ }],
1554
+ ["duplicates", {
1298
1555
  category: "Dead Code",
1299
1556
  message: "Duplicate export",
1300
1557
  severity: "warning"
1301
- }
1302
- };
1558
+ }]
1559
+ ]);
1303
1560
  const FALLBACK_KNIP_DESCRIPTOR = {
1304
1561
  category: "Dead Code",
1305
1562
  message: "Issue",
1306
1563
  severity: "warning"
1307
1564
  };
1308
1565
  const collectIssueRecords = (records, issueType, rootDirectory) => {
1309
- const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS[issueType] ?? FALLBACK_KNIP_DESCRIPTOR;
1566
+ const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get(issueType) ?? FALLBACK_KNIP_DESCRIPTOR;
1310
1567
  const diagnostics = [];
1311
1568
  for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
1312
1569
  filePath: path.relative(rootDirectory, issue.filePath),
@@ -1344,7 +1601,7 @@ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
1344
1601
  const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
1345
1602
  const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
1346
1603
  const failedPlugin = extractFailedPluginName(error);
1347
- if (!failedPlugin || !(failedPlugin in parsedConfig) || disabledPlugins.has(failedPlugin)) return false;
1604
+ if (!failedPlugin || !Object.hasOwn(parsedConfig, failedPlugin) || disabledPlugins.has(failedPlugin)) return false;
1348
1605
  disabledPlugins.add(failedPlugin);
1349
1606
  parsedConfig[failedPlugin] = false;
1350
1607
  return true;
@@ -1358,6 +1615,7 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
1358
1615
  ...tsConfigFile ? { tsConfigFile } : {}
1359
1616
  }));
1360
1617
  const parsedConfig = options.parsedConfig;
1618
+ sanitizeKnipConfigPatterns(parsedConfig);
1361
1619
  const disabledPlugins = /* @__PURE__ */ new Set();
1362
1620
  let lastKnipError;
1363
1621
  for (let attempt = 0; attempt < 6; attempt++) try {
@@ -1389,7 +1647,7 @@ const runKnip = async (rootDirectory) => {
1389
1647
  if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
1390
1648
  const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
1391
1649
  const diagnostics = [];
1392
- const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
1650
+ const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get("files") ?? FALLBACK_KNIP_DESCRIPTOR;
1393
1651
  for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
1394
1652
  filePath: path.relative(rootDirectory, unusedFilePath),
1395
1653
  plugin: "knip",
@@ -1432,6 +1690,103 @@ const batchIncludePaths = (baseArgs, includePaths) => {
1432
1690
  return batches;
1433
1691
  };
1434
1692
  //#endregion
1693
+ //#region src/utils/can-oxlint-extend-config.ts
1694
+ const EXTENDS_LOCAL_PATH_PREFIXES = [
1695
+ "./",
1696
+ "../",
1697
+ "/"
1698
+ ];
1699
+ const isLocalPathExtend = (entry) => {
1700
+ for (const prefix of EXTENDS_LOCAL_PATH_PREFIXES) if (entry.startsWith(prefix)) return true;
1701
+ return false;
1702
+ };
1703
+ const stripJsoncComments = (raw) => {
1704
+ let result = "";
1705
+ let cursor = 0;
1706
+ let inString = false;
1707
+ let stringQuote = "";
1708
+ while (cursor < raw.length) {
1709
+ const character = raw[cursor];
1710
+ const nextCharacter = raw[cursor + 1];
1711
+ if (inString) {
1712
+ result += character;
1713
+ if (character === "\\" && cursor + 1 < raw.length) {
1714
+ result += nextCharacter;
1715
+ cursor += 2;
1716
+ continue;
1717
+ }
1718
+ if (character === stringQuote) inString = false;
1719
+ cursor += 1;
1720
+ continue;
1721
+ }
1722
+ if (character === "\"" || character === "'") {
1723
+ inString = true;
1724
+ stringQuote = character;
1725
+ result += character;
1726
+ cursor += 1;
1727
+ continue;
1728
+ }
1729
+ if (character === "/" && nextCharacter === "/") {
1730
+ const lineEndIndex = raw.indexOf("\n", cursor);
1731
+ cursor = lineEndIndex === -1 ? raw.length : lineEndIndex;
1732
+ continue;
1733
+ }
1734
+ if (character === "/" && nextCharacter === "*") {
1735
+ const blockEndIndex = raw.indexOf("*/", cursor + 2);
1736
+ cursor = blockEndIndex === -1 ? raw.length : blockEndIndex + 2;
1737
+ continue;
1738
+ }
1739
+ result += character;
1740
+ cursor += 1;
1741
+ }
1742
+ return result;
1743
+ };
1744
+ const parseJsonOrJsonc = (raw) => {
1745
+ try {
1746
+ return JSON.parse(raw);
1747
+ } catch {
1748
+ return JSON.parse(stripJsoncComments(raw));
1749
+ }
1750
+ };
1751
+ const canOxlintExtendConfig = (configPath) => {
1752
+ if (!configPath.endsWith(".eslintrc.json")) return true;
1753
+ let parsed;
1754
+ try {
1755
+ parsed = parseJsonOrJsonc(fs.readFileSync(configPath, "utf-8"));
1756
+ } catch {
1757
+ return true;
1758
+ }
1759
+ if (!isPlainObject(parsed)) return true;
1760
+ const extendsValue = parsed.extends;
1761
+ if (extendsValue === void 0 || extendsValue === null) return true;
1762
+ const extendsEntries = Array.isArray(extendsValue) ? extendsValue : [extendsValue];
1763
+ if (extendsEntries.length === 0) return true;
1764
+ return extendsEntries.some((entry) => typeof entry === "string" && isLocalPathExtend(entry));
1765
+ };
1766
+ //#endregion
1767
+ //#region src/utils/detect-user-lint-config.ts
1768
+ const findFirstLintConfigInDirectory = (directory) => {
1769
+ for (const filename of ADOPTABLE_LINT_CONFIG_FILENAMES) {
1770
+ const candidatePath = path.join(directory, filename);
1771
+ if (isFile(candidatePath)) return candidatePath;
1772
+ }
1773
+ return null;
1774
+ };
1775
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1776
+ const detectUserLintConfigPaths = (rootDirectory) => {
1777
+ const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
1778
+ if (directLintConfig) return [directLintConfig];
1779
+ if (isProjectBoundary(rootDirectory)) return [];
1780
+ let ancestorDirectory = path.dirname(rootDirectory);
1781
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1782
+ const ancestorLintConfig = findFirstLintConfigInDirectory(ancestorDirectory);
1783
+ if (ancestorLintConfig) return [ancestorLintConfig];
1784
+ if (isProjectBoundary(ancestorDirectory)) return [];
1785
+ ancestorDirectory = path.dirname(ancestorDirectory);
1786
+ }
1787
+ return [];
1788
+ };
1789
+ //#endregion
1435
1790
  //#region src/oxlint-config.ts
1436
1791
  const esmRequire$1 = createRequire(import.meta.url);
1437
1792
  const NEXTJS_RULES = {
@@ -1593,18 +1948,27 @@ const BUILTIN_A11Y_RULES = {
1593
1948
  const GLOBAL_REACT_DOCTOR_RULES = {
1594
1949
  "react-doctor/no-derived-state-effect": "warn",
1595
1950
  "react-doctor/no-fetch-in-effect": "warn",
1951
+ "react-doctor/no-mirror-prop-effect": "warn",
1952
+ "react-doctor/no-mutable-in-deps": "error",
1596
1953
  "react-doctor/no-cascading-set-state": "warn",
1954
+ "react-doctor/no-effect-chain": "warn",
1597
1955
  "react-doctor/no-effect-event-handler": "warn",
1598
1956
  "react-doctor/no-effect-event-in-deps": "error",
1957
+ "react-doctor/no-event-trigger-state": "warn",
1599
1958
  "react-doctor/no-prop-callback-in-effect": "warn",
1600
1959
  "react-doctor/no-derived-useState": "warn",
1960
+ "react-doctor/no-direct-state-mutation": "warn",
1961
+ "react-doctor/no-set-state-in-render": "warn",
1962
+ "react-doctor/prefer-use-effect-event": "warn",
1601
1963
  "react-doctor/prefer-useReducer": "warn",
1964
+ "react-doctor/prefer-use-sync-external-store": "warn",
1602
1965
  "react-doctor/rerender-lazy-state-init": "warn",
1603
1966
  "react-doctor/rerender-functional-setstate": "warn",
1604
1967
  "react-doctor/rerender-dependencies": "error",
1605
1968
  "react-doctor/rerender-state-only-in-handlers": "warn",
1606
1969
  "react-doctor/rerender-defer-reads-hook": "warn",
1607
1970
  "react-doctor/advanced-event-handler-refs": "warn",
1971
+ "react-doctor/effect-needs-cleanup": "error",
1608
1972
  "react-doctor/no-giant-component": "warn",
1609
1973
  "react-doctor/no-render-in-render": "warn",
1610
1974
  "react-doctor/no-many-boolean-props": "warn",
@@ -1612,6 +1976,10 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1612
1976
  "react-doctor/no-render-prop-children": "warn",
1613
1977
  "react-doctor/no-nested-component-definition": "error",
1614
1978
  "react-doctor/react-compiler-destructure-method": "warn",
1979
+ "react-doctor/no-legacy-class-lifecycles": "error",
1980
+ "react-doctor/no-legacy-context-api": "error",
1981
+ "react-doctor/no-default-props": "warn",
1982
+ "react-doctor/no-react-dom-deprecated-apis": "warn",
1615
1983
  "react-doctor/no-usememo-simple-expression": "warn",
1616
1984
  "react-doctor/no-layout-property-animation": "error",
1617
1985
  "react-doctor/rerender-memo-with-default-value": "warn",
@@ -1660,6 +2028,7 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1660
2028
  "react-doctor/rendering-conditional-render": "warn",
1661
2029
  "react-doctor/rendering-svg-precision": "warn",
1662
2030
  "react-doctor/no-prevent-default": "warn",
2031
+ "react-doctor/no-uncontrolled-input": "warn",
1663
2032
  "react-doctor/no-document-start-view-transition": "warn",
1664
2033
  "react-doctor/no-flush-sync": "warn",
1665
2034
  "react-doctor/server-auth-actions": "error",
@@ -1687,6 +2056,14 @@ const GLOBAL_REACT_DOCTOR_RULES = {
1687
2056
  "react-doctor/no-disabled-zoom": "error",
1688
2057
  "react-doctor/no-outline-none": "warn",
1689
2058
  "react-doctor/no-long-transition-duration": "warn",
2059
+ "react-doctor/design-no-bold-heading": "warn",
2060
+ "react-doctor/design-no-redundant-padding-axes": "warn",
2061
+ "react-doctor/design-no-redundant-size-axes": "warn",
2062
+ "react-doctor/design-no-space-on-flex-children": "warn",
2063
+ "react-doctor/design-no-em-dash-in-jsx-text": "warn",
2064
+ "react-doctor/design-no-three-period-ellipsis": "warn",
2065
+ "react-doctor/design-no-default-tailwind-palette": "warn",
2066
+ "react-doctor/design-no-vague-button-label": "warn",
1690
2067
  "react-doctor/async-parallel": "warn"
1691
2068
  };
1692
2069
  const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
@@ -1696,10 +2073,38 @@ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
1696
2073
  ...Object.keys(TANSTACK_START_RULES),
1697
2074
  ...Object.keys(TANSTACK_QUERY_RULES)
1698
2075
  ]);
1699
- const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false }) => {
2076
+ const VERSION_GATED_RULE_IDS = new Map([
2077
+ ["react-doctor/no-react19-deprecated-apis", {
2078
+ minMajor: 19,
2079
+ mode: "deprecation-warning"
2080
+ }],
2081
+ ["react-doctor/no-default-props", {
2082
+ minMajor: 19,
2083
+ mode: "deprecation-warning"
2084
+ }],
2085
+ ["react-doctor/no-react-dom-deprecated-apis", {
2086
+ minMajor: 18,
2087
+ mode: "deprecation-warning"
2088
+ }],
2089
+ ["react-doctor/prefer-use-effect-event", {
2090
+ minMajor: 19,
2091
+ mode: "prefer-newer-api"
2092
+ }]
2093
+ ]);
2094
+ const filterRulesByReactMajor = (rules, reactMajorVersion) => {
2095
+ return Object.fromEntries(Object.entries(rules).filter(([ruleKey]) => {
2096
+ const gate = VERSION_GATED_RULE_IDS.get(ruleKey);
2097
+ if (gate === void 0) return true;
2098
+ if (gate.mode === "deprecation-warning") return true;
2099
+ if (reactMajorVersion === null) return true;
2100
+ return reactMajorVersion >= gate.minMajor;
2101
+ }));
2102
+ };
2103
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
1700
2104
  const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
1701
2105
  const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
1702
2106
  return {
2107
+ ...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
1703
2108
  categories: {
1704
2109
  correctness: "off",
1705
2110
  suspicious: "off",
@@ -1715,7 +2120,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
1715
2120
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
1716
2121
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
1717
2122
  ...reactCompilerRules,
1718
- ...GLOBAL_REACT_DOCTOR_RULES,
2123
+ ...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
1719
2124
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1720
2125
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
1721
2126
  ...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
@@ -1827,23 +2232,43 @@ const PLUGIN_CATEGORY_MAP = {
1827
2232
  "react-hooks-js": "React Compiler",
1828
2233
  "react-doctor": "Other",
1829
2234
  "jsx-a11y": "Accessibility",
1830
- knip: "Dead Code"
2235
+ knip: "Dead Code",
2236
+ eslint: "Correctness",
2237
+ oxc: "Correctness",
2238
+ typescript: "Correctness",
2239
+ unicorn: "Correctness",
2240
+ import: "Bundle Size",
2241
+ promise: "Correctness",
2242
+ n: "Correctness",
2243
+ node: "Correctness",
2244
+ vitest: "Correctness",
2245
+ jest: "Correctness",
2246
+ nextjs: "Next.js"
1831
2247
  };
1832
2248
  const RULE_CATEGORY_MAP = {
1833
2249
  "react-doctor/no-derived-state-effect": "State & Effects",
1834
2250
  "react-doctor/no-fetch-in-effect": "State & Effects",
2251
+ "react-doctor/no-mirror-prop-effect": "State & Effects",
2252
+ "react-doctor/no-mutable-in-deps": "State & Effects",
1835
2253
  "react-doctor/no-cascading-set-state": "State & Effects",
2254
+ "react-doctor/no-effect-chain": "State & Effects",
1836
2255
  "react-doctor/no-effect-event-handler": "State & Effects",
1837
2256
  "react-doctor/no-effect-event-in-deps": "State & Effects",
2257
+ "react-doctor/no-event-trigger-state": "State & Effects",
1838
2258
  "react-doctor/no-prop-callback-in-effect": "State & Effects",
1839
2259
  "react-doctor/no-derived-useState": "State & Effects",
2260
+ "react-doctor/no-direct-state-mutation": "State & Effects",
2261
+ "react-doctor/no-set-state-in-render": "State & Effects",
2262
+ "react-doctor/prefer-use-effect-event": "State & Effects",
1840
2263
  "react-doctor/prefer-useReducer": "State & Effects",
2264
+ "react-doctor/prefer-use-sync-external-store": "State & Effects",
1841
2265
  "react-doctor/rerender-lazy-state-init": "Performance",
1842
2266
  "react-doctor/rerender-functional-setstate": "Performance",
1843
2267
  "react-doctor/rerender-dependencies": "State & Effects",
1844
2268
  "react-doctor/rerender-state-only-in-handlers": "Performance",
1845
2269
  "react-doctor/rerender-defer-reads-hook": "Performance",
1846
2270
  "react-doctor/advanced-event-handler-refs": "Performance",
2271
+ "react-doctor/effect-needs-cleanup": "State & Effects",
1847
2272
  "react-doctor/no-generic-handler-names": "Architecture",
1848
2273
  "react-doctor/no-giant-component": "Architecture",
1849
2274
  "react-doctor/no-many-boolean-props": "Architecture",
@@ -1852,6 +2277,10 @@ const RULE_CATEGORY_MAP = {
1852
2277
  "react-doctor/no-render-in-render": "Architecture",
1853
2278
  "react-doctor/no-nested-component-definition": "Correctness",
1854
2279
  "react-doctor/react-compiler-destructure-method": "Architecture",
2280
+ "react-doctor/no-legacy-class-lifecycles": "Correctness",
2281
+ "react-doctor/no-legacy-context-api": "Correctness",
2282
+ "react-doctor/no-default-props": "Architecture",
2283
+ "react-doctor/no-react-dom-deprecated-apis": "Architecture",
1855
2284
  "react-doctor/no-usememo-simple-expression": "Performance",
1856
2285
  "react-doctor/no-layout-property-animation": "Performance",
1857
2286
  "react-doctor/rerender-memo-with-default-value": "Performance",
@@ -1885,6 +2314,7 @@ const RULE_CATEGORY_MAP = {
1885
2314
  "react-doctor/rendering-conditional-render": "Correctness",
1886
2315
  "react-doctor/rendering-svg-precision": "Performance",
1887
2316
  "react-doctor/no-prevent-default": "Correctness",
2317
+ "react-doctor/no-uncontrolled-input": "Correctness",
1888
2318
  "react-doctor/no-document-start-view-transition": "Correctness",
1889
2319
  "react-doctor/no-flush-sync": "Performance",
1890
2320
  "react-doctor/nextjs-no-img-element": "Next.js",
@@ -1934,6 +2364,14 @@ const RULE_CATEGORY_MAP = {
1934
2364
  "react-doctor/no-disabled-zoom": "Accessibility",
1935
2365
  "react-doctor/no-outline-none": "Accessibility",
1936
2366
  "react-doctor/no-long-transition-duration": "Performance",
2367
+ "react-doctor/design-no-bold-heading": "Architecture",
2368
+ "react-doctor/design-no-redundant-padding-axes": "Architecture",
2369
+ "react-doctor/design-no-redundant-size-axes": "Architecture",
2370
+ "react-doctor/design-no-space-on-flex-children": "Architecture",
2371
+ "react-doctor/design-no-em-dash-in-jsx-text": "Architecture",
2372
+ "react-doctor/design-no-three-period-ellipsis": "Architecture",
2373
+ "react-doctor/design-no-default-tailwind-palette": "Architecture",
2374
+ "react-doctor/design-no-vague-button-label": "Accessibility",
1937
2375
  "react-doctor/js-flatmap-filter": "Performance",
1938
2376
  "react-doctor/js-combine-iterations": "Performance",
1939
2377
  "react-doctor/js-tosorted-immutable": "Performance",
@@ -1991,10 +2429,18 @@ const RULE_CATEGORY_MAP = {
1991
2429
  const RULE_HELP_MAP = {
1992
2430
  "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",
1993
2431
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
2432
+ "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",
2433
+ "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",
1994
2434
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
2435
+ "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",
1995
2436
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
2437
+ "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",
1996
2438
  "no-derived-useState": "Remove useState and compute the value inline: `const value = transform(propName)`",
2439
+ "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",
2440
+ "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",
2441
+ "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",
1997
2442
  "prefer-useReducer": "Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`",
2443
+ "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",
1998
2444
  "rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
1999
2445
  "rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
2000
2446
  "rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
@@ -2003,7 +2449,11 @@ const RULE_HELP_MAP = {
2003
2449
  "no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
2004
2450
  "no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
2005
2451
  "no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
2006
- "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.",
2452
+ "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+.",
2453
+ "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.",
2454
+ "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.",
2455
+ "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\" }`.",
2456
+ "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+.",
2007
2457
  "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",
2008
2458
  "no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
2009
2459
  "no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
@@ -2018,6 +2468,7 @@ const RULE_HELP_MAP = {
2018
2468
  "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",
2019
2469
  "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",
2020
2470
  "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",
2471
+ "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",
2021
2472
  "async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
2022
2473
  "async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
2023
2474
  "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",
@@ -2064,9 +2515,18 @@ const RULE_HELP_MAP = {
2064
2515
  "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",
2065
2516
  "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",
2066
2517
  "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",
2518
+ "design-no-bold-heading": "Use `font-semibold` (600) or `font-medium` (500) on headings — 700+ crushes letter counter shapes at display sizes",
2519
+ "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`)",
2520
+ "design-no-redundant-size-axes": "Collapse `w-N h-N` to `size-N` (Tailwind v3.4+) when both axes match",
2521
+ "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",
2522
+ "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",
2523
+ "design-no-three-period-ellipsis": "Use the typographic ellipsis \"…\" (or `&hellip;`) instead of three periods — pairs with action-with-followup labels (\"Rename…\", \"Loading…\")",
2524
+ "design-no-default-tailwind-palette": "Replace `indigo-*` / `gray-*` / `slate-*` with project tokens, your brand color, or a less-default neutral (`zinc`, `neutral`, `stone`)",
2525
+ "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",
2067
2526
  "no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
2068
2527
  "rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
2069
2528
  "no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
2529
+ "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",
2070
2530
  "nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
2071
2531
  "nextjs-async-client-component": "Fetch data in a parent Server Component and pass it as props, or use useQuery/useSWR in the client component",
2072
2532
  "nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
@@ -2149,6 +2609,7 @@ const RULE_HELP_MAP = {
2149
2609
  };
2150
2610
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
2151
2611
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
2612
+ const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
2152
2613
  const cleanDiagnosticMessage = (message, help, plugin, rule) => {
2153
2614
  if (plugin === "react-hooks-js") return {
2154
2615
  message: REACT_COMPILER_MESSAGE,
@@ -2156,7 +2617,7 @@ const cleanDiagnosticMessage = (message, help, plugin, rule) => {
2156
2617
  };
2157
2618
  return {
2158
2619
  message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
2159
- help: help || RULE_HELP_MAP[rule] || ""
2620
+ help: help || lookupOwnString(RULE_HELP_MAP, rule) || ""
2160
2621
  };
2161
2622
  };
2162
2623
  const parseRuleCode = (code) => {
@@ -2184,7 +2645,7 @@ const resolvePluginPath = () => {
2184
2645
  return pluginPath;
2185
2646
  };
2186
2647
  const resolveDiagnosticCategory = (plugin, rule) => {
2187
- return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
2648
+ return lookupOwnString(RULE_CATEGORY_MAP, `${plugin}/${rule}`) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
2188
2649
  };
2189
2650
  const SANITIZED_ENV = (() => {
2190
2651
  const sanitized = {};
@@ -2275,7 +2736,7 @@ const parseOxlintOutput = (stdout) => {
2275
2736
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
2276
2737
  }
2277
2738
  if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
2278
- return parsed.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2739
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2279
2740
  const { plugin, rule } = parseRuleCode(diagnostic.code);
2280
2741
  const primaryLabel = diagnostic.labels[0];
2281
2742
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -2305,8 +2766,8 @@ const validateRuleRegistration = () => {
2305
2766
  const missingCategory = [];
2306
2767
  for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2307
2768
  const ruleName = fullKey.replace(/^react-doctor\//, "");
2308
- if (!(fullKey in RULE_CATEGORY_MAP)) missingCategory.push(fullKey);
2309
- if (!(ruleName in RULE_HELP_MAP)) missingHelp.push(fullKey);
2769
+ if (!Object.hasOwn(RULE_CATEGORY_MAP, fullKey)) missingCategory.push(fullKey);
2770
+ if (!Object.hasOwn(RULE_HELP_MAP, ruleName)) missingHelp.push(fullKey);
2310
2771
  }
2311
2772
  if (missingCategory.length > 0 || missingHelp.length > 0) {
2312
2773
  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("; ");
@@ -2314,26 +2775,24 @@ const validateRuleRegistration = () => {
2314
2775
  }
2315
2776
  };
2316
2777
  const runOxlint = async (options) => {
2317
- const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
2778
+ const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, reactMajorVersion = null, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true } = options;
2318
2779
  validateRuleRegistration();
2319
2780
  if (includePaths !== void 0 && includePaths.length === 0) return [];
2320
2781
  const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
2321
2782
  const configPath = path.join(configDirectory, "oxlintrc.json");
2783
+ const pluginPath = resolvePluginPath();
2784
+ const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
2322
2785
  const config = createOxlintConfig({
2323
- pluginPath: resolvePluginPath(),
2786
+ pluginPath,
2324
2787
  framework,
2325
2788
  hasReactCompiler,
2326
2789
  hasTanStackQuery,
2327
- customRulesOnly
2790
+ customRulesOnly,
2791
+ reactMajorVersion,
2792
+ extendsPaths
2328
2793
  });
2329
2794
  const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
2330
2795
  try {
2331
- const fileHandle = fs.openSync(configPath, "wx", 384);
2332
- try {
2333
- fs.writeFileSync(fileHandle, JSON.stringify(config));
2334
- } finally {
2335
- fs.closeSync(fileHandle);
2336
- }
2337
2796
  const baseArgs = [
2338
2797
  resolveOxlintBinary(),
2339
2798
  "-c",
@@ -2352,12 +2811,39 @@ const runOxlint = async (options) => {
2352
2811
  baseArgs.push("--ignore-path", combinedIgnorePath);
2353
2812
  }
2354
2813
  const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
2355
- const allDiagnostics = [];
2356
- for (const batch of fileBatches) {
2357
- const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
2358
- allDiagnostics.push(...parseOxlintOutput(stdout));
2814
+ const writeOxlintConfig = (configToWrite) => {
2815
+ fs.rmSync(configPath, { force: true });
2816
+ const fileHandle = fs.openSync(configPath, "wx", 384);
2817
+ try {
2818
+ fs.writeFileSync(fileHandle, JSON.stringify(configToWrite));
2819
+ } finally {
2820
+ fs.closeSync(fileHandle);
2821
+ }
2822
+ };
2823
+ const spawnLintBatches = async () => {
2824
+ const allDiagnostics = [];
2825
+ for (const batch of fileBatches) {
2826
+ const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
2827
+ allDiagnostics.push(...parseOxlintOutput(stdout));
2828
+ }
2829
+ return allDiagnostics;
2830
+ };
2831
+ writeOxlintConfig(config);
2832
+ try {
2833
+ return await spawnLintBatches();
2834
+ } catch (error) {
2835
+ if (extendsPaths.length === 0) throw error;
2836
+ writeOxlintConfig(createOxlintConfig({
2837
+ pluginPath,
2838
+ framework,
2839
+ hasReactCompiler,
2840
+ hasTanStackQuery,
2841
+ customRulesOnly,
2842
+ reactMajorVersion,
2843
+ extendsPaths: []
2844
+ }));
2845
+ return await spawnLintBatches();
2359
2846
  }
2360
- return allDiagnostics;
2361
2847
  } finally {
2362
2848
  restoreDisableDirectives();
2363
2849
  fs.rmSync(configDirectory, {
@@ -2509,36 +2995,60 @@ const toJsonReport = (result, options) => buildJsonReport({
2509
2995
  }],
2510
2996
  totalElapsedMilliseconds: result.elapsedMilliseconds
2511
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
+ };
2512
3004
  const diagnose = async (directory, options = {}) => {
3005
+ const startTime = globalThis.performance.now();
2513
3006
  const resolvedDirectory = path.resolve(directory);
2514
3007
  const userConfig = loadConfig(resolvedDirectory);
2515
3008
  const includePaths = options.includePaths ?? [];
2516
3009
  const isDiffMode = includePaths.length > 0;
3010
+ const projectInfo = discoverProject(resolvedDirectory);
3011
+ if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(resolvedDirectory));
2517
3012
  const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
2518
- 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({
2519
3018
  rootDirectory: resolvedDirectory,
2520
- readFileLinesSync: createNodeReadFileLinesSync(resolvedDirectory),
2521
- loadUserConfig: () => userConfig,
2522
- discoverProjectInfo: () => discoverProject(resolvedDirectory),
2523
- calculateDiagnosticsScore: calculateScore,
2524
- getExtraDiagnostics: () => isDiffMode ? [] : checkReducedMotion(resolvedDirectory),
2525
- createRunners: ({ resolvedDirectory: projectRoot, projectInfo, userConfig: config }) => ({
2526
- runLint: () => runOxlint({
2527
- rootDirectory: projectRoot,
2528
- hasTypeScript: projectInfo.hasTypeScript,
2529
- framework: projectInfo.framework,
2530
- hasReactCompiler: projectInfo.hasReactCompiler,
2531
- hasTanStackQuery: projectInfo.hasTanStackQuery,
2532
- includePaths: lintIncludePaths,
2533
- customRulesOnly: config?.customRulesOnly ?? false,
2534
- respectInlineDisables: options.respectInlineDisables ?? config?.respectInlineDisables ?? true
2535
- }),
2536
- runDeadCode: () => runKnip(projectRoot)
2537
- })
2538
- }, {
2539
- ...options,
2540
- lintIncludePaths
2541
- });
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
+ };
2542
3052
  };
2543
3053
  //#endregion
2544
3054
  export { buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, summarizeDiagnostics, toJsonReport };