react-doctor 0.0.17 → 0.0.19

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
  });
@@ -185,23 +187,34 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
185
187
  if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
186
188
  return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry)).filter((entryPath) => fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
187
189
  };
188
- const findDependencyInfoFromAncestors = (startDirectory) => {
190
+ const isMonorepoRoot = (directory) => {
191
+ if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
192
+ const packageJsonPath = path.join(directory, "package.json");
193
+ if (!fs.existsSync(packageJsonPath)) return false;
194
+ const packageJson = readPackageJson(packageJsonPath);
195
+ return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
196
+ };
197
+ const findMonorepoRoot$1 = (startDirectory) => {
189
198
  let currentDirectory = path.dirname(startDirectory);
190
- const result = {
191
- reactVersion: null,
192
- framework: "unknown"
193
- };
194
199
  while (currentDirectory !== path.dirname(currentDirectory)) {
195
- const packageJsonPath = path.join(currentDirectory, "package.json");
196
- if (fs.existsSync(packageJsonPath)) {
197
- const info = extractDependencyInfo(readPackageJson(packageJsonPath));
198
- if (!result.reactVersion && info.reactVersion) result.reactVersion = info.reactVersion;
199
- if (result.framework === "unknown" && info.framework !== "unknown") result.framework = info.framework;
200
- if (result.reactVersion && result.framework !== "unknown") return result;
201
- }
200
+ if (isMonorepoRoot(currentDirectory)) return currentDirectory;
202
201
  currentDirectory = path.dirname(currentDirectory);
203
202
  }
204
- return result;
203
+ return null;
204
+ };
205
+ const findDependencyInfoFromMonorepoRoot = (directory) => {
206
+ const monorepoRoot = findMonorepoRoot$1(directory);
207
+ if (!monorepoRoot) return {
208
+ reactVersion: null,
209
+ framework: "unknown"
210
+ };
211
+ const rootPackageJson = readPackageJson(path.join(monorepoRoot, "package.json"));
212
+ const rootInfo = extractDependencyInfo(rootPackageJson);
213
+ const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
214
+ return {
215
+ reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
216
+ framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
217
+ };
205
218
  };
206
219
  const findReactInWorkspaces = (rootDirectory, packageJson) => {
207
220
  const patterns = getWorkspacePatterns(rootDirectory, packageJson);
@@ -255,10 +268,10 @@ const discoverProject = (directory) => {
255
268
  if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
256
269
  if (framework === "unknown" && workspaceInfo.framework !== "unknown") framework = workspaceInfo.framework;
257
270
  }
258
- if (!reactVersion || framework === "unknown") {
259
- const ancestorInfo = findDependencyInfoFromAncestors(directory);
260
- if (!reactVersion) reactVersion = ancestorInfo.reactVersion;
261
- if (framework === "unknown") framework = ancestorInfo.framework;
271
+ if ((!reactVersion || framework === "unknown") && !isMonorepoRoot(directory)) {
272
+ const monorepoInfo = findDependencyInfoFromMonorepoRoot(directory);
273
+ if (!reactVersion) reactVersion = monorepoInfo.reactVersion;
274
+ if (framework === "unknown") framework = monorepoInfo.framework;
262
275
  }
263
276
  const projectName = packageJson.name ?? path.basename(directory);
264
277
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
@@ -275,6 +288,79 @@ const discoverProject = (directory) => {
275
288
  };
276
289
  };
277
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
+
278
364
  //#endregion
279
365
  //#region src/utils/run-knip.ts
280
366
  const KNIP_CATEGORY_MAP = {
@@ -315,15 +401,18 @@ const silenced = async (fn) => {
315
401
  const originalLog = console.log;
316
402
  const originalInfo = console.info;
317
403
  const originalWarn = console.warn;
404
+ const originalError = console.error;
318
405
  console.log = () => {};
319
406
  console.info = () => {};
320
407
  console.warn = () => {};
408
+ console.error = () => {};
321
409
  try {
322
410
  return await fn();
323
411
  } finally {
324
412
  console.log = originalLog;
325
413
  console.info = originalInfo;
326
414
  console.warn = originalWarn;
415
+ console.error = originalError;
327
416
  }
328
417
  };
329
418
  const findMonorepoRoot = (directory) => {
@@ -339,13 +428,26 @@ const findMonorepoRoot = (directory) => {
339
428
  }
340
429
  return null;
341
430
  };
431
+ const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
432
+ const extractFailedPluginName = (error) => {
433
+ return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
434
+ };
435
+ const MAX_KNIP_RETRIES = 5;
342
436
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
343
437
  const options = await silenced(() => createOptions({
344
438
  cwd: knipCwd,
345
439
  isShowProgress: false,
346
440
  ...workspaceName ? { workspace: workspaceName } : {}
347
441
  }));
348
- return await silenced(() => main(options));
442
+ const parsedConfig = options.parsedConfig;
443
+ for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
444
+ return await silenced(() => main(options));
445
+ } catch (error) {
446
+ const failedPlugin = extractFailedPluginName(error);
447
+ if (!failedPlugin || attempt === MAX_KNIP_RETRIES) throw error;
448
+ parsedConfig[failedPlugin] = false;
449
+ }
450
+ throw new Error("Unreachable");
349
451
  };
350
452
  const hasNodeModules = (directory) => {
351
453
  const nodeModulesPath = path.join(directory, "node_modules");
@@ -675,7 +777,8 @@ const resolvePluginPath = () => {
675
777
  const resolveDiagnosticCategory = (plugin, rule) => {
676
778
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
677
779
  };
678
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler) => {
780
+ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
781
+ if (includePaths !== void 0 && includePaths.length === 0) return [];
679
782
  const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
680
783
  const config = createOxlintConfig({
681
784
  pluginPath: resolvePluginPath(),
@@ -692,7 +795,8 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
692
795
  "json"
693
796
  ];
694
797
  if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
695
- args.push(".");
798
+ if (includePaths !== void 0) args.push(...includePaths);
799
+ else args.push(".");
696
800
  const stdout = await new Promise((resolve, reject) => {
697
801
  const child = spawn(process.execPath, args, { cwd: rootDirectory });
698
802
  const stdoutBuffers = [];
@@ -719,7 +823,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
719
823
  } catch {
720
824
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
721
825
  }
722
- return output.diagnostics.filter((diagnostic) => JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
826
+ return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
723
827
  const { plugin, rule } = parseRuleCode(diagnostic.code);
724
828
  const primaryLabel = diagnostic.labels[0];
725
829
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -740,28 +844,93 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
740
844
  }
741
845
  };
742
846
 
847
+ //#endregion
848
+ //#region src/utils/get-diff-files.ts
849
+ const getCurrentBranch = (directory) => {
850
+ try {
851
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
852
+ cwd: directory,
853
+ stdio: "pipe"
854
+ }).toString().trim();
855
+ return branch === "HEAD" ? null : branch;
856
+ } catch {
857
+ return null;
858
+ }
859
+ };
860
+ const detectDefaultBranch = (directory) => {
861
+ try {
862
+ return execSync("git symbolic-ref refs/remotes/origin/HEAD", {
863
+ cwd: directory,
864
+ stdio: "pipe"
865
+ }).toString().trim().replace("refs/remotes/origin/", "");
866
+ } catch {
867
+ for (const candidate of DEFAULT_BRANCH_CANDIDATES) try {
868
+ execSync(`git rev-parse --verify ${candidate}`, {
869
+ cwd: directory,
870
+ stdio: "pipe"
871
+ });
872
+ return candidate;
873
+ } catch {}
874
+ return null;
875
+ }
876
+ };
877
+ const getChangedFilesSinceBranch = (directory, baseBranch) => {
878
+ try {
879
+ const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${execSync(`git merge-base ${baseBranch} HEAD`, {
880
+ cwd: directory,
881
+ stdio: "pipe"
882
+ }).toString().trim()}`, {
883
+ cwd: directory,
884
+ stdio: "pipe"
885
+ }).toString().trim();
886
+ if (!output) return [];
887
+ return output.split("\n").filter(Boolean);
888
+ } catch {
889
+ return [];
890
+ }
891
+ };
892
+ const getDiffInfo = (directory, explicitBaseBranch) => {
893
+ const currentBranch = getCurrentBranch(directory);
894
+ if (!currentBranch) return null;
895
+ const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
896
+ if (!baseBranch) return null;
897
+ if (currentBranch === baseBranch) return null;
898
+ return {
899
+ currentBranch,
900
+ baseBranch,
901
+ changedFiles: getChangedFilesSinceBranch(directory, baseBranch)
902
+ };
903
+ };
904
+ const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
905
+
743
906
  //#endregion
744
907
  //#region src/index.ts
745
908
  const diagnose = async (directory, options = {}) => {
746
- const { lint = true, deadCode = true } = options;
909
+ const { includePaths = [] } = options;
910
+ const isDiffMode = includePaths.length > 0;
747
911
  const startTime = performance.now();
748
912
  const resolvedDirectory = path.resolve(directory);
749
913
  const projectInfo = discoverProject(resolvedDirectory);
914
+ const userConfig = loadConfig(resolvedDirectory);
915
+ const effectiveLint = options.lint ?? userConfig?.lint ?? true;
916
+ const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
750
917
  if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
751
- const lintPromise = lint ? runOxlint(resolvedDirectory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler).catch((error) => {
918
+ const jsxIncludePaths = isDiffMode ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
919
+ const lintPromise = effectiveLint ? runOxlint(resolvedDirectory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths).catch((error) => {
752
920
  console.error("Lint failed:", error);
753
921
  return [];
754
922
  }) : Promise.resolve([]);
755
- const deadCodePromise = deadCode ? runKnip(resolvedDirectory).catch((error) => {
923
+ const deadCodePromise = effectiveDeadCode && !isDiffMode ? runKnip(resolvedDirectory).catch((error) => {
756
924
  console.error("Dead code analysis failed:", error);
757
925
  return [];
758
926
  }) : Promise.resolve([]);
759
927
  const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
760
- const diagnostics = [
928
+ const allDiagnostics = [
761
929
  ...lintDiagnostics,
762
930
  ...deadCodeDiagnostics,
763
- ...checkReducedMotion(resolvedDirectory)
931
+ ...isDiffMode ? [] : checkReducedMotion(resolvedDirectory)
764
932
  ];
933
+ const diagnostics = userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
765
934
  const elapsedMilliseconds = performance.now() - startTime;
766
935
  return {
767
936
  diagnostics,
@@ -772,5 +941,5 @@ const diagnose = async (directory, options = {}) => {
772
941
  };
773
942
 
774
943
  //#endregion
775
- export { diagnose };
944
+ export { diagnose, filterSourceFiles, getDiffInfo };
776
945
  //# sourceMappingURL=index.js.map