react-doctor 0.0.28 → 0.0.29

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/README.md CHANGED
@@ -48,6 +48,29 @@ curl -fsSL https://react.doctor/install-skill.sh | bash
48
48
 
49
49
  Supports Cursor, Claude Code, Amp Code, Codex, Gemini CLI, OpenCode, Windsurf, and Antigravity.
50
50
 
51
+ ## GitHub Actions
52
+
53
+ ```yaml
54
+ - uses: actions/checkout@v5
55
+ with:
56
+ fetch-depth: 0 # required for --diff
57
+ - uses: millionco/react-doctor@main
58
+ with:
59
+ diff: main
60
+ github-token: ${{ secrets.GITHUB_TOKEN }}
61
+ ```
62
+
63
+ | Input | Default | Description |
64
+ | -------------- | ------- | ----------------------------------------------------------------- |
65
+ | `directory` | `.` | Project directory to scan |
66
+ | `verbose` | `true` | Show file details per rule |
67
+ | `project` | | Workspace project(s) to scan (comma-separated) |
68
+ | `diff` | | Base branch for diff mode. Only changed files are scanned |
69
+ | `github-token` | | When set on `pull_request` events, posts findings as a PR comment |
70
+ | `node-version` | `20` | Node.js version to use |
71
+
72
+ The action outputs a `score` (0–100) you can use in subsequent steps.
73
+
51
74
  ## Options
52
75
 
