react-doctor 0.0.28 → 0.0.30

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.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  //#region src/types.d.ts
2
- type Framework = "nextjs" | "vite" | "cra" | "remix" | "gatsby" | "unknown";
2
+ type FailOnLevel = "error" | "warning" | "none";
3
+ type Framework = "nextjs" | "vite" | "cra" | "remix" | "gatsby" | "expo" | "react-native" | "unknown";
3
4
  interface ProjectInfo {
4
5
  rootDirectory: string;
5
6
  projectName: string;
@@ -41,6 +42,7 @@ interface ReactDoctorConfig {
41
42
  deadCode?: boolean;
42
43
  verbose?: boolean;
43
44
  diff?: boolean | string;
45
+ failOn?: FailOnLevel;
44
46
  }
45
47
  //#endregion
46
48
  //#region src/utils/get-diff-files.d.ts
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/utils/get-diff-files.ts","../src/index.ts"],"mappings":";KAAY,SAAA;AAAA,UAEK,WAAA;EACf,aAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA,EAAW,SAAA;EACX,aAAA;EACA,gBAAA;EACA,eAAA;AAAA;AAAA,UAiCe,UAAA;EACf,QAAA;EACA,MAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,IAAA;EACA,MAAA;EACA,QAAA;EACA,MAAA;AAAA;AAAA,UA4Be,WAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAyBe,QAAA;EACf,aAAA;EACA,UAAA;EACA,YAAA;EACA,gBAAA;AAAA;AAAA,UA2Ce,uBAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EACf,MAAA,GAAS,uBAAA;EACT,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;AAAA;;;cCrFW,WAAA,GAAe,SAAA,UAAmB,kBAAA,cAA8B,QAAA;AAAA,cAiBhE,iBAAA,GAAqB,SAAA;;;UCnFjB,eAAA;EACf,IAAA;EACA,QAAA;EACA,YAAA;AAAA;AAAA,UAGe,cAAA;EACf,WAAA,EAAa,UAAA;EACb,KAAA,EAAO,WAAA;EACP,OAAA,EAAS,WAAA;EACT,mBAAA;AAAA;AAAA,cAGW,QAAA,GACX,SAAA,UACA,OAAA,GAAS,eAAA,KACR,OAAA,CAAQ,cAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/utils/get-diff-files.ts","../src/index.ts"],"mappings":";KAAY,WAAA;AAAA,KAEA,SAAA;AAAA,UAUK,WAAA;EACf,aAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA,EAAW,SAAA;EACX,aAAA;EACA,gBAAA;EACA,eAAA;AAAA;AAAA,UAiCe,UAAA;EACf,QAAA;EACA,MAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,IAAA;EACA,MAAA;EACA,QAAA;EACA,MAAA;AAAA;AAAA,UA4Be,WAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAyBe,QAAA;EACf,aAAA;EACA,UAAA;EACA,YAAA;EACA,gBAAA;AAAA;AAAA,UA2Ce,uBAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EACf,MAAA,GAAS,uBAAA;EACT,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,MAAA,GAAS,WAAA;AAAA;;;cChGE,WAAA,GAAe,SAAA,UAAmB,kBAAA,cAA8B,QAAA;AAAA,cAiBhE,iBAAA,GAAqB,SAAA;;;UCnFjB,eAAA;EACf,IAAA;EACA,QAAA;EACA,YAAA;AAAA;AAAA,UAGe,cAAA;EACf,WAAA,EAAa,UAAA;EACb,KAAA,EAAO,WAAA;EACP,OAAA,EAAS,WAAA;EACT,mBAAA;AAAA;AAAA,cAGW,QAAA,GACX,SAAA,UACA,OAAA,GAAS,eAAA,KACR,OAAA,CAAQ,cAAA"}
package/dist/index.js CHANGED
@@ -12,11 +12,18 @@ import { fileURLToPath } from "node:url";
12
12
  const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
13
13
  const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
14
14
  const ERROR_PREVIEW_LENGTH_CHARS = 200;
15
+ const PERFECT_SCORE = 100;
16
+ const SCORE_GOOD_THRESHOLD = 75;
17
+ const SCORE_OK_THRESHOLD = 50;
15
18
  const SCORE_API_URL = "https://www.react.doctor/api/score";
16
19
  const FETCH_TIMEOUT_MS = 1e4;
17
20
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
18
21
  const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
19
22
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
23
+ const ERROR_RULE_PENALTY = 1.5;
24
+ const WARNING_RULE_PENALTY = .75;
25
+ const ERROR_ESTIMATED_FIX_RATE = .85;
26
+ const WARNING_ESTIMATED_FIX_RATE = .8;
20
27
  const MAX_KNIP_RETRIES = 5;
21
28
  const AMI_WEBSITE_URL = "https://ami.dev";
22
29
  const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
@@ -71,6 +78,46 @@ const proxyFetch = async (url, init) => {
71
78
 
72
79
  //#endregion
73
80
  //#region src/utils/calculate-score.ts
81
+ const getScoreLabel = (score) => {
82
+ if (score >= SCORE_GOOD_THRESHOLD) return "Great";
83
+ if (score >= SCORE_OK_THRESHOLD) return "Needs work";
84
+ return "Critical";
85
+ };
86
+ const countUniqueRules = (diagnostics) => {
87
+ const errorRules = /* @__PURE__ */ new Set();
88
+ const warningRules = /* @__PURE__ */ new Set();
89
+ for (const diagnostic of diagnostics) {
90
+ const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
91
+ if (diagnostic.severity === "error") errorRules.add(ruleKey);
92
+ else warningRules.add(ruleKey);
93
+ }
94
+ return {
95
+ errorRuleCount: errorRules.size,
96
+ warningRuleCount: warningRules.size
97
+ };
98
+ };
99
+ const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
100
+ const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
101
+ return Math.max(0, Math.round(PERFECT_SCORE - penalty));
102
+ };
103
+ const estimateScoreLocally = (diagnostics) => {
104
+ const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
105
+ const currentScore = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
106
+ const estimatedScore = scoreFromRuleCounts(Math.round(errorRuleCount * (1 - ERROR_ESTIMATED_FIX_RATE)), Math.round(warningRuleCount * (1 - WARNING_ESTIMATED_FIX_RATE)));
107
+ return {
108
+ currentScore,
109
+ currentLabel: getScoreLabel(currentScore),
110
+ estimatedScore,
111
+ estimatedLabel: getScoreLabel(estimatedScore)
112
+ };
113
+ };
114
+ const calculateScoreLocally = (diagnostics) => {
115
+ const { currentScore, currentLabel } = estimateScoreLocally(diagnostics);
116
+ return {
117
+ score: currentScore,
118
+ label: currentLabel
119
+ };
120
+ };
74
121
  const calculateScore = async (diagnostics) => {
75
122
  try {
76
123
  const response = await proxyFetch(SCORE_API_URL, {
@@ -78,10 +125,10 @@ const calculateScore = async (diagnostics) => {
78
125
  headers: { "Content-Type": "application/json" },
79
126
  body: JSON.stringify({ diagnostics })
80
127
  });
81
- if (!response.ok) return null;
128
+ if (!response.ok) return calculateScoreLocally(diagnostics);
82
129
  return await response.json();
83
130
  } catch {
84
- return null;
131
+ return calculateScoreLocally(diagnostics);
85
132
  }
86
133
  };
87
134
 
@@ -89,13 +136,33 @@ const calculateScore = async (diagnostics) => {
89
136
  //#region src/plugin/constants.ts
90
137
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
91
138
 
139
+ //#endregion
140
+ //#region src/utils/is-file.ts
141
+ const isFile = (filePath) => {
142
+ try {
143
+ return fs.statSync(filePath).isFile();
144
+ } catch {
145
+ return false;
146
+ }
147
+ };
148
+
92
149
  //#endregion
93
150
  //#region src/utils/read-package-json.ts
94
- const readPackageJson = (packageJsonPath) => JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
151
+ const readPackageJson = (packageJsonPath) => {
152
+ try {
153
+ return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
154
+ } catch (error) {
155
+ if (error instanceof Error && "code" in error) {
156
+ const { code } = error;
157
+ if (code === "EISDIR" || code === "EACCES") return {};
158
+ }
159
+ throw error;
160
+ }
161
+ };
95
162
 
96
163
  //#endregion
97
164
  //#region src/utils/check-reduced-motion.ts
98
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
165
+ const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
99
166
  const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
100
167
  const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
101
168
  filePath: "package.json",
@@ -111,7 +178,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
111
178
  };
112
179
  const checkReducedMotion = (rootDirectory) => {
113
180
  const packageJsonPath = path.join(rootDirectory, "package.json");
114
- if (!fs.existsSync(packageJsonPath)) return [];
181
+ if (!isFile(packageJsonPath)) return [];
115
182
  let hasMotionLibrary = false;
116
183
  try {
117
184
  const packageJson = readPackageJson(packageJsonPath);
@@ -139,7 +206,7 @@ const checkReducedMotion = (rootDirectory) => {
139
206
  //#region src/utils/match-glob-pattern.ts
140
207
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
141
208
  const compileGlobPattern = (pattern) => {
142
- const normalizedPattern = pattern.replace(/\\/g, "/");
209
+ const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
143
210
  let regexSource = "^";
144
211
  let characterIndex = 0;
145
212
  while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
@@ -177,25 +244,67 @@ const filterIgnoredDiagnostics = (diagnostics, config) => {
177
244
  return true;
178
245
  });
179
246
  };
247
+ const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
248
+ const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
249
+ const isRuleSuppressed = (commentRules, ruleId) => {
250
+ if (!commentRules?.trim()) return true;
251
+ return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
252
+ };
253
+ const filterInlineSuppressions = (diagnostics, rootDirectory) => {
254
+ const fileLineCache = /* @__PURE__ */ new Map();
255
+ const getFileLines = (filePath) => {
256
+ const cached = fileLineCache.get(filePath);
257
+ if (cached !== void 0) return cached;
258
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
259
+ try {
260
+ const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
261
+ fileLineCache.set(filePath, lines);
262
+ return lines;
263
+ } catch {
264
+ fileLineCache.set(filePath, null);
265
+ return null;
266
+ }
267
+ };
268
+ return diagnostics.filter((diagnostic) => {
269
+ if (diagnostic.line <= 0) return true;
270
+ const lines = getFileLines(diagnostic.filePath);
271
+ if (!lines) return true;
272
+ const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
273
+ const currentLine = lines[diagnostic.line - 1];
274
+ if (currentLine) {
275
+ const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
276
+ if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
277
+ }
278
+ if (diagnostic.line >= 2) {
279
+ const prevLine = lines[diagnostic.line - 2];
280
+ if (prevLine) {
281
+ const nextLineMatch = prevLine.match(DISABLE_NEXT_LINE_PATTERN);
282
+ if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
283
+ }
284
+ }
285
+ return true;
286
+ });
287
+ };
180
288
 
181
289
  //#endregion
182
290
  //#region src/utils/combine-diagnostics.ts
183
291
  const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
184
292
  const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
185
- const allDiagnostics = [
293
+ const merged = [
186
294
  ...lintDiagnostics,
187
295
  ...deadCodeDiagnostics,
188
296
  ...isDiffMode ? [] : checkReducedMotion(directory)
189
297
  ];
190
- return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
298
+ return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(merged, userConfig) : merged, directory);
191
299
  };
192
300
 
193
301
  //#endregion
194
302
  //#region src/utils/find-monorepo-root.ts
195
303
  const isMonorepoRoot = (directory) => {
196
- if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
304
+ if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
305
+ if (isFile(path.join(directory, "nx.json"))) return true;
197
306
  const packageJsonPath = path.join(directory, "package.json");
198
- if (!fs.existsSync(packageJsonPath)) return false;
307
+ if (!isFile(packageJsonPath)) return false;
199
308
  const packageJson = readPackageJson(packageJsonPath);
200
309
  return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
201
310
  };
@@ -208,6 +317,10 @@ const findMonorepoRoot = (startDirectory) => {
208
317
  return null;
209
318
  };
210
319
 
320
+ //#endregion
321
+ //#region src/utils/is-plain-object.ts
322
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
323
+
211
324
  //#endregion
212
325
  //#region src/utils/discover-project.ts
213
326
  const REACT_COMPILER_PACKAGES = new Set([
@@ -246,9 +359,33 @@ const FRAMEWORK_PACKAGES = {
246
359
  vite: "vite",
247
360
  "react-scripts": "cra",
248
361
  "@remix-run/react": "remix",
249
- gatsby: "gatsby"
362
+ gatsby: "gatsby",
363
+ expo: "expo",
364
+ "react-native": "react-native"
365
+ };
366
+ const IGNORED_DIRECTORIES = new Set([
367
+ "node_modules",
368
+ "dist",
369
+ "build",
370
+ "coverage"
371
+ ]);
372
+ const countSourceFilesViaFilesystem = (rootDirectory) => {
373
+ let count = 0;
374
+ const stack = [rootDirectory];
375
+ while (stack.length > 0) {
376
+ const currentDirectory = stack.pop();
377
+ const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
378
+ for (const entry of entries) {
379
+ if (entry.isDirectory()) {
380
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
381
+ continue;
382
+ }
383
+ if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
384
+ }
385
+ }
386
+ return count;
250
387
  };
251
- const countSourceFiles = (rootDirectory) => {
388
+ const countSourceFilesViaGit = (rootDirectory) => {
252
389
  const result = spawnSync("git", [
253
390
  "ls-files",
254
391
  "--cached",
@@ -259,9 +396,10 @@ const countSourceFiles = (rootDirectory) => {
259
396
  encoding: "utf-8",
260
397
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
261
398
  });
262
- if (result.error || result.status !== 0) return 0;
399
+ if (result.error || result.status !== 0) return null;
263
400
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
264
401
  };
402
+ const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
265
403
  const collectAllDependencies = (packageJson) => ({
266
404
  ...packageJson.peerDependencies,
267
405
  ...packageJson.dependencies,
@@ -271,16 +409,37 @@ const detectFramework = (dependencies) => {
271
409
  for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
272
410
  return "unknown";
273
411
  };
412
+ const isCatalogReference = (version) => version.startsWith("catalog:");
413
+ const resolveVersionFromCatalog = (catalog, packageName) => {
414
+ const version = catalog[packageName];
415
+ if (typeof version === "string" && !isCatalogReference(version)) return version;
416
+ return null;
417
+ };
418
+ const resolveCatalogVersion = (packageJson, packageName) => {
419
+ const raw = packageJson;
420
+ if (isPlainObject(raw.catalog)) {
421
+ const version = resolveVersionFromCatalog(raw.catalog, packageName);
422
+ if (version) return version;
423
+ }
424
+ if (isPlainObject(raw.catalogs)) {
425
+ for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
426
+ const version = resolveVersionFromCatalog(catalogEntries, packageName);
427
+ if (version) return version;
428
+ }
429
+ }
430
+ return null;
431
+ };
274
432
  const extractDependencyInfo = (packageJson) => {
275
433
  const allDependencies = collectAllDependencies(packageJson);
434
+ const rawVersion = allDependencies.react ?? null;
276
435
  return {
277
- reactVersion: allDependencies.react ?? null,
436
+ reactVersion: rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null,
278
437
  framework: detectFramework(allDependencies)
279
438
  };
280
439
  };
281
440
  const parsePnpmWorkspacePatterns = (rootDirectory) => {
282
441
  const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
283
- if (!fs.existsSync(workspacePath)) return [];
442
+ if (!isFile(workspacePath)) return [];
284
443
  const content = fs.readFileSync(workspacePath, "utf-8");
285
444
  const patterns = [];
286
445
  let isInsidePackagesBlock = false;
@@ -306,14 +465,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
306
465
  const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
307
466
  if (!cleanPattern.includes("*")) {
308
467
  const directoryPath = path.join(rootDirectory, cleanPattern);
309
- if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
468
+ if (fs.existsSync(directoryPath) && isFile(path.join(directoryPath, "package.json"))) return [directoryPath];
310
469
  return [];
311
470
  }
312
471
  const wildcardIndex = cleanPattern.indexOf("*");
313
472
  const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
314
473
  const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
315
474
  if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
316
- return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)).filter((entryPath) => fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
475
+ return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)).filter((entryPath) => fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() && isFile(path.join(entryPath, "package.json")));
317
476
  };
318
477
  const findDependencyInfoFromMonorepoRoot = (directory) => {
319
478
  const monorepoRoot = findMonorepoRoot(directory);
@@ -321,11 +480,17 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
321
480
  reactVersion: null,
322
481
  framework: "unknown"
323
482
  };
324
- const rootPackageJson = readPackageJson(path.join(monorepoRoot, "package.json"));
483
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
484
+ if (!isFile(monorepoPackageJsonPath)) return {
485
+ reactVersion: null,
486
+ framework: "unknown"
487
+ };
488
+ const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
325
489
  const rootInfo = extractDependencyInfo(rootPackageJson);
490
+ const catalogVersion = resolveCatalogVersion(rootPackageJson, "react");
326
491
  const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
327
492
  return {
328
- reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
493
+ reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
329
494
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
330
495
  };
331
496
  };
@@ -351,7 +516,7 @@ const hasCompilerPackage = (packageJson) => {
351
516
  return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
352
517
  };
353
518
  const fileContainsPattern = (filePath, pattern) => {
354
- if (!fs.existsSync(filePath)) return false;
519
+ if (!isFile(filePath)) return false;
355
520
  const content = fs.readFileSync(filePath, "utf-8");
356
521
  return pattern.test(content);
357
522
  };
@@ -365,7 +530,7 @@ const detectReactCompiler = (directory, packageJson) => {
365
530
  let ancestorDirectory = path.dirname(directory);
366
531
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
367
532
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
368
- if (fs.existsSync(ancestorPackagePath)) {
533
+ if (isFile(ancestorPackagePath)) {
369
534
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
370
535
  }
371
536
  ancestorDirectory = path.dirname(ancestorDirectory);
@@ -374,9 +539,10 @@ const detectReactCompiler = (directory, packageJson) => {
374
539
  };
375
540
  const discoverProject = (directory) => {
376
541
  const packageJsonPath = path.join(directory, "package.json");
377
- if (!fs.existsSync(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
542
+ if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
378
543
  const packageJson = readPackageJson(packageJsonPath);
379
544
  let { reactVersion, framework } = extractDependencyInfo(packageJson);
545
+ if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react");
380
546
  if (!reactVersion || framework === "unknown") {
381
547
  const workspaceInfo = findReactInWorkspaces(directory, packageJson);
382
548
  if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
@@ -406,23 +572,18 @@ const discoverProject = (directory) => {
406
572
  //#region src/utils/load-config.ts
407
573
  const CONFIG_FILENAME = "react-doctor.config.json";
408
574
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
409
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
410
575
  const loadConfig = (rootDirectory) => {
411
576
  const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
412
- if (fs.existsSync(configFilePath)) try {
577
+ if (isFile(configFilePath)) try {
413
578
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
414
579
  const parsed = JSON.parse(fileContent);
415
- if (!isPlainObject(parsed)) {
416
- console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
417
- return null;
418
- }
419
- return parsed;
580
+ if (isPlainObject(parsed)) return parsed;
581
+ console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
420
582
  } catch (error) {
421
583
  console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
422
- return null;
423
584
  }
424
585
  const packageJsonPath = path.join(rootDirectory, "package.json");
425
- if (fs.existsSync(packageJsonPath)) try {
586
+ if (isFile(packageJsonPath)) try {
426
587
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
427
588
  const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
428
589
  if (isPlainObject(embeddedConfig)) return embeddedConfig;
@@ -490,11 +651,15 @@ const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
490
651
  const extractFailedPluginName = (error) => {
491
652
  return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
492
653
  };
654
+ const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
655
+ const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
493
656
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
657
+ const tsConfigFile = resolveTsConfigFile(knipCwd);
494
658
  const options = await silenced(() => createOptions({
495
659
  cwd: knipCwd,
496
660
  isShowProgress: false,
497
- ...workspaceName ? { workspace: workspaceName } : {}
661
+ ...workspaceName ? { workspace: workspaceName } : {},
662
+ ...tsConfigFile ? { tsConfigFile } : {}
498
663
  }));
499
664
  const parsedConfig = options.parsedConfig;
500
665
  for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
@@ -516,7 +681,7 @@ const runKnip = async (rootDirectory) => {
516
681
  let knipResult;
517
682
  if (monorepoRoot) {
518
683
  const packageJsonPath = path.join(rootDirectory, "package.json");
519
- const workspaceName = (fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
684
+ const workspaceName = (isFile(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
520
685
  try {
521
686
  knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
522
687
  } catch {
@@ -566,6 +731,16 @@ const NEXTJS_RULES = {
566
731
  "react-doctor/nextjs-no-head-import": "error",
567
732
  "react-doctor/nextjs-no-side-effect-in-get-handler": "error"
568
733
  };
734
+ const REACT_NATIVE_RULES = {
735
+ "react-doctor/rn-no-raw-text": "error",
736
+ "react-doctor/rn-no-deprecated-modules": "error",
737
+ "react-doctor/rn-no-legacy-expo-packages": "warn",
738
+ "react-doctor/rn-no-dimensions-get": "warn",
739
+ "react-doctor/rn-no-inline-flatlist-renderitem": "warn",
740
+ "react-doctor/rn-no-legacy-shadow-styles": "warn",
741
+ "react-doctor/rn-prefer-reanimated": "warn",
742
+ "react-doctor/rn-no-single-element-style-array": "warn"
743
+ };
569
744
  const REACT_COMPILER_RULES = {
570
745
  "react-hooks-js/set-state-in-render": "error",
571
746
  "react-hooks-js/immutability": "error",
@@ -669,7 +844,8 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
669
844
  "react-doctor/server-after-nonblocking": "warn",
670
845
  "react-doctor/client-passive-event-listeners": "warn",
671
846
  "react-doctor/async-parallel": "warn",
672
- ...framework === "nextjs" ? NEXTJS_RULES : {}
847
+ ...framework === "nextjs" ? NEXTJS_RULES : {},
848
+ ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}
673
849
  }
674
850
  });
675
851
 
@@ -777,10 +953,18 @@ const RULE_CATEGORY_MAP = {
777
953
  "react-doctor/server-auth-actions": "Server",
778
954
  "react-doctor/server-after-nonblocking": "Server",
779
955
  "react-doctor/client-passive-event-listeners": "Performance",
780
- "react-doctor/async-parallel": "Performance"
956
+ "react-doctor/async-parallel": "Performance",
957
+ "react-doctor/rn-no-raw-text": "React Native",
958
+ "react-doctor/rn-no-deprecated-modules": "React Native",
959
+ "react-doctor/rn-no-legacy-expo-packages": "React Native",
960
+ "react-doctor/rn-no-dimensions-get": "React Native",
961
+ "react-doctor/rn-no-inline-flatlist-renderitem": "React Native",
962
+ "react-doctor/rn-no-legacy-shadow-styles": "React Native",
963
+ "react-doctor/rn-prefer-reanimated": "React Native",
964
+ "react-doctor/rn-no-single-element-style-array": "React Native"
781
965
  };
782
966
  const RULE_HELP_MAP = {
783
- "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} />`",
967
+ "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",
784
968
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
785
969
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
786
970
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
@@ -820,7 +1004,7 @@ const RULE_HELP_MAP = {
820
1004
  "nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
821
1005
  "nextjs-no-client-fetch-for-server-data": "Remove 'use client' and fetch directly in the Server Component — no API round-trip, secrets stay on server",
822
1006
  "nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
823
- "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' in a Server Component, or handle in middleware",
1007
+ "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' directly (works in both server and client components), or handle in middleware",
824
1008
  "nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
825
1009
  "nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
826
1010
  "nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
@@ -833,7 +1017,15 @@ const RULE_HELP_MAP = {
833
1017
  "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
834
1018
  "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
835
1019
  "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
836
- "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently"
1020
+ "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
1021
+ "rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
1022
+ "rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
1023
+ "rn-no-legacy-expo-packages": "Migrate to the recommended replacement package — legacy Expo packages are no longer maintained",
1024
+ "rn-no-dimensions-get": "Use `const { width, height } = useWindowDimensions()` — it updates reactively on rotation and resize",
1025
+ "rn-no-inline-flatlist-renderitem": "Extract renderItem to a named function or wrap in useCallback to avoid re-creating on every render",
1026
+ "rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
1027
+ "rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
1028
+ "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation"
837
1029
  };
838
1030
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
839
1031
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
@@ -900,7 +1092,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
900
1092
  child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
901
1093
  child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
902
1094
  child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
903
- child.on("close", () => {
1095
+ child.on("close", (code, signal) => {
1096
+ if (signal) {
1097
+ const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1098
+ const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
1099
+ const detail = stderrOutput ? `: ${stderrOutput}` : "";
1100
+ reject(/* @__PURE__ */ new Error(`oxlint was killed by ${signal}${hint}${detail}`));
1101
+ return;
1102
+ }
904
1103
  const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
905
1104
  if (!output) {
906
1105
  const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();