react-doctor 0.0.18 → 0.0.20

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
@@ -25,11 +25,32 @@ interface ScoreResult {
25
25
  score: number;
26
26
  label: string;
27
27
  }
28
+ interface DiffInfo {
29
+ currentBranch: string;
30
+ baseBranch: string;
31
+ changedFiles: string[];
32
+ }
33
+ interface ReactDoctorIgnoreConfig {
34
+ rules?: string[];
35
+ files?: string[];
36
+ }
37
+ interface ReactDoctorConfig {
38
+ ignore?: ReactDoctorIgnoreConfig;
39
+ lint?: boolean;
40
+ deadCode?: boolean;
41
+ verbose?: boolean;
42
+ diff?: boolean | string;
43
+ }
44
+ //#endregion
45
+ //#region src/utils/get-diff-files.d.ts
46
+ declare const getDiffInfo: (directory: string, explicitBaseBranch?: string) => DiffInfo | null;
47
+ declare const filterSourceFiles: (filePaths: string[]) => string[];
28
48
  //#endregion
29
49
  //#region src/index.d.ts
30
50
  interface DiagnoseOptions {
31
51
  lint?: boolean;
32
52
  deadCode?: boolean;
53
+ includePaths?: string[];
33
54
  }
34
55
  interface DiagnoseResult {
35
56
  diagnostics: Diagnostic[];
@@ -39,5 +60,5 @@ interface DiagnoseResult {
39
60
  }
40
61
  declare const diagnose: (directory: string, options?: DiagnoseOptions) => Promise<DiagnoseResult>;
41
62
  //#endregion
42
- export { DiagnoseOptions, DiagnoseResult, type Diagnostic, type ProjectInfo, type ScoreResult, diagnose };
63
+ export { DiagnoseOptions, DiagnoseResult, type Diagnostic, type DiffInfo, type ProjectInfo, type ReactDoctorConfig, type ScoreResult, diagnose, filterSourceFiles, getDiffInfo };
43
64
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.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,UA2Be,WAAA;EACf,KAAA;EACA,KAAA;AAAA;;;UCtEe,eAAA;EACf,IAAA;EACA,QAAA;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,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,UAWe,QAAA;EACf,aAAA;EACA,UAAA;EACA,YAAA;AAAA;AAAA,UAqDe,uBAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EACf,MAAA,GAAS,uBAAA;EACT,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;AAAA;;;cC/FW,WAAA,GAAe,SAAA,UAAmB,kBAAA,cAA8B,QAAA;AAAA,cAYhE,iBAAA,GAAqB,SAAA;;;UC7DjB,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
@@ -14,6 +14,7 @@ const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
14
14
  const ERROR_PREVIEW_LENGTH_CHARS = 200;
15
15
  const SCORE_API_URL = "https://www.react.doctor/api/score";
16
16
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
17
+ const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
17
18
 
18
19
  //#endregion
19
20
  //#region src/utils/calculate-score.ts
@@ -136,6 +137,7 @@ const countSourceFiles = (rootDirectory) => {
136
137
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
137
138
  };
138
139
  const collectAllDependencies = (packageJson) => ({
140
+ ...packageJson.peerDependencies,
139
141
  ...packageJson.dependencies,
140
142
  ...packageJson.devDependencies
141
143
  });
@@ -286,6 +288,79 @@ const discoverProject = (directory) => {
286
288
  };
287
289
  };
288
290
 
291
+ //#endregion
292
+ //#region src/utils/match-glob-pattern.ts
293
+ const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
294
+ const compileGlobPattern = (pattern) => {
295
+ const normalizedPattern = pattern.replace(/\\/g, "/");
296
+ let regexSource = "^";
297
+ let characterIndex = 0;
298
+ while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
299
+ regexSource += "(?:.+/)?";
300
+ characterIndex += 3;
301
+ } else {
302
+ regexSource += ".*";
303
+ characterIndex += 2;
304
+ }
305
+ else if (normalizedPattern[characterIndex] === "*") {
306
+ regexSource += "[^/]*";
307
+ characterIndex++;
308
+ } else if (normalizedPattern[characterIndex] === "?") {
309
+ regexSource += "[^/]";
310
+ characterIndex++;
311
+ } else {
312
+ regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
313
+ characterIndex++;
314
+ }
315
+ regexSource += "$";
316
+ return new RegExp(regexSource);
317
+ };
318
+
319
+ //#endregion
320
+ //#region src/utils/filter-diagnostics.ts
321
+ const filterIgnoredDiagnostics = (diagnostics, config) => {
322
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
323
+ const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
324
+ if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
325
+ return diagnostics.filter((diagnostic) => {
326
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
327
+ if (ignoredRules.has(ruleIdentifier)) return false;
328
+ const normalizedPath = diagnostic.filePath.replace(/\\/g, "/");
329
+ if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
330
+ return true;
331
+ });
332
+ };
333
+
334
+ //#endregion
335
+ //#region src/utils/load-config.ts
336
+ const CONFIG_FILENAME = "react-doctor.config.json";
337
+ const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
338
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
339
+ const loadConfig = (rootDirectory) => {
340
+ const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
341
+ if (fs.existsSync(configFilePath)) try {
342
+ const fileContent = fs.readFileSync(configFilePath, "utf-8");
343
+ const parsed = JSON.parse(fileContent);
344
+ if (!isPlainObject(parsed)) {
345
+ console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
346
+ return null;
347
+ }
348
+ return parsed;
349
+ } catch (error) {
350
+ console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
351
+ return null;
352
+ }
353
+ const packageJsonPath = path.join(rootDirectory, "package.json");
354
+ if (fs.existsSync(packageJsonPath)) try {
355
+ const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
356
+ const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
357
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
358
+ } catch {
359
+ return null;
360
+ }
361
+ return null;
362
+ };
363
+
289
364
  //#endregion