53
76
  ```
package/dist/cli.js CHANGED
@@ -283,6 +283,7 @@ const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isD
283
283
  //#region src/utils/find-monorepo-root.ts
284
284
  const isMonorepoRoot = (directory) => {
285
285
  if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
286
+ if (fs.existsSync(path.join(directory, "nx.json"))) return true;
286
287
  const packageJsonPath = path.join(directory, "package.json");
287
288
  if (!fs.existsSync(packageJsonPath)) return false;
288
289
  const packageJson = readPackageJson(packageJsonPath);
@@ -335,7 +336,9 @@ const FRAMEWORK_PACKAGES = {
335
336
  vite: "vite",
336
337
  "react-scripts": "cra",
337
338
  "@remix-run/react": "remix",
338
- gatsby: "gatsby"
339
+ gatsby: "gatsby",
340
+ expo: "expo",
341
+ "react-native": "react-native"
339
342
  };
340
343
  const FRAMEWORK_DISPLAY_NAMES = {
341
344
  nextjs: "Next.js",
@@ -343,10 +346,34 @@ const FRAMEWORK_DISPLAY_NAMES = {
343
346
  cra: "Create React App",
344
347
  remix: "Remix",
345
348
  gatsby: "Gatsby",
349
+ expo: "Expo",
350
+ "react-native": "React Native",
346
351
  unknown: "React"
347
352
  };
348
353
  const formatFrameworkName = (framework) => FRAMEWORK_DISPLAY_NAMES[framework];
349
- const countSourceFiles = (rootDirectory) => {
354
+ const IGNORED_DIRECTORIES = new Set([
355
+ "node_modules",
356
+ "dist",
357
+ "build",
358
+ "coverage"
359
+ ]);
360
+ const countSourceFilesViaFilesystem = (rootDirectory) => {
361
+ let count = 0;
362
+ const stack = [rootDirectory];
363
+ while (stack.length > 0) {
364
+ const currentDirectory = stack.pop();
365
+ const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
366
+ for (const entry of entries) {
367
+ if (entry.isDirectory()) {
368
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
369
+ continue;
370
+ }
371
+ if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
372
+ }
373
+ }
374
+ return count;
375
+ };
376
+ const countSourceFilesViaGit = (rootDirectory) => {
350
377
  const result = spawnSync("git", [
351
378
  "ls-files",
352
379
  "--cached",
@@ -357,9 +384,10 @@ const countSourceFiles = (rootDirectory) => {
357
384
  encoding: "utf-8",
358
385
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
359
386
  });
360
- if (result.error || result.status !== 0) return 0;
387
+ if (result.error || result.status !== 0) return null;
361
388
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
362
389
  };
390
+ const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
363
391
  const collectAllDependencies = (packageJson) => ({
364
392
  ...packageJson.peerDependencies,
365
393
  ...packageJson.dependencies,
@@ -444,14 +472,30 @@ const findReactInWorkspaces = (rootDirectory, packageJson) => {
444
472
  }
445
473
  return result;
446
474
  };
475
+ const REACT_DEPENDENCY_NAMES = new Set([
476
+ "react",
477
+ "react-native",
478
+ "next"
479
+ ]);
447
480
  const hasReactDependency = (packageJson) => {
448
481
  const allDependencies = collectAllDependencies(packageJson);
449
- return Object.keys(allDependencies).some((packageName) => packageName === "next" || packageName.includes("react"));
482
+ return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
450
483
  };
451
484
  const discoverReactSubprojects = (rootDirectory) => {
452
485
  if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
453
- const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
454
486
  const packages = [];
487
+ const rootPackageJsonPath = path.join(rootDirectory, "package.json");
488
+ if (fs.existsSync(rootPackageJsonPath)) {
489
+ const rootPackageJson = readPackageJson(rootPackageJsonPath);
490
+ if (hasReactDependency(rootPackageJson)) {
491
+ const name = rootPackageJson.name ?? path.basename(rootDirectory);
492
+ packages.push({
493
+ name,
494
+ directory: rootDirectory
495
+ });
496
+ }
497
+ }
498
+ const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
455
499
  for (const entry of entries) {
456
500
  if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
457
501
  const subdirectory = path.join(rootDirectory, entry.name);
@@ -623,14 +667,10 @@ const loadConfig = (rootDirectory) => {
623
667
  if (fs.existsSync(configFilePath)) try {
624
668
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
625
669
  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;
670
+ if (isPlainObject(parsed)) return parsed;
671
+ console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
631
672
  } catch (error) {
632
673
  console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
633
- return null;
634
674
  }
635
675
  const packageJsonPath = path.join(rootDirectory, "package.json");
636
676
  if (fs.existsSync(packageJsonPath)) try {
@@ -862,11 +902,15 @@ const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
862
902
  const extractFailedPluginName = (error) => {
863
903
  return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
864
904
  };
905
+ const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
906
+ const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
865
907
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
908
+ const tsConfigFile = resolveTsConfigFile(knipCwd);
866
909
  const options = await silenced(() => createOptions({
867
910
  cwd: knipCwd,
868
911
  isShowProgress: false,
869
- ...workspaceName ? { workspace: workspaceName } : {}
912
+ ...workspaceName ? { workspace: workspaceName } : {},
913
+ ...tsConfigFile ? { tsConfigFile } : {}
870
914
  }));
871
915
  const parsedConfig = options.parsedConfig;
872
916
  for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
@@ -938,6 +982,16 @@ const NEXTJS_RULES = {
938
982
  "react-doctor/nextjs-no-head-import": "error",
939
983
  "react-doctor/nextjs-no-side-effect-in-get-handler": "error"
940
984
  };
985
+ const REACT_NATIVE_RULES = {
986
+ "react-doctor/rn-no-raw-text": "error",
987
+ "react-doctor/rn-no-deprecated-modules": "error",
988
+ "react-doctor/rn-no-legacy-expo-packages": "warn",
989
+ "react-doctor/rn-no-dimensions-get": "warn",
990
+ "react-doctor/rn-no-inline-flatlist-renderitem": "warn",
991
+ "react-doctor/rn-no-legacy-shadow-styles": "warn",
992
+ "react-doctor/rn-prefer-reanimated": "warn",
993
+ "react-doctor/rn-no-single-element-style-array": "warn"
994
+ };
941
995
  const REACT_COMPILER_RULES = {
942
996
  "react-hooks-js/set-state-in-render": "error",
943
997
  "react-hooks-js/immutability": "error",
@@ -1041,7 +1095,8 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
1041
1095
  "react-doctor/server-after-nonblocking": "warn",
1042
1096
  "react-doctor/client-passive-event-listeners": "warn",
1043
1097
  "react-doctor/async-parallel": "warn",
1044
- ...framework === "nextjs" ? NEXTJS_RULES : {}
1098
+ ...framework === "nextjs" ? NEXTJS_RULES : {},
1099
+ ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}
1045
1100
  }
1046
1101
  });
1047
1102
 
@@ -1149,7 +1204,15 @@ const RULE_CATEGORY_MAP = {
1149
1204
  "react-doctor/server-auth-actions": "Server",
1150
1205
  "react-doctor/server-after-nonblocking": "Server",
1151
1206
  "react-doctor/client-passive-event-listeners": "Performance",
1152
- "react-doctor/async-parallel": "Performance"
1207
+ "react-doctor/async-parallel": "Performance",
1208
+ "react-doctor/rn-no-raw-text": "React Native",
1209
+ "react-doctor/rn-no-deprecated-modules": "React Native",
1210
+ "react-doctor/rn-no-legacy-expo-packages": "React Native",
1211
+ "react-doctor/rn-no-dimensions-get": "React Native",
1212
+ "react-doctor/rn-no-inline-flatlist-renderitem": "React Native",
1213
+ "react-doctor/rn-no-legacy-shadow-styles": "React Native",
1214
+ "react-doctor/rn-prefer-reanimated": "React Native",
1215
+ "react-doctor/rn-no-single-element-style-array": "React Native"
1153
1216
  };
1154
1217
  const RULE_HELP_MAP = {
1155
1218
  "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} />`",
