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/cli.js CHANGED
@@ -33,7 +33,6 @@ const FETCH_TIMEOUT_MS = 1e4;
33
33
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
34
34
  const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
35
35
  const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
36
- const OFFLINE_FLAG_MESSAGE = "Score not calculated. Remove --offline to calculate score.";
37
36
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
38
37
  const ERROR_RULE_PENALTY = 1.5;
39
38
  const WARNING_RULE_PENALTY = .75;
@@ -129,6 +128,13 @@ const estimateScoreLocally = (diagnostics) => {
129
128
  estimatedLabel: getScoreLabel(estimatedScore)
130
129
  };
131
130
  };
131
+ const calculateScoreLocally = (diagnostics) => {
132
+ const { currentScore, currentLabel } = estimateScoreLocally(diagnostics);
133
+ return {
134
+ score: currentScore,
135
+ label: currentLabel
136
+ };
137
+ };
132
138
  const calculateScore = async (diagnostics) => {
133
139
  try {
134
140
  const response = await proxyFetch(SCORE_API_URL, {
@@ -136,10 +142,10 @@ const calculateScore = async (diagnostics) => {
136
142
  headers: { "Content-Type": "application/json" },
137
143
  body: JSON.stringify({ diagnostics })
138
144
  });
139
- if (!response.ok) return null;
145
+ if (!response.ok) return calculateScoreLocally(diagnostics);
140
146
  return await response.json();
141
147
  } catch {
142
- return null;
148
+ return calculateScoreLocally(diagnostics);
143
149
  }
144
150
  };
145
151
  const fetchEstimatedScore = async (diagnostics) => {
@@ -178,13 +184,33 @@ const colorizeByScore = (text, score) => {
178
184
  //#region src/plugin/constants.ts
179
185
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
180
186
 
187
+ //#endregion
188
+ //#region src/utils/is-file.ts
189
+ const isFile = (filePath) => {
190
+ try {
191
+ return fs.statSync(filePath).isFile();
192
+ } catch {
193
+ return false;
194
+ }
195
+ };
196
+
181
197
  //#endregion
182
198
  //#region src/utils/read-package-json.ts
183
- const readPackageJson = (packageJsonPath) => JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
199
+ const readPackageJson = (packageJsonPath) => {
200
+ try {
201
+ return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
202
+ } catch (error) {
203
+ if (error instanceof Error && "code" in error) {
204
+ const { code } = error;
205
+ if (code === "EISDIR" || code === "EACCES") return {};
206
+ }
207
+ throw error;
208
+ }
209
+ };
184
210
 
185
211
  //#endregion
186
212
  //#region src/utils/check-reduced-motion.ts
187
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
213
+ const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
188
214
  const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
189
215
  const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
190
216
  filePath: "package.json",
@@ -200,7 +226,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
200
226
  };
201
227
  const checkReducedMotion = (rootDirectory) => {
202
228
  const packageJsonPath = path.join(rootDirectory, "package.json");
203
- if (!fs.existsSync(packageJsonPath)) return [];
229
+ if (!isFile(packageJsonPath)) return [];
204
230
  let hasMotionLibrary = false;
205
231
  try {
206
232
  const packageJson = readPackageJson(packageJsonPath);
@@ -228,7 +254,7 @@ const checkReducedMotion = (rootDirectory) => {
228
254
  //#region src/utils/match-glob-pattern.ts
229
255
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
230
256
  const compileGlobPattern = (pattern) => {
231
- const normalizedPattern = pattern.replace(/\\/g, "/");
257
+ const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
232
258
  let regexSource = "^";
233
259
  let characterIndex = 0;
234
260
  while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
@@ -266,25 +292,67 @@ const filterIgnoredDiagnostics = (diagnostics, config) => {
266
292
  return true;
267
293
  });
268
294
  };
295
+ const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
296
+ const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
297
+ const isRuleSuppressed = (commentRules, ruleId) => {
298
+ if (!commentRules?.trim()) return true;
299
+ return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
300
+ };
301
+ const filterInlineSuppressions = (diagnostics, rootDirectory) => {
302
+ const fileLineCache = /* @__PURE__ */ new Map();
303
+ const getFileLines = (filePath) => {
304
+ const cached = fileLineCache.get(filePath);
305
+ if (cached !== void 0) return cached;
306
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
307
+ try {
308
+ const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
309
+ fileLineCache.set(filePath, lines);
310
+ return lines;
311
+ } catch {
312
+ fileLineCache.set(filePath, null);
313
+ return null;
314
+ }
315
+ };
316
+ return diagnostics.filter((diagnostic) => {
317
+ if (diagnostic.line <= 0) return true;
318
+ const lines = getFileLines(diagnostic.filePath);
319
+ if (!lines) return true;
320
+ const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
321
+ const currentLine = lines[diagnostic.line - 1];
322
+ if (currentLine) {
323
+ const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
324
+ if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
325
+ }
326
+ if (diagnostic.line >= 2) {
327
+ const prevLine = lines[diagnostic.line - 2];
328
+ if (prevLine) {
329
+ const nextLineMatch = prevLine.match(DISABLE_NEXT_LINE_PATTERN);
330
+ if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
331
+ }
332
+ }
333
+ return true;
334
+ });
335
+ };
269
336
 
270
337
  //#endregion
271
338
  //#region src/utils/combine-diagnostics.ts
272
339
  const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
273
340
  const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
274
- const allDiagnostics = [
341
+ const merged = [
275
342
  ...lintDiagnostics,
276
343
  ...deadCodeDiagnostics,
277
344
  ...isDiffMode ? [] : checkReducedMotion(directory)
278
345
  ];
279
- return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
346
+ return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(merged, userConfig) : merged, directory);
280
347
  };
281
348
 
282
349
  //#endregion
283
350
  //#region src/utils/find-monorepo-root.ts
284
351
  const isMonorepoRoot = (directory) => {
285
- if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
352
+ if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
353
+ if (isFile(path.join(directory, "nx.json"))) return true;
286
354
  const packageJsonPath = path.join(directory, "package.json");
287
- if (!fs.existsSync(packageJsonPath)) return false;
355
+ if (!isFile(packageJsonPath)) return false;
288
356
  const packageJson = readPackageJson(packageJsonPath);
289
357
  return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
290
358
  };
@@ -297,6 +365,10 @@ const findMonorepoRoot = (startDirectory) => {
297
365
  return null;
298
366
  };
299
367
 
368
+ //#endregion
369
+ //#region src/utils/is-plain-object.ts
370
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
371
+
300
372
  //#endregion
301
373
  //#region src/utils/discover-project.ts
302
374
  const REACT_COMPILER_PACKAGES = new Set([
@@ -335,7 +407,9 @@ const FRAMEWORK_PACKAGES = {
335
407
  vite: "vite",
336
408
  "react-scripts": "cra",
337
409
  "@remix-run/react": "remix",
338
- gatsby: "gatsby"
410
+ gatsby: "gatsby",
411
+ expo: "expo",
412
+ "react-native": "react-native"
339
413
  };
340
414
  const FRAMEWORK_DISPLAY_NAMES = {
341
415
  nextjs: "Next.js",
@@ -343,10 +417,34 @@ const FRAMEWORK_DISPLAY_NAMES = {
343
417
  cra: "Create React App",
344
418
  remix: "Remix",
345
419
  gatsby: "Gatsby",
420
+ expo: "Expo",
421
+ "react-native": "React Native",
346
422
  unknown: "React"
347
423
  };
348
424
  const formatFrameworkName = (framework) => FRAMEWORK_DISPLAY_NAMES[framework];
349
- const countSourceFiles = (rootDirectory) => {
425
+ const IGNORED_DIRECTORIES = new Set([
426
+ "node_modules",
427
+ "dist",
428
+ "build",
429
+ "coverage"
430
+ ]);
431
+ const countSourceFilesViaFilesystem = (rootDirectory) => {
432
+ let count = 0;
433
+ const stack = [rootDirectory];
434
+ while (stack.length > 0) {
435
+ const currentDirectory = stack.pop();
436
+ const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
437
+ for (const entry of entries) {
438
+ if (entry.isDirectory()) {
439
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
440
+ continue;
441
+ }
442
+ if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
443
+ }
444
+ }
445
+ return count;
446
+ };
447
+ const countSourceFilesViaGit = (rootDirectory) => {
350
448
  const result = spawnSync("git", [
351
449
  "ls-files",
352
450
  "--cached",
@@ -357,9 +455,10 @@ const countSourceFiles = (rootDirectory) => {
357
455
  encoding: "utf-8",
358
456
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
359
457
  });
360
- if (result.error || result.status !== 0) return 0;
458
+ if (result.error || result.status !== 0) return null;
361
459
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
362
460
  };
461
+ const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
363
462
  const collectAllDependencies = (packageJson) => ({
364
463
  ...packageJson.peerDependencies,
365
464
  ...packageJson.dependencies,
@@ -369,16 +468,37 @@ const detectFramework = (dependencies) => {
369
468
  for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
370
469
  return "unknown";
371
470
  };
471
+ const isCatalogReference = (version) => version.startsWith("catalog:");
472
+ const resolveVersionFromCatalog = (catalog, packageName) => {
473
+ const version = catalog[packageName];
474
+ if (typeof version === "string" && !isCatalogReference(version)) return version;
475
+ return null;
476
+ };
477
+ const resolveCatalogVersion = (packageJson, packageName) => {
478
+ const raw = packageJson;
479
+ if (isPlainObject(raw.catalog)) {
480
+ const version = resolveVersionFromCatalog(raw.catalog, packageName);
481
+ if (version) return version;
482
+ }
483
+ if (isPlainObject(raw.catalogs)) {
484
+ for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
485
+ const version = resolveVersionFromCatalog(catalogEntries, packageName);
486
+ if (version) return version;
487
+ }
488
+ }
489
+ return null;
490
+ };
372
491
  const extractDependencyInfo = (packageJson) => {
373
492
  const allDependencies = collectAllDependencies(packageJson);
493
+ const rawVersion = allDependencies.react ?? null;
374
494
  return {
375
- reactVersion: allDependencies.react ?? null,
495
+ reactVersion: rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null,
376
496
  framework: detectFramework(allDependencies)
377
497
  };
378
498
  };
379
499
  const parsePnpmWorkspacePatterns = (rootDirectory) => {
380
500
  const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
381
- if (!fs.existsSync(workspacePath)) return [];
501
+ if (!isFile(workspacePath)) return [];
382
502
  const content = fs.readFileSync(workspacePath, "utf-8");
383
503
  const patterns = [];
384
504
  let isInsidePackagesBlock = false;
@@ -404,14 +524,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
404
524
  const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
405
525
  if (!cleanPattern.includes("*")) {
406
526
  const directoryPath = path.join(rootDirectory, cleanPattern);
407
- if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
527
+ if (fs.existsSync(directoryPath) && isFile(path.join(directoryPath, "package.json"))) return [directoryPath];
408
528
  return [];
409
529
  }
410
530
  const wildcardIndex = cleanPattern.indexOf("*");
411
531
  const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
412
532
  const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
413
533
  if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
414
- 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")));
534
+ 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")));
415
535
  };
416
536
  const findDependencyInfoFromMonorepoRoot = (directory) => {
417
537
  const monorepoRoot = findMonorepoRoot(directory);
@@ -419,11 +539,17 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
419
539
  reactVersion: null,
420
540
  framework: "unknown"
421
541
  };
422
- const rootPackageJson = readPackageJson(path.join(monorepoRoot, "package.json"));
542
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
543
+ if (!isFile(monorepoPackageJsonPath)) return {
544
+ reactVersion: null,
545
+ framework: "unknown"
546
+ };
547
+ const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
423
548
  const rootInfo = extractDependencyInfo(rootPackageJson);
549
+ const catalogVersion = resolveCatalogVersion(rootPackageJson, "react");
424
550
  const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
425
551
  return {
426
- reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
552
+ reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
427
553
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
428
554
  };
429
555
  };
@@ -444,19 +570,35 @@ const findReactInWorkspaces = (rootDirectory, packageJson) => {
444
570
  }
445
571
  return result;
446
572
  };
573
+ const REACT_DEPENDENCY_NAMES = new Set([
574
+ "react",
575
+ "react-native",
576
+ "next"
577
+ ]);
447
578
  const hasReactDependency = (packageJson) => {
448
579
  const allDependencies = collectAllDependencies(packageJson);
449
- return Object.keys(allDependencies).some((packageName) => packageName === "next" || packageName.includes("react"));
580
+ return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
450
581
  };
451
582
  const discoverReactSubprojects = (rootDirectory) => {
452
583
  if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
453
- const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
454
584
  const packages = [];
585
+ const rootPackageJsonPath = path.join(rootDirectory, "package.json");
586
+ if (isFile(rootPackageJsonPath)) {
587
+ const rootPackageJson = readPackageJson(rootPackageJsonPath);
588
+ if (hasReactDependency(rootPackageJson)) {
589
+ const name = rootPackageJson.name ?? path.basename(rootDirectory);
590
+ packages.push({
591
+ name,
592
+ directory: rootDirectory
593
+ });
594
+ }
595
+ }
596
+ const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
455
597
  for (const entry of entries) {
456
598
  if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
457
599
  const subdirectory = path.join(rootDirectory, entry.name);
458
600
  const packageJsonPath = path.join(subdirectory, "package.json");
459
- if (!fs.existsSync(packageJsonPath)) continue;
601
+ if (!isFile(packageJsonPath)) continue;
460
602
  const packageJson = readPackageJson(packageJsonPath);
461
603
  if (!hasReactDependency(packageJson)) continue;
462
604
  const name = packageJson.name ?? entry.name;
@@ -469,7 +611,7 @@ const discoverReactSubprojects = (rootDirectory) => {
469
611
  };
470
612
  const listWorkspacePackages = (rootDirectory) => {
471
613
  const packageJsonPath = path.join(rootDirectory, "package.json");
472
- if (!fs.existsSync(packageJsonPath)) return [];
614
+ if (!isFile(packageJsonPath)) return [];
473
615
  const patterns = getWorkspacePatterns(rootDirectory, readPackageJson(packageJsonPath));
474
616
  if (patterns.length === 0) return [];
475
617
  const packages = [];
@@ -492,7 +634,7 @@ const hasCompilerPackage = (packageJson) => {
492
634
  return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
493
635
  };
494
636
  const fileContainsPattern = (filePath, pattern) => {
495
- if (!fs.existsSync(filePath)) return false;
637
+ if (!isFile(filePath)) return false;
496
638
  const content = fs.readFileSync(filePath, "utf-8");
497
639
  return pattern.test(content);
498
640
  };
@@ -506,7 +648,7 @@ const detectReactCompiler = (directory, packageJson) => {
506
648
  let ancestorDirectory = path.dirname(directory);
507
649
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
508
650
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
509
- if (fs.existsSync(ancestorPackagePath)) {
651
+ if (isFile(ancestorPackagePath)) {
510
652
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
511
653
  }
512
654
  ancestorDirectory = path.dirname(ancestorDirectory);
@@ -515,9 +657,10 @@ const detectReactCompiler = (directory, packageJson) => {
515
657
  };
516
658
  const discoverProject = (directory) => {
517
659
  const packageJsonPath = path.join(directory, "package.json");
518
- if (!fs.existsSync(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
660
+ if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
519
661
  const packageJson = readPackageJson(packageJsonPath);
520
662
  let { reactVersion, framework } = extractDependencyInfo(packageJson);
663
+ if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react");
521
664
  if (!reactVersion || framework === "unknown") {
522
665
  const workspaceInfo = findReactInWorkspaces(directory, packageJson);
523
666
  if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
@@ -617,23 +760,18 @@ const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText
617
760
  //#region src/utils/load-config.ts
618
761
  const CONFIG_FILENAME = "react-doctor.config.json";
619
762
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
620
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
621
763
  const loadConfig = (rootDirectory) => {
622
764
  const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
623
- if (fs.existsSync(configFilePath)) try {
765
+ if (isFile(configFilePath)) try {
624
766
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
625
767
  const parsed = JSON.parse(fileContent);
626
- if (!isPlainObject(parsed)) {
627
- console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
628
- return null;
629
- }
630
- return parsed;
768
+ if (isPlainObject(parsed)) return parsed;
769
+ console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
631
770
  } catch (error) {
632
771
  console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
633
- return null;
634
772
  }
635
773
  const packageJsonPath = path.join(rootDirectory, "package.json");
636
- if (fs.existsSync(packageJsonPath)) try {
774
+ if (isFile(packageJsonPath)) try {
637
775
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
638
776
  const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
639
777
  if (isPlainObject(embeddedConfig)) return embeddedConfig;
@@ -862,11 +1000,15 @@ const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
862
1000
  const extractFailedPluginName = (error) => {
863
1001
  return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
864
1002
  };
1003
+ const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
1004
+ const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
865
1005
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
1006
+ const tsConfigFile = resolveTsConfigFile(knipCwd);
866
1007
  const options = await silenced(() => createOptions({
867
1008
  cwd: knipCwd,
868
1009
  isShowProgress: false,
869
- ...workspaceName ? { workspace: workspaceName } : {}
1010
+ ...workspaceName ? { workspace: workspaceName } : {},
1011
+ ...tsConfigFile ? { tsConfigFile } : {}
870
1012
  }));
871
1013
  const parsedConfig = options.parsedConfig;
872
1014
  for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
@@ -888,7 +1030,7 @@ const runKnip = async (rootDirectory) => {
888
1030
  let knipResult;
889
1031
  if (monorepoRoot) {
890
1032
  const packageJsonPath = path.join(rootDirectory, "package.json");
891
- const workspaceName = (fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
1033
+ const workspaceName = (isFile(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
892
1034
  try {
893
1035
  knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
894
1036
  } catch {
@@ -938,6 +1080,16 @@ const NEXTJS_RULES = {
938
1080
  "react-doctor/nextjs-no-head-import": "error",
939
1081
  "react-doctor/nextjs-no-side-effect-in-get-handler": "error"
940
1082
  };
1083
+ const REACT_NATIVE_RULES = {
1084
+ "react-doctor/rn-no-raw-text": "error",
1085
+ "react-doctor/rn-no-deprecated-modules": "error",
1086
+ "react-doctor/rn-no-legacy-expo-packages": "warn",
1087
+ "react-doctor/rn-no-dimensions-get": "warn",
1088
+ "react-doctor/rn-no-inline-flatlist-renderitem": "warn",
1089
+ "react-doctor/rn-no-legacy-shadow-styles": "warn",
1090
+ "react-doctor/rn-prefer-reanimated": "warn",
1091
+ "react-doctor/rn-no-single-element-style-array": "warn"
1092
+ };
941
1093
  const REACT_COMPILER_RULES = {
942
1094
  "react-hooks-js/set-state-in-render": "error",
943
1095
  "react-hooks-js/immutability": "error",
@@ -1041,7 +1193,8 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
1041
1193
  "react-doctor/server-after-nonblocking": "warn",
1042
1194
  "react-doctor/client-passive-event-listeners": "warn",
1043
1195
  "react-doctor/async-parallel": "warn",
1044
- ...framework === "nextjs" ? NEXTJS_RULES : {}
1196
+ ...framework === "nextjs" ? NEXTJS_RULES : {},
1197
+ ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}
1045
1198
  }
1046
1199
  });
1047
1200
 
@@ -1149,10 +1302,18 @@ const RULE_CATEGORY_MAP = {
1149
1302
  "react-doctor/server-auth-actions": "Server",
1150
1303
  "react-doctor/server-after-nonblocking": "Server",
1151
1304
  "react-doctor/client-passive-event-listeners": "Performance",
1152
- "react-doctor/async-parallel": "Performance"
1305
+ "react-doctor/async-parallel": "Performance",
1306
+ "react-doctor/rn-no-raw-text": "React Native",
1307
+ "react-doctor/rn-no-deprecated-modules": "React Native",
1308
+ "react-doctor/rn-no-legacy-expo-packages": "React Native",
1309
+ "react-doctor/rn-no-dimensions-get": "React Native",
1310
+ "react-doctor/rn-no-inline-flatlist-renderitem": "React Native",
1311
+ "react-doctor/rn-no-legacy-shadow-styles": "React Native",
1312
+ "react-doctor/rn-prefer-reanimated": "React Native",
1313
+ "react-doctor/rn-no-single-element-style-array": "React Native"
1153
1314
  };
1154
1315
  const RULE_HELP_MAP = {
1155
- "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} />`",
1316
+ "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",
1156
1317
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
1157
1318
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
1158
1319
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
@@ -1192,7 +1353,7 @@ const RULE_HELP_MAP = {
1192
1353
  "nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
1193
1354
  "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",
1194
1355
  "nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
1195
- "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' in a Server Component, or handle in middleware",
1356
+ "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' directly (works in both server and client components), or handle in middleware",
1196
1357
  "nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
1197
1358
  "nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
1198
1359
  "nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
@@ -1205,7 +1366,15 @@ const RULE_HELP_MAP = {
1205
1366
  "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
1206
1367
  "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
1207
1368
  "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
1208
- "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently"
1369
+ "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
1370
+ "rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
1371
+ "rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
1372
+ "rn-no-legacy-expo-packages": "Migrate to the recommended replacement package — legacy Expo packages are no longer maintained",
1373
+ "rn-no-dimensions-get": "Use `const { width, height } = useWindowDimensions()` — it updates reactively on rotation and resize",
1374
+ "rn-no-inline-flatlist-renderitem": "Extract renderItem to a named function or wrap in useCallback to avoid re-creating on every render",
1375
+ "rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
1376
+ "rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
1377
+ "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation"
1209
1378
  };
1210
1379
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
1211
1380
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
@@ -1272,7 +1441,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
1272
1441
  child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1273
1442
  child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1274
1443
  child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1275
- child.on("close", () => {
1444
+ child.on("close", (code, signal) => {
1445
+ if (signal) {
1446
+ const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1447
+ const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
1448
+ const detail = stderrOutput ? `: ${stderrOutput}` : "";
1449
+ reject(/* @__PURE__ */ new Error(`oxlint was killed by ${signal}${hint}${detail}`));
1450
+ return;
1451
+ }
1276
1452
  const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
1277
1453
  if (!output) {
1278
1454
  const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
@@ -1543,7 +1719,7 @@ const buildCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMillis
1543
1719
  renderedParts.push(highlighter.dim(fileCountText), highlighter.dim(elapsedTimeText));
1544
1720
  return createFramedLine(plainParts.join(" "), renderedParts.join(" "));
1545
1721
  };
1546
- const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage) => {
1722
+ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName, totalSourceFileCount, noScoreMessage, isOffline) => {
1547
1723
  printFramedBox([...buildBrandingLines(scoreResult, noScoreMessage), buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds)]);
1548
1724
  try {
1549
1725
  const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
@@ -1552,9 +1728,11 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1552
1728
  } catch {
1553
1729
  logger.break();
1554
1730
  }
1555
- const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
1556
- logger.break();
1557
- logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1731
+ if (!isOffline) {
1732
+ const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
1733
+ logger.break();
1734
+ logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1735
+ }
1558
1736
  };
1559
1737
  const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
1560
1738
  if (!isLintEnabled) return null;
@@ -1639,13 +1817,15 @@ const scan = async (directory, inputOptions = {}) => {
1639
1817
  return lintDiagnostics;
1640
1818
  } catch (error) {
1641
1819
  didLintFail = true;
1642
- const errorMessage = error instanceof Error ? error.message : String(error);
1643
- if (errorMessage.includes("native binding")) {
1644
- lintSpinner?.fail(`Lint checks failed — oxlint native binding not found (Node ${process.version}).`);
1645
- logger.dim(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`);
1646
- } else {
1647
- lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
1648
- logger.error(errorMessage);
1820
+ if (!options.scoreOnly) {
1821
+ const errorMessage = error instanceof Error ? error.message : String(error);
1822
+ if (errorMessage.includes("native binding")) {
1823
+ lintSpinner?.fail(`Lint checks failed oxlint native binding not found (Node ${process.version}).`);
1824
+ logger.dim(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`);
1825
+ } else {
1826
+ lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
1827
+ logger.error(errorMessage);
1828
+ }
1649
1829
  }
1650
1830
  return [];
1651
1831
  }
@@ -1658,8 +1838,10 @@ const scan = async (directory, inputOptions = {}) => {
1658
1838
  return knipDiagnostics;
1659
1839
  } catch (error) {
1660
1840
  didDeadCodeFail = true;
1661
- deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
1662
- logger.error(String(error));
1841
+ if (!options.scoreOnly) {
1842
+ deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
1843
+ logger.error(String(error));
1844
+ }
1663
1845
  return [];
1664
1846
  }
1665
1847
  })() : Promise.resolve([]);
@@ -1670,8 +1852,8 @@ const scan = async (directory, inputOptions = {}) => {
1670
1852
  if (didLintFail) skippedChecks.push("lint");
1671
1853
  if (didDeadCodeFail) skippedChecks.push("dead code");
1672
1854
  const hasSkippedChecks = skippedChecks.length > 0;
1673
- const scoreResult = options.offline ? null : await calculateScore(diagnostics);
1674
- const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
1855
+ const scoreResult = options.offline ? calculateScoreLocally(diagnostics) : await calculateScore(diagnostics);
1856
+ const noScoreMessage = OFFLINE_MESSAGE;
1675
1857
  if (options.scoreOnly) {
1676
1858
  if (scoreResult) logger.log(`${scoreResult.score}`);
1677
1859
  else logger.dim(noScoreMessage);
@@ -1702,7 +1884,7 @@ const scan = async (directory, inputOptions = {}) => {
1702
1884
  }
1703
1885
  printDiagnostics(diagnostics, options.verbose);
1704
1886
  const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;
1705
- printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage);
1887
+ printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName, displayedSourceFileCount, noScoreMessage, options.offline);
1706
1888
  if (hasSkippedChecks) {
1707
1889
  const skippedLabel = skippedChecks.join(" and ");
1708
1890
  logger.break();
@@ -2029,7 +2211,18 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
2029
2211
 
2030
2212
  //#endregion
2031
2213
  //#region src/cli.ts
2032
- const VERSION = "0.0.28";
2214
+ const VERSION = "0.0.30";
2215
+ const VALID_FAIL_ON_LEVELS = new Set([
2216
+ "error",
2217
+ "warning",
2218
+ "none"
2219
+ ]);
2220
+ const isValidFailOnLevel = (level) => VALID_FAIL_ON_LEVELS.has(level);
2221
+ const shouldFailForDiagnostics = (diagnostics, failOnLevel) => {
2222
+ if (failOnLevel === "none") return false;
2223
+ if (failOnLevel === "warning") return diagnostics.length > 0;
2224
+ return diagnostics.some((diagnostic) => diagnostic.severity === "error");
2225
+ };
2033
2226
  const exitWithFixHint = () => {
2034
2227
  logger.break();
2035
2228
  logger.log("Cancelled.");
@@ -2052,8 +2245,8 @@ const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVa
2052
2245
  const resolveCliScanOptions = (flags, userConfig, programInstance) => {
2053
2246
  const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
2054
2247
  return {
2055
- lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
2056
- deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
2248
+ lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? true,
2249
+ deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? true,
2057
2250
  verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
2058
2251
  scoreOnly: flags.score,
2059
2252
  offline: flags.offline
@@ -2081,7 +2274,7 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
2081
2274
  });
2082
2275
  return Boolean(shouldScanChangedOnly);
2083
2276
  };
2084
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--no-ami", "skip Ami-related prompts").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
2277
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--ami", "enable Ami-related prompts").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
2085
2278
  const isScoreOnly = flags.score;
2086
2279
  try {
2087
2280
  const resolvedDirectory = path.resolve(directory);
@@ -2131,6 +2324,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2131
2324
  allDiagnostics.push(...scanResult.diagnostics);
2132
2325
  if (!isScoreOnly) logger.break();
2133
2326
  }
2327
+ const resolvedFailOn = program.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
2328
+ if (shouldFailForDiagnostics(allDiagnostics, isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none")) process.exitCode = 1;
2134
2329
  if (flags.fix) openAmiToFix(resolvedDirectory);
2135
2330
  if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) {
2136
2331
  await maybePromptSkillInstall(shouldSkipAmiPrompts);
@@ -2193,7 +2388,11 @@ const openAmiToFix = (directory) => {
2193
2388
  if (!isInstalled) {
2194
2389
  if (process.platform === "darwin") {
2195
2390
  installAmi();
2196
- logger.success("Ami installed successfully.");
2391
+ if (isAmiInstalled()) logger.success("Ami installed successfully.");
2392
+ else {
2393
+ logger.error("Installation could not be verified.");
2394
+ logger.dim(`Install manually at ${highlighter.info(AMI_WEBSITE_URL)}`);
2395
+ }
2197
2396
  } else {
2198
2397
  logger.error("Ami is not installed.");
2199
2398
  logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`);