290
365
  //#region src/utils/run-knip.ts
291
366
  const KNIP_CATEGORY_MAP = {
@@ -541,6 +616,46 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
541
616
  }
542
617
  });
543
618
 
619
+ //#endregion
620
+ //#region src/utils/neutralize-disable-directives.ts
621
+ const findFilesWithDisableDirectives = (rootDirectory) => {
622
+ const result = spawnSync("git", [
623
+ "grep",
624
+ "-l",
625
+ "--untracked",
626
+ "-E",
627
+ "(eslint|oxlint)-disable"
628
+ ], {
629
+ cwd: rootDirectory,
630
+ encoding: "utf-8",
631
+ maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
632
+ });
633
+ if (result.error || result.status === null) return [];
634
+ return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
635
+ };
636
+ const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
637
+ const neutralizeDisableDirectives = (rootDirectory) => {
638
+ const filePaths = findFilesWithDisableDirectives(rootDirectory);
639
+ const originalContents = /* @__PURE__ */ new Map();
640
+ for (const relativePath of filePaths) {
641
+ const absolutePath = path.join(rootDirectory, relativePath);
642
+ let originalContent;
643
+ try {
644
+ originalContent = fs.readFileSync(absolutePath, "utf-8");
645
+ } catch {
646
+ continue;
647
+ }
648
+ const neutralizedContent = neutralizeContent(originalContent);
649
+ if (neutralizedContent !== originalContent) {
650
+ originalContents.set(absolutePath, originalContent);
651
+ fs.writeFileSync(absolutePath, neutralizedContent);
652
+ }
653
+ }
654
+ return () => {
655
+ for (const [absolutePath, originalContent] of originalContents) fs.writeFileSync(absolutePath, originalContent);
656
+ };
657
+ };
658
+
544
659
  //#endregion
545
660
  //#region src/utils/run-oxlint.ts
546
661
  const esmRequire = createRequire(import.meta.url);
@@ -608,7 +723,7 @@ const RULE_CATEGORY_MAP = {
608
723
  "react-doctor/async-parallel": "Performance"
609
724
  };
610
725
  const RULE_HELP_MAP = {
611
- "no-derived-state-effect": "Compute during render: `const derived = computeFrom(dep1, dep2)` no useEffect needed",
726
+ "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} />`",
612
727
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
613
728
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
614
729
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
@@ -702,13 +817,15 @@ const resolvePluginPath = () => {
702
817
  const resolveDiagnosticCategory = (plugin, rule) => {
703
818
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
704
819
  };
705
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler) => {
820
+ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
821
+ if (includePaths !== void 0 && includePaths.length === 0) return [];
706
822
  const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
707
823
  const config = createOxlintConfig({
708
824
  pluginPath: resolvePluginPath(),
709
825
  framework,
710
826
  hasReactCompiler
711
827
  });
828
+ const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
712
829
  try {
713
830
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
714
831
  const args = [
@@ -719,7 +836,8 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
719
836
  "json"
720
837
  ];
721
838
  if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
722
- args.push(".");
839
+ if (includePaths !== void 0) args.push(...includePaths);
840
+ else args.push(".");
723
841
  const stdout = await new Promise((resolve, reject) => {
724
842
  const child = spawn(process.execPath, args, { cwd: rootDirectory });
725
843
  const stdoutBuffers = [];
@@ -746,7 +864,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
746
864
  } catch {
747
865
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
748
866
  }
749
- return output.diagnostics.filter((diagnostic) => JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
867
+ return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
750
868
  const { plugin, rule } = parseRuleCode(diagnostic.code);
751
869
  const primaryLabel = diagnostic.labels[0];
752
870
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -763,32 +881,98 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
763
881
  };
764
882
  });
765
883
  } finally {
884
+ restoreDisableDirectives();
766
885
  if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
767
886
  }
768
887
  };