@@ -1205,7 +1268,15 @@ const RULE_HELP_MAP = {
1205
1268
  "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
1206
1269
  "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
1207
1270
  "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"
1271
+ "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
1272
+ "rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
1273
+ "rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
1274
+ "rn-no-legacy-expo-packages": "Migrate to the recommended replacement package — legacy Expo packages are no longer maintained",
1275
+ "rn-no-dimensions-get": "Use `const { width, height } = useWindowDimensions()` — it updates reactively on rotation and resize",
1276
+ "rn-no-inline-flatlist-renderitem": "Extract renderItem to a named function or wrap in useCallback to avoid re-creating on every render",
1277
+ "rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
1278
+ "rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
1279
+ "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation"
1209
1280
  };
1210
1281
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
1211
1282
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
@@ -1639,13 +1710,15 @@ const scan = async (directory, inputOptions = {}) => {
1639
1710
  return lintDiagnostics;
1640
1711
  } catch (error) {
1641
1712
  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);
1713
+ if (!options.scoreOnly) {
1714
+ const errorMessage = error instanceof Error ? error.message : String(error);
1715
+ if (errorMessage.includes("native binding")) {
1716
+ lintSpinner?.fail(`Lint checks failed oxlint native binding not found (Node ${process.version}).`);
1717
+ logger.dim(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`);
1718
+ } else {
1719
+ lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
1720
+ logger.error(errorMessage);
1721
+ }
1649
1722
  }
1650
1723
  return [];
1651
1724
  }
@@ -1658,8 +1731,10 @@ const scan = async (directory, inputOptions = {}) => {
1658
1731
  return knipDiagnostics;
1659
1732
  } catch (error) {
1660
1733
  didDeadCodeFail = true;
1661
- deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
1662
- logger.error(String(error));
1734
+ if (!options.scoreOnly) {
1735
+ deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
1736
+ logger.error(String(error));
1737
+ }
1663
1738
  return [];
1664
1739
  }
1665
1740
  })() : Promise.resolve([]);
@@ -2029,7 +2104,18 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
2029
2104
 
2030
2105
  //#endregion
2031
2106
  //#region src/cli.ts
2032
- const VERSION = "0.0.28";
2107
+ const VERSION = "0.0.29";
2108
+ const VALID_FAIL_ON_LEVELS = new Set([
2109
+ "error",
2110
+ "warning",
2111
+ "none"
2112
+ ]);
2113
+ const isValidFailOnLevel = (level) => VALID_FAIL_ON_LEVELS.has(level);
2114
+ const shouldFailForDiagnostics = (diagnostics, failOnLevel) => {
2115
+ if (failOnLevel === "none") return false;
2116
+ if (failOnLevel === "warning") return diagnostics.length > 0;
2117
+ return diagnostics.some((diagnostic) => diagnostic.severity === "error");
2118
+ };
2033
2119
  const exitWithFixHint = () => {
2034
2120
  logger.break();
2035
2121
  logger.log("Cancelled.");
@@ -2052,8 +2138,8 @@ const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVa
2052
2138
  const resolveCliScanOptions = (flags, userConfig, programInstance) => {
2053
2139
  const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
2054
2140
  return {
2055
- lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
2056
- deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
2141
+ lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? true,
2142
+ deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? true,
2057
2143
  verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
2058
2144
  scoreOnly: flags.score,
2059
2145
  offline: flags.offline
@@ -2081,7 +2167,7 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
2081
2167
  });
2082
2168
  return Boolean(shouldScanChangedOnly);
2083
2169
  };
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) => {
2170
+ 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("--no-ami", "skip 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
2171
  const isScoreOnly = flags.score;
2086
2172
  try {
2087
2173
  const resolvedDirectory = path.resolve(directory);
@@ -2131,6 +2217,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2131
2217
  allDiagnostics.push(...scanResult.diagnostics);
2132
2218
  if (!isScoreOnly) logger.break();
2133
2219
  }
2220
+ const resolvedFailOn = program.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
2221
+ if (shouldFailForDiagnostics(allDiagnostics, isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none")) process.exitCode = 1;
2134
2222
  if (flags.fix) openAmiToFix(resolvedDirectory);
2135
2223
  if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) {
2136
2224
  await maybePromptSkillInstall(shouldSkipAmiPrompts);
@@ -2193,7 +2281,11 @@ const openAmiToFix = (directory) => {
2193
2281
  if (!isInstalled) {
2194
2282
  if (process.platform === "darwin") {
2195
2283
  installAmi();
2196
- logger.success("Ami installed successfully.");
2284
+ if (isAmiInstalled()) logger.success("Ami installed successfully.");
2285
+ else {
2286
+ logger.error("Installation could not be verified.");
2287
+ logger.dim(`Install manually at ${highlighter.info(AMI_WEBSITE_URL)}`);
2288
+ }
2197
2289
  } else {
2198
2290
  logger.error("Ami is not installed.");
2199
2291
  logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`);