react-doctor 0.0.29 → 0.0.31

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/utils/get-diff-files.ts","../src/index.ts"],"mappings":";KAAY,WAAA;AAAA,KAEA,SAAA;AAAA,UAUK,WAAA;EACf,aAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA,EAAW,SAAA;EACX,aAAA;EACA,gBAAA;EACA,eAAA;AAAA;AAAA,UAiCe,UAAA;EACf,QAAA;EACA,MAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,IAAA;EACA,MAAA;EACA,QAAA;EACA,MAAA;AAAA;AAAA,UA4Be,WAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAyBe,QAAA;EACf,aAAA;EACA,UAAA;EACA,YAAA;EACA,gBAAA;AAAA;AAAA,UA2Ce,uBAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EACf,MAAA,GAAS,uBAAA;EACT,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,MAAA,GAAS,WAAA;AAAA;;;cChGE,WAAA,GAAe,SAAA,UAAmB,kBAAA,cAA8B,QAAA;AAAA,cAiBhE,iBAAA,GAAqB,SAAA;;;UCnFjB,eAAA;EACf,IAAA;EACA,QAAA;EACA,YAAA;AAAA;AAAA,UAGe,cAAA;EACf,WAAA,EAAa,UAAA;EACb,KAAA,EAAO,WAAA;EACP,OAAA,EAAS,WAAA;EACT,mBAAA;AAAA;AAAA,cAGW,QAAA,GACX,SAAA,UACA,OAAA,GAAS,eAAA,KACR,OAAA,CAAQ,cAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/utils/get-diff-files.ts","../src/index.ts"],"mappings":";KAAY,WAAA;AAAA,KAEA,SAAA;AAAA,UAUK,WAAA;EACf,aAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA,EAAW,SAAA;EACX,aAAA;EACA,gBAAA;EACA,eAAA;AAAA;AAAA,UAiCe,UAAA;EACf,QAAA;EACA,MAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,IAAA;EACA,MAAA;EACA,QAAA;EACA,MAAA;AAAA;AAAA,UA4Be,WAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAyBe,QAAA;EACf,aAAA;EACA,UAAA;EACA,YAAA;EACA,gBAAA;AAAA;AAAA,UA2Ce,uBAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EACf,MAAA,GAAS,uBAAA;EACT,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,MAAA,GAAS,WAAA;AAAA;;;cChGE,WAAA,GAAe,SAAA,UAAmB,kBAAA,cAA8B,QAAA;AAAA,cAiBhE,iBAAA,GAAqB,SAAA;;;UClFjB,eAAA;EACf,IAAA;EACA,QAAA;EACA,YAAA;AAAA;AAAA,UAGe,cAAA;EACf,WAAA,EAAa,UAAA;EACb,KAAA,EAAO,WAAA;EACP,OAAA,EAAS,WAAA;EACT,mBAAA;AAAA;AAAA,cAGW,QAAA,GACX,SAAA,UACA,OAAA,GAAS,eAAA,KACR,OAAA,CAAQ,cAAA"}
package/dist/index.js CHANGED
@@ -12,12 +12,25 @@ import { fileURLToPath } from "node:url";
12
12
  const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
13
13
  const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
14
14
  const ERROR_PREVIEW_LENGTH_CHARS = 200;
15
+ const PERFECT_SCORE = 100;
16
+ const SCORE_GOOD_THRESHOLD = 75;
17
+ const SCORE_OK_THRESHOLD = 50;
15
18
  const SCORE_API_URL = "https://www.react.doctor/api/score";
16
19
  const FETCH_TIMEOUT_MS = 1e4;
17
20
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
18
21
  const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
19
22
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
23
+ const ERROR_RULE_PENALTY = 1.5;
24
+ const WARNING_RULE_PENALTY = .75;
25
+ const ERROR_ESTIMATED_FIX_RATE = .85;
26
+ const WARNING_ESTIMATED_FIX_RATE = .8;
20
27
  const MAX_KNIP_RETRIES = 5;
28
+ const IGNORED_DIRECTORIES = new Set([
29
+ "node_modules",
30
+ "dist",
31
+ "build",
32
+ "coverage"
33
+ ]);
21
34
  const AMI_WEBSITE_URL = "https://ami.dev";
22
35
  const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
23
36
 
@@ -71,6 +84,46 @@ const proxyFetch = async (url, init) => {
71
84
 
72
85
  //#endregion
73
86
  //#region src/utils/calculate-score.ts
87
+ const getScoreLabel = (score) => {
88
+ if (score >= SCORE_GOOD_THRESHOLD) return "Great";
89
+ if (score >= SCORE_OK_THRESHOLD) return "Needs work";
90
+ return "Critical";
91
+ };
92
+ const countUniqueRules = (diagnostics) => {
93
+ const errorRules = /* @__PURE__ */ new Set();
94
+ const warningRules = /* @__PURE__ */ new Set();
95
+ for (const diagnostic of diagnostics) {
96
+ const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
97
+ if (diagnostic.severity === "error") errorRules.add(ruleKey);
98
+ else warningRules.add(ruleKey);
99
+ }
100
+ return {
101
+ errorRuleCount: errorRules.size,
102
+ warningRuleCount: warningRules.size
103
+ };
104
+ };
105
+ const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
106
+ const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
107
+ return Math.max(0, Math.round(PERFECT_SCORE - penalty));
108
+ };
109
+ const estimateScoreLocally = (diagnostics) => {
110
+ const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
111
+ const currentScore = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
112
+ const estimatedScore = scoreFromRuleCounts(Math.round(errorRuleCount * (1 - ERROR_ESTIMATED_FIX_RATE)), Math.round(warningRuleCount * (1 - WARNING_ESTIMATED_FIX_RATE)));
113
+ return {
114
+ currentScore,
115
+ currentLabel: getScoreLabel(currentScore),
116
+ estimatedScore,
117
+ estimatedLabel: getScoreLabel(estimatedScore)
118
+ };
119
+ };
120
+ const calculateScoreLocally = (diagnostics) => {
121
+ const { currentScore, currentLabel } = estimateScoreLocally(diagnostics);
122
+ return {
123
+ score: currentScore,
124
+ label: currentLabel
125
+ };
126
+ };
74
127
  const calculateScore = async (diagnostics) => {
75
128
  try {
76
129
  const response = await proxyFetch(SCORE_API_URL, {
@@ -78,10 +131,10 @@ const calculateScore = async (diagnostics) => {
78
131
  headers: { "Content-Type": "application/json" },
79
132
  body: JSON.stringify({ diagnostics })
80
133
  });
81
- if (!response.ok) return null;
134
+ if (!response.ok) return calculateScoreLocally(diagnostics);
82
135
  return await response.json();
83
136
  } catch {
84
- return null;
137
+ return calculateScoreLocally(diagnostics);
85
138
  }
86
139
  };
87
140
 
@@ -89,13 +142,33 @@ const calculateScore = async (diagnostics) => {
89
142
  //#region src/plugin/constants.ts
90
143
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
91
144
 
145
+ //#endregion
146
+ //#region src/utils/is-file.ts
147
+ const isFile = (filePath) => {
148
+ try {
149
+ return fs.statSync(filePath).isFile();
150
+ } catch {
151
+ return false;
152
+ }
153
+ };
154
+
92
155
  //#endregion
93
156
  //#region src/utils/read-package-json.ts
94
- const readPackageJson = (packageJsonPath) => JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
157
+ const readPackageJson = (packageJsonPath) => {
158
+ try {
159
+ return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
160
+ } catch (error) {
161
+ if (error instanceof Error && "code" in error) {
162
+ const { code } = error;
163
+ if (code === "EISDIR" || code === "EACCES") return {};
164
+ }
165
+ throw error;
166
+ }
167
+ };
95
168
 
96
169
  //#endregion
97
170
  //#region src/utils/check-reduced-motion.ts
98
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
171
+ const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
99
172
  const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
100
173
  const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
101
174
  filePath: "package.json",
@@ -111,7 +184,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
111
184
  };
112
185
  const checkReducedMotion = (rootDirectory) => {
113
186
  const packageJsonPath = path.join(rootDirectory, "package.json");
114
- if (!fs.existsSync(packageJsonPath)) return [];
187
+ if (!isFile(packageJsonPath)) return [];
115
188
  let hasMotionLibrary = false;
116
189
  try {
117
190
  const packageJson = readPackageJson(packageJsonPath);
@@ -139,7 +212,7 @@ const checkReducedMotion = (rootDirectory) => {
139
212
  //#region src/utils/match-glob-pattern.ts
140
213
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
141
214
  const compileGlobPattern = (pattern) => {
142
- const normalizedPattern = pattern.replace(/\\/g, "/");
215
+ const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
143
216
  let regexSource = "^";
144
217
  let characterIndex = 0;
145
218
  while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
@@ -163,17 +236,72 @@ const compileGlobPattern = (pattern) => {
163
236
  return new RegExp(regexSource);
164
237
  };
165
238
 
239
+ //#endregion
240
+ //#region src/utils/is-ignored-file.ts
241
+ const toRelativePath = (filePath, rootDirectory) => {
242
+ const normalizedFilePath = filePath.replace(/\\/g, "/");
243
+ const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
244
+ if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
245
+ return normalizedFilePath.replace(/^\.\//, "");
246
+ };
247
+ const compileIgnoredFilePatterns = (userConfig) => Array.isArray(userConfig?.ignore?.files) ? userConfig.ignore.files.map(compileGlobPattern) : [];
248
+ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
249
+ if (patterns.length === 0) return false;
250
+ const relativePath = toRelativePath(filePath, rootDirectory);
251
+ return patterns.some((pattern) => pattern.test(relativePath));
252
+ };
253
+
166
254
  //#endregion
167
255
  //#region src/utils/filter-diagnostics.ts
168
- const filterIgnoredDiagnostics = (diagnostics, config) => {
256
+ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory) => {
169
257
  const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
170
- const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
258
+ const ignoredFilePatterns = compileIgnoredFilePatterns(config);
171
259
  if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
172
260
  return diagnostics.filter((diagnostic) => {
173
261
  const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
174
262
  if (ignoredRules.has(ruleIdentifier)) return false;
175
- const normalizedPath = diagnostic.filePath.replace(/\\/g, "/").replace(/^\.\//, "");
176
- if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
263
+ if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
264
+ return true;
265
+ });
266
+ };
267
+ const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
268
+ const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
269
+ const isRuleSuppressed = (commentRules, ruleId) => {
270
+ if (!commentRules?.trim()) return true;
271
+ return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
272
+ };
273
+ const filterInlineSuppressions = (diagnostics, rootDirectory) => {
274
+ const fileLineCache = /* @__PURE__ */ new Map();
275
+ const getFileLines = (filePath) => {
276
+ const cached = fileLineCache.get(filePath);
277
+ if (cached !== void 0) return cached;
278
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
279
+ try {
280
+ const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
281
+ fileLineCache.set(filePath, lines);
282
+ return lines;
283
+ } catch {
284
+ fileLineCache.set(filePath, null);
285
+ return null;
286
+ }
287
+ };
288
+ return diagnostics.filter((diagnostic) => {
289
+ if (diagnostic.line <= 0) return true;
290
+ const lines = getFileLines(diagnostic.filePath);
291
+ if (!lines) return true;
292
+ const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
293
+ const currentLine = lines[diagnostic.line - 1];
294
+ if (currentLine) {
295
+ const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
296
+ if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
297
+ }
298
+ if (diagnostic.line >= 2) {
299
+ const prevLine = lines[diagnostic.line - 2];
300
+ if (prevLine) {
301
+ const nextLineMatch = prevLine.match(DISABLE_NEXT_LINE_PATTERN);
302
+ if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
303
+ }
304
+ }
177
305
  return true;
178
306
  });
179
307
  };
@@ -182,21 +310,21 @@ const filterIgnoredDiagnostics = (diagnostics, config) => {
182
310
  //#region src/utils/combine-diagnostics.ts
183
311
  const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
184
312
  const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
185
- const allDiagnostics = [
313
+ const merged = [
186
314
  ...lintDiagnostics,
187
315
  ...deadCodeDiagnostics,
188
316
  ...isDiffMode ? [] : checkReducedMotion(directory)
189
317
  ];
190
- return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
318
+ return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(merged, userConfig, directory) : merged, directory);
191
319
  };
192
320
 
193
321
  //#endregion
194
322
  //#region src/utils/find-monorepo-root.ts
195
323
  const isMonorepoRoot = (directory) => {
196
- if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
197
- if (fs.existsSync(path.join(directory, "nx.json"))) return true;
324
+ if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
325
+ if (isFile(path.join(directory, "nx.json"))) return true;
198
326
  const packageJsonPath = path.join(directory, "package.json");
199
- if (!fs.existsSync(packageJsonPath)) return false;
327
+ if (!isFile(packageJsonPath)) return false;
200
328
  const packageJson = readPackageJson(packageJsonPath);
201
329
  return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
202
330
  };
@@ -209,6 +337,10 @@ const findMonorepoRoot = (startDirectory) => {
209
337
  return null;
210
338
  };
211
339
 
340
+ //#endregion
341
+ //#region src/utils/is-plain-object.ts
342
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
343
+
212
344
  //#endregion
213
345
  //#region src/utils/discover-project.ts
214
346
  const REACT_COMPILER_PACKAGES = new Set([
@@ -251,12 +383,6 @@ const FRAMEWORK_PACKAGES = {
251
383
  expo: "expo",
252
384
  "react-native": "react-native"
253
385
  };
254
- const IGNORED_DIRECTORIES = new Set([
255
- "node_modules",
256
- "dist",
257
- "build",
258
- "coverage"
259
- ]);
260
386
  const countSourceFilesViaFilesystem = (rootDirectory) => {
261
387
  let count = 0;
262
388
  const stack = [rootDirectory];
@@ -297,16 +423,129 @@ const detectFramework = (dependencies) => {
297
423
  for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
298
424
  return "unknown";
299
425
  };
426
+ const isCatalogReference = (version) => version.startsWith("catalog:");
427
+ const extractCatalogName = (version) => {
428
+ if (!isCatalogReference(version)) return null;
429
+ const name = version.slice(8).trim();
430
+ return name.length > 0 ? name : null;
431
+ };
432
+ const resolveVersionFromCatalog = (catalog, packageName) => {
433
+ const version = catalog[packageName];
434
+ if (typeof version === "string" && !isCatalogReference(version)) return version;
435
+ return null;
436
+ };
437
+ const parsePnpmWorkspaceCatalogs = (rootDirectory) => {
438
+ const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
439
+ if (!isFile(workspacePath)) return {
440
+ defaultCatalog: {},
441
+ namedCatalogs: {}
442
+ };
443
+ const content = fs.readFileSync(workspacePath, "utf-8");
444
+ const defaultCatalog = {};
445
+ const namedCatalogs = {};
446
+ let currentSection = "none";
447
+ let currentCatalogName = "";
448
+ for (const line of content.split("\n")) {
449
+ const trimmed = line.trim();
450
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
451
+ const indentLevel = line.search(/\S/);
452
+ if (indentLevel === 0 && trimmed === "catalog:") {
453
+ currentSection = "catalog";
454
+ continue;
455
+ }
456
+ if (indentLevel === 0 && trimmed === "catalogs:") {
457
+ currentSection = "catalogs";
458
+ continue;
459
+ }
460
+ if (indentLevel === 0) {
461
+ currentSection = "none";
462
+ continue;
463
+ }
464
+ if (currentSection === "catalog" && indentLevel > 0) {
465
+ const colonIndex = trimmed.indexOf(":");
466
+ if (colonIndex > 0) {
467
+ const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
468
+ const value = trimmed.slice(colonIndex + 1).trim().replace(/["']/g, "");
469
+ if (key && value) defaultCatalog[key] = value;
470
+ }
471
+ continue;
472
+ }
473
+ if (currentSection === "catalogs" && indentLevel > 0) {
474
+ if (trimmed.endsWith(":") && !trimmed.includes(" ")) {
475
+ currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
476
+ currentSection = "named-catalog";
477
+ namedCatalogs[currentCatalogName] = {};
478
+ continue;
479
+ }
480
+ }
481
+ if (currentSection === "named-catalog" && indentLevel > 0) {
482
+ if (indentLevel <= 2 && trimmed.endsWith(":") && !trimmed.includes(" ")) {
483
+ currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
484
+ namedCatalogs[currentCatalogName] = {};
485
+ continue;
486
+ }
487
+ const colonIndex = trimmed.indexOf(":");
488
+ if (colonIndex > 0 && currentCatalogName) {
489
+ const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
490
+ const value = trimmed.slice(colonIndex + 1).trim().replace(/["']/g, "");
491
+ if (key && value) namedCatalogs[currentCatalogName][key] = value;
492
+ }
493
+ }
494
+ }
495
+ return {
496
+ defaultCatalog,
497
+ namedCatalogs
498
+ };
499
+ };
500
+ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogReference) => {
501
+ if (catalogReference) {
502
+ const namedCatalog = catalogs.namedCatalogs[catalogReference];
503
+ if (namedCatalog?.[packageName]) return namedCatalog[packageName];
504
+ }
505
+ if (catalogs.defaultCatalog[packageName]) return catalogs.defaultCatalog[packageName];
506
+ for (const namedCatalog of Object.values(catalogs.namedCatalogs)) if (namedCatalog[packageName]) return namedCatalog[packageName];
507
+ return null;
508
+ };
509
+ const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
510
+ const rawVersion = collectAllDependencies(packageJson)[packageName];
511
+ const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
512
+ const raw = packageJson;
513
+ if (isPlainObject(raw.catalog)) {
514
+ const version = resolveVersionFromCatalog(raw.catalog, packageName);
515
+ if (version) return version;
516
+ }
517
+ if (isPlainObject(raw.catalogs)) {
518
+ if (catalogName && isPlainObject(raw.catalogs[catalogName])) {
519
+ const version = resolveVersionFromCatalog(raw.catalogs[catalogName], packageName);
520
+ if (version) return version;
521
+ }
522
+ for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
523
+ const version = resolveVersionFromCatalog(catalogEntries, packageName);
524
+ if (version) return version;
525
+ }
526
+ }
527
+ const workspaces = packageJson.workspaces;
528
+ if (workspaces && !Array.isArray(workspaces) && isPlainObject(workspaces.catalog)) {
529
+ const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
530
+ if (version) return version;
531
+ }
532
+ if (rootDirectory) {
533
+ const pnpmVersion = resolveCatalogVersionFromCollection(parsePnpmWorkspaceCatalogs(rootDirectory), packageName, catalogName);
534
+ if (pnpmVersion) return pnpmVersion;
535
+ }
536
+ return null;
537
+ };
300
538
  const extractDependencyInfo = (packageJson) => {
301
539
  const allDependencies = collectAllDependencies(packageJson);
540
+ const rawVersion = allDependencies.react ?? null;
302
541
  return {
303
- reactVersion: allDependencies.react ?? null,
542
+ reactVersion: rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null,
304
543
  framework: detectFramework(allDependencies)
305
544
  };
306
545
  };
307
546
  const parsePnpmWorkspacePatterns = (rootDirectory) => {
308
547
  const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
309
- if (!fs.existsSync(workspacePath)) return [];
548
+ if (!isFile(workspacePath)) return [];
310
549
  const content = fs.readFileSync(workspacePath, "utf-8");
311
550
  const patterns = [];
312
551
  let isInsidePackagesBlock = false;
@@ -332,14 +571,14 @@ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
332
571
  const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
333
572
  if (!cleanPattern.includes("*")) {
334
573
  const directoryPath = path.join(rootDirectory, cleanPattern);
335
- if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
574
+ if (fs.existsSync(directoryPath) && isFile(path.join(directoryPath, "package.json"))) return [directoryPath];
336
575
  return [];
337
576
  }
338
577
  const wildcardIndex = cleanPattern.indexOf("*");
339
578
  const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
340
579
  const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
341
580
  if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
342
- 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")));
581
+ 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")));
343
582
  };
344
583
  const findDependencyInfoFromMonorepoRoot = (directory) => {
345
584
  const monorepoRoot = findMonorepoRoot(directory);
@@ -347,11 +586,17 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
347
586
  reactVersion: null,
348
587
  framework: "unknown"
349
588
  };
350
- const rootPackageJson = readPackageJson(path.join(monorepoRoot, "package.json"));
589
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
590
+ if (!isFile(monorepoPackageJsonPath)) return {
591
+ reactVersion: null,
592
+ framework: "unknown"
593
+ };
594
+ const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
351
595
  const rootInfo = extractDependencyInfo(rootPackageJson);
596
+ const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot);
352
597
  const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
353
598
  return {
354
- reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
599
+ reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
355
600
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
356
601
  };
357
602
  };
@@ -377,7 +622,7 @@ const hasCompilerPackage = (packageJson) => {
377
622
  return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
378
623
  };
379
624
  const fileContainsPattern = (filePath, pattern) => {
380
- if (!fs.existsSync(filePath)) return false;
625
+ if (!isFile(filePath)) return false;
381
626
  const content = fs.readFileSync(filePath, "utf-8");
382
627
  return pattern.test(content);
383
628
  };
@@ -391,7 +636,7 @@ const detectReactCompiler = (directory, packageJson) => {
391
636
  let ancestorDirectory = path.dirname(directory);
392
637
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
393
638
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
394
- if (fs.existsSync(ancestorPackagePath)) {
639
+ if (isFile(ancestorPackagePath)) {
395
640
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
396
641
  }
397
642
  ancestorDirectory = path.dirname(ancestorDirectory);
@@ -400,9 +645,17 @@ const detectReactCompiler = (directory, packageJson) => {
400
645
  };
401
646
  const discoverProject = (directory) => {
402
647
  const packageJsonPath = path.join(directory, "package.json");
403
- if (!fs.existsSync(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
648
+ if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
404
649
  const packageJson = readPackageJson(packageJsonPath);
405
650
  let { reactVersion, framework } = extractDependencyInfo(packageJson);
651
+ if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react", directory);
652
+ if (!reactVersion) {
653
+ const monorepoRoot = findMonorepoRoot(directory);
654
+ if (monorepoRoot) {
655
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
656
+ if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot);
657
+ }
658
+ }
406
659
  if (!reactVersion || framework === "unknown") {
407
660
  const workspaceInfo = findReactInWorkspaces(directory, packageJson);
408
661
  if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
@@ -432,10 +685,9 @@ const discoverProject = (directory) => {
432
685
  //#region src/utils/load-config.ts
433
686
  const CONFIG_FILENAME = "react-doctor.config.json";
434
687
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
435
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
436
688
  const loadConfig = (rootDirectory) => {
437
689
  const configFilePath = path.join(rootDirectory, CONFIG_FILENAME);
438
- if (fs.existsSync(configFilePath)) try {
690
+ if (isFile(configFilePath)) try {
439
691
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
440
692
  const parsed = JSON.parse(fileContent);
441
693
  if (isPlainObject(parsed)) return parsed;
@@ -444,7 +696,7 @@ const loadConfig = (rootDirectory) => {
444
696
  console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
445
697
  }
446
698
  const packageJsonPath = path.join(rootDirectory, "package.json");
447
- if (fs.existsSync(packageJsonPath)) try {
699
+ if (isFile(packageJsonPath)) try {
448
700
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
449
701
  const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
450
702
  if (isPlainObject(embeddedConfig)) return embeddedConfig;
@@ -454,6 +706,49 @@ const loadConfig = (rootDirectory) => {
454
706
  return null;
455
707
  };
456
708
 
709
+ //#endregion
710
+ //#region src/utils/resolve-lint-include-paths.ts
711
+ const listSourceFilesViaGit = (rootDirectory) => {
712
+ const result = spawnSync("git", [
713
+ "ls-files",
714
+ "--cached",
715
+ "--others",
716
+ "--exclude-standard"
717
+ ], {
718
+ cwd: rootDirectory,
719
+ encoding: "utf-8",
720
+ maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
721
+ });
722
+ if (result.error || result.status !== 0) return null;
723
+ return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
724
+ };
725
+ const listSourceFilesViaFilesystem = (rootDirectory) => {
726
+ const filePaths = [];
727
+ const stack = [rootDirectory];
728
+ while (stack.length > 0) {
729
+ const currentDirectory = stack.pop();
730
+ const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
731
+ for (const entry of entries) {
732
+ const absolutePath = path.join(currentDirectory, entry.name);
733
+ if (entry.isDirectory()) {
734
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
735
+ continue;
736
+ }
737
+ if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
738
+ }
739
+ }
740
+ return filePaths;
741
+ };
742
+ const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
743
+ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
744
+ if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
745
+ const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
746
+ return listSourceFiles(rootDirectory).filter((filePath) => {
747
+ if (!JSX_FILE_PATTERN.test(filePath)) return false;
748
+ return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
749
+ });
750
+ };
751
+
457
752
  //#endregion
458
753
  //#region src/utils/run-knip.ts
459
754
  const KNIP_CATEGORY_MAP = {
@@ -542,7 +837,7 @@ const runKnip = async (rootDirectory) => {
542
837
  let knipResult;
543
838
  if (monorepoRoot) {
544
839
  const packageJsonPath = path.join(rootDirectory, "package.json");
545
- const workspaceName = (fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
840
+ const workspaceName = (isFile(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
546
841
  try {
547
842
  knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
548
843
  } catch {
@@ -712,24 +1007,27 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
712
1007
 
713
1008
  //#endregion
714
1009
  //#region src/utils/neutralize-disable-directives.ts
715
- const findFilesWithDisableDirectives = (rootDirectory) => {
716
- const result = spawnSync("git", [
1010
+ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1011
+ const grepArgs = [
717
1012
  "grep",
718
1013
  "-l",
719
1014
  "--untracked",
720
1015
  "-E",
721
1016
  "(eslint|oxlint)-disable"
722
- ], {
1017
+ ];
1018
+ if (includePaths && includePaths.length > 0) grepArgs.push("--", ...includePaths);
1019
+ const result = spawnSync("git", grepArgs, {
723
1020
  cwd: rootDirectory,
724
1021
  encoding: "utf-8",
725
1022
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
726
1023
  });
727
1024
  if (result.error || result.status === null) return [];
1025
+ if (result.status !== 0 && result.stdout.trim().length === 0) return [];
728
1026
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
729
1027
  };
730
1028
  const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
731
- const neutralizeDisableDirectives = (rootDirectory) => {
732
- const filePaths = findFilesWithDisableDirectives(rootDirectory);
1029
+ const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1030
+ const filePaths = findFilesWithDisableDirectives(rootDirectory, includePaths);
733
1031
  const originalContents = /* @__PURE__ */ new Map();
734
1032
  for (const relativePath of filePaths) {
735
1033
  const absolutePath = path.join(rootDirectory, relativePath);
@@ -825,7 +1123,7 @@ const RULE_CATEGORY_MAP = {
825
1123
  "react-doctor/rn-no-single-element-style-array": "React Native"
826
1124
  };
827
1125
  const RULE_HELP_MAP = {
828
- "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} />`",
1126
+ "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",
829
1127
  "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
830
1128
  "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
831
1129
  "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
@@ -865,7 +1163,7 @@ const RULE_HELP_MAP = {
865
1163
  "nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
866
1164
  "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",
867
1165
  "nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
868
- "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' in a Server Component, or handle in middleware",
1166
+ "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' directly (works in both server and client components), or handle in middleware",
869
1167
  "nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
870
1168
  "nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
871
1169
  "nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
@@ -953,7 +1251,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
953
1251
  child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
954
1252
  child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
955
1253
  child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
956
- child.on("close", () => {
1254
+ child.on("close", (code, signal) => {
1255
+ if (signal) {
1256
+ const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1257
+ const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
1258
+ const detail = stderrOutput ? `: ${stderrOutput}` : "";
1259
+ reject(/* @__PURE__ */ new Error(`oxlint was killed by ${signal}${hint}${detail}`));
1260
+ return;
1261
+ }
957
1262
  const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
958
1263
  if (!output) {
959
1264
  const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
@@ -998,7 +1303,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
998
1303
  framework,
999
1304
  hasReactCompiler
1000
1305
  });
1001
- const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
1306
+ const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory, includePaths);
1002
1307
  try {
1003
1308
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1004
1309
  const baseArgs = [
@@ -1114,9 +1419,9 @@ const diagnose = async (directory, options = {}) => {
1114
1419
  const effectiveLint = options.lint ?? userConfig?.lint ?? true;
1115
1420
  const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
1116
1421
  if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
1117
- const jsxIncludePaths = computeJsxIncludePaths(includePaths);
1422
+ const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
1118
1423
  const emptyDiagnostics = [];
1119
- const lintPromise = effectiveLint ? runOxlint(resolvedDirectory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths).catch((error) => {
1424
+ const lintPromise = effectiveLint ? runOxlint(resolvedDirectory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths).catch((error) => {
1120
1425
  console.error("Lint failed:", error);
1121
1426
  return emptyDiagnostics;
1122
1427
  }) : Promise.resolve(emptyDiagnostics);