769
888
 
889
+ //#endregion
890
+ //#region src/utils/get-diff-files.ts
891
+ const getCurrentBranch = (directory) => {
892
+ try {
893
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
894
+ cwd: directory,
895
+ stdio: "pipe"
896
+ }).toString().trim();
897
+ return branch === "HEAD" ? null : branch;
898
+ } catch {
899
+ return null;
900
+ }
901
+ };
902
+ const detectDefaultBranch = (directory) => {
903
+ try {
904
+ return execSync("git symbolic-ref refs/remotes/origin/HEAD", {
905
+ cwd: directory,
906
+ stdio: "pipe"
907
+ }).toString().trim().replace("refs/remotes/origin/", "");
908
+ } catch {
909
+ for (const candidate of DEFAULT_BRANCH_CANDIDATES) try {
910
+ execSync(`git rev-parse --verify ${candidate}`, {
911
+ cwd: directory,
912
+ stdio: "pipe"
913
+ });
914
+ return candidate;
915
+ } catch {}
916
+ return null;
917
+ }
918
+ };
919
+ const getChangedFilesSinceBranch = (directory, baseBranch) => {
920
+ try {
921
+ const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${execSync(`git merge-base ${baseBranch} HEAD`, {
922
+ cwd: directory,
923
+ stdio: "pipe"
924
+ }).toString().trim()}`, {
925
+ cwd: directory,
926
+ stdio: "pipe"
927
+ }).toString().trim();
928
+ if (!output) return [];
929
+ return output.split("\n").filter(Boolean);
930
+ } catch {
931
+ return [];
932
+ }
933
+ };
934
+ const getDiffInfo = (directory, explicitBaseBranch) => {
935
+ const currentBranch = getCurrentBranch(directory);
936
+ if (!currentBranch) return null;
937
+ const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
938
+ if (!baseBranch) return null;
939
+ if (currentBranch === baseBranch) return null;
940
+ return {
941
+ currentBranch,
942
+ baseBranch,
943
+ changedFiles: getChangedFilesSinceBranch(directory, baseBranch)
944
+ };
945
+ };
946
+ const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
947
+
770
948
  //#endregion
771
949
  //#region src/index.ts
772
950
  const diagnose = async (directory, options = {}) => {
773
- const { lint = true, deadCode = true } = options;
951
+ const { includePaths = [] } = options;
952
+ const isDiffMode = includePaths.length > 0;
774
953
  const startTime = performance.now();
775
954
  const resolvedDirectory = path.resolve(directory);
776
955
  const projectInfo = discoverProject(resolvedDirectory);
956
+ const userConfig = loadConfig(resolvedDirectory);
957
+ const effectiveLint = options.lint ?? userConfig?.lint ?? true;
958
+ const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
777
959
  if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
778
- const lintPromise = lint ? runOxlint(resolvedDirectory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler).catch((error) => {
960
+ const jsxIncludePaths = isDiffMode ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
961
+ const lintPromise = effectiveLint ? runOxlint(resolvedDirectory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths).catch((error) => {
779
962
  console.error("Lint failed:", error);
780
963
  return [];
781
964
  }) : Promise.resolve([]);
782
- const deadCodePromise = deadCode ? runKnip(resolvedDirectory).catch((error) => {
965
+ const deadCodePromise = effectiveDeadCode && !isDiffMode ? runKnip(resolvedDirectory).catch((error) => {
783
966
  console.error("Dead code analysis failed:", error);
784
967
  return [];
785
968
  }) : Promise.resolve([]);
786
969
  const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
787
- const diagnostics = [
970
+ const allDiagnostics = [
788
971
  ...lintDiagnostics,
789
972
  ...deadCodeDiagnostics,
790
- ...checkReducedMotion(resolvedDirectory)
973
+ ...isDiffMode ? [] : checkReducedMotion(resolvedDirectory)
791
974
  ];
975
+ const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
792
976
  const elapsedMilliseconds = performance.now() - startTime;
793
977
  return {
794
978
  diagnostics,
@@ -799,5 +983,5 @@ const diagnose = async (directory, options = {}) => {
799
983
  };
800
984
 
801
985
  //#endregion
802
- export { diagnose };
986
+ export { diagnose, filterSourceFiles, getDiffInfo };
803
987
  //# sourceMappingURL=index.js.map