react-doctor 0.0.41 → 0.0.44

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.js CHANGED
@@ -1,20 +1,39 @@
1
1
  import { createRequire } from "node:module";
2
2
  import path from "node:path";
3
- import { execSync, spawn, spawnSync } from "node:child_process";
3
+ import { spawn, spawnSync } from "node:child_process";
4
4
  import fs from "node:fs";
5
+ import pc from "picocolors";
5
6
  import { main } from "knip";
6
7
  import { createOptions } from "knip/session";
7
8
  import os from "node:os";
8
9
  import { fileURLToPath } from "node:url";
9
-
10
- //#region src/core/build-diagnose-result.ts
11
- const buildDiagnoseResult = (params) => ({
12
- diagnostics: params.diagnostics,
13
- score: params.score,
14
- project: params.project,
15
- elapsedMilliseconds: params.elapsedMilliseconds
16
- });
17
-
10
+ //#region src/constants.ts
11
+ const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
12
+ const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
13
+ const SCORE_API_URL = "https://www.react.doctor/api/score";
14
+ const FETCH_TIMEOUT_MS = 1e4;
15
+ const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
16
+ const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
17
+ const ERROR_RULE_PENALTY = 1.5;
18
+ const WARNING_RULE_PENALTY = .75;
19
+ const KNIP_CONFIG_LOCATIONS = [
20
+ "knip.json",
21
+ "knip.jsonc",
22
+ ".knip.json",
23
+ ".knip.jsonc",
24
+ "knip.ts",
25
+ "knip.js",
26
+ "knip.config.ts",
27
+ "knip.config.js"
28
+ ];
29
+ const IGNORED_DIRECTORIES = new Set([
30
+ "node_modules",
31
+ "dist",
32
+ "build",
33
+ "coverage"
34
+ ]);
35
+ const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
36
+ const buildNoReactDependencyError = (directory) => `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`;
18
37
  //#endregion
19
38
  //#region src/utils/match-glob-pattern.ts
20
39
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
@@ -42,7 +61,6 @@ const compileGlobPattern = (pattern) => {
42
61
  regexSource += "$";
43
62
  return new RegExp(regexSource);
44
63
  };
45
-
46
64
  //#endregion
47
65
  //#region src/utils/is-ignored-file.ts
48
66
  const toRelativePath = (filePath, rootDirectory) => {
@@ -51,13 +69,16 @@ const toRelativePath = (filePath, rootDirectory) => {
51
69
  if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
52
70
  return normalizedFilePath.replace(/^\.\//, "");
53
71
  };
54
- const compileIgnoredFilePatterns = (userConfig) => Array.isArray(userConfig?.ignore?.files) ? userConfig.ignore.files.map(compileGlobPattern) : [];
72
+ const compileIgnoredFilePatterns = (userConfig) => {
73
+ const files = userConfig?.ignore?.files;
74
+ if (!Array.isArray(files)) return [];
75
+ return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
76
+ };
55
77
  const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
56
78
  if (patterns.length === 0) return false;
57
79
  const relativePath = toRelativePath(filePath, rootDirectory);
58
80
  return patterns.some((pattern) => pattern.test(relativePath));
59
81
  };
60
-
61
82
  //#endregion
62
83
  //#region src/utils/filter-diagnostics.ts
63
84
  const resolveCandidateReadPath = (rootDirectory, filePath) => {
@@ -93,9 +114,9 @@ const isRuleSuppressed = (commentRules, ruleId) => {
93
114
  return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
94
115
  };
95
116
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
96
- const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
117
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
97
118
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
98
- const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents : []);
119
+ const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
99
120
  const hasTextComponents = textComponentNames.size > 0;
100
121
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
101
122
  return diagnostics.filter((diagnostic) => {
@@ -131,13 +152,11 @@ const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync)
131
152
  return true;
132
153
  });
133
154
  };
134
-
135
155
  //#endregion
136
156
  //#region src/utils/merge-and-filter-diagnostics.ts
137
157
  const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync) => {
138
158
  return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics, directory, readFileLinesSync);
139
159
  };
140
-
141
160
  //#endregion
142
161
  //#region src/core/build-result.ts
143
162
  const buildDiagnoseTimedResult = async (input) => {
@@ -149,36 +168,9 @@ const buildDiagnoseTimedResult = async (input) => {
149
168
  elapsedMilliseconds
150
169
  };
151
170
  };
152
-
153
- //#endregion
154
- //#region src/constants.ts
155
- const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
156
- const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
157
- const ERROR_PREVIEW_LENGTH_CHARS = 200;
158
- const PERFECT_SCORE = 100;
159
- const SCORE_GOOD_THRESHOLD = 75;
160
- const SCORE_OK_THRESHOLD = 50;
161
- const SCORE_API_URL = "https://www.react.doctor/api/score";
162
- const FETCH_TIMEOUT_MS = 1e4;
163
- const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
164
- const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
165
- const OXLINT_MAX_FILES_PER_BATCH = 500;
166
- const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
167
- const ERROR_RULE_PENALTY = 1.5;
168
- const WARNING_RULE_PENALTY = .75;
169
- const MAX_KNIP_RETRIES = 5;
170
- const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
171
- const IGNORED_DIRECTORIES = new Set([
172
- "node_modules",
173
- "dist",
174
- "build",
175
- "coverage"
176
- ]);
177
-
178
171
  //#endregion
179
172
  //#region src/utils/jsx-include-paths.ts
180
173
  const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
181
-
182
174
  //#endregion
183
175
  //#region src/core/diagnose-core.ts
184
176
  const diagnoseCore = async (deps, options = {}) => {
@@ -190,7 +182,7 @@ const diagnoseCore = async (deps, options = {}) => {
190
182
  const userConfig = deps.loadUserConfig();
191
183
  const effectiveLint = options.lint ?? userConfig?.lint ?? true;
192
184
  const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
193
- if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
185
+ if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(deps.rootDirectory));
194
186
  const lintIncludePaths = options.lintIncludePaths !== void 0 ? options.lintIncludePaths : computeJsxIncludePaths(includePaths);
195
187
  const { runLint, runDeadCode } = deps.createRunners({
196
188
  resolvedDirectory,
@@ -208,7 +200,11 @@ const diagnoseCore = async (deps, options = {}) => {
208
200
  console.error("Dead code analysis failed:", error);
209
201
  return emptyDiagnostics;
210
202
  }) : Promise.resolve(emptyDiagnostics);
211
- const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
203
+ const [lintSettled, deadCodeSettled] = await Promise.allSettled([lintPromise, deadCodePromise]);
204
+ const lintDiagnostics = lintSettled.status === "fulfilled" ? lintSettled.value : emptyDiagnostics;
205
+ const deadCodeDiagnostics = deadCodeSettled.status === "fulfilled" ? deadCodeSettled.value : emptyDiagnostics;
206
+ if (lintSettled.status === "rejected") console.error("Lint rejected:", lintSettled.reason);
207
+ if (deadCodeSettled.status === "rejected") console.error("Dead code rejected:", deadCodeSettled.reason);
212
208
  const environmentDiagnostics = deps.getExtraDiagnostics?.() ?? [];
213
209
  const timed = await buildDiagnoseTimedResult({
214
210
  mergedDiagnostics: [
@@ -222,18 +218,149 @@ const diagnoseCore = async (deps, options = {}) => {
222
218
  startTime,
223
219
  calculateDiagnosticsScore: deps.calculateDiagnosticsScore
224
220
  });
225
- return buildDiagnoseResult({
221
+ return {
226
222
  diagnostics: timed.diagnostics,
227
223
  score: timed.score,
228
224
  project: projectInfo,
229
225
  elapsedMilliseconds: timed.elapsedMilliseconds
230
- });
226
+ };
227
+ };
228
+ //#endregion
229
+ //#region src/utils/summarize-diagnostics.ts
230
+ const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
231
+ let errorCount = 0;
232
+ let warningCount = 0;
233
+ const affectedFiles = /* @__PURE__ */ new Set();
234
+ for (const diagnostic of diagnostics) {
235
+ if (diagnostic.severity === "error") errorCount++;
236
+ else warningCount++;
237
+ affectedFiles.add(diagnostic.filePath);
238
+ }
239
+ return {
240
+ errorCount,
241
+ warningCount,
242
+ affectedFileCount: affectedFiles.size,
243
+ totalDiagnosticCount: diagnostics.length,
244
+ score: worstScore,
245
+ scoreLabel: worstScoreLabel
246
+ };
247
+ };
248
+ //#endregion
249
+ //#region src/utils/build-json-report.ts
250
+ const toJsonDiff = (diff) => {
251
+ if (!diff) return null;
252
+ return {
253
+ baseBranch: diff.baseBranch,
254
+ currentBranch: diff.currentBranch,
255
+ changedFileCount: diff.changedFiles.length,
256
+ isCurrentChanges: Boolean(diff.isCurrentChanges)
257
+ };
258
+ };
259
+ const findWorstScoredProject = (projects) => {
260
+ let worst = null;
261
+ let worstScore = Number.POSITIVE_INFINITY;
262
+ for (const project of projects) {
263
+ const score = project.score?.score;
264
+ if (typeof score !== "number") continue;
265
+ if (score < worstScore) {
266
+ worstScore = score;
267
+ worst = project;
268
+ }
269
+ }
270
+ return worst;
271
+ };
272
+ const buildJsonReport = (input) => {
273
+ const projects = input.scans.map(({ directory, result }) => ({
274
+ directory,
275
+ project: result.project,
276
+ diagnostics: result.diagnostics,
277
+ score: result.score,
278
+ skippedChecks: result.skippedChecks,
279
+ elapsedMilliseconds: result.elapsedMilliseconds
280
+ }));
281
+ const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
282
+ const worstScoredProject = findWorstScoredProject(projects);
283
+ const summary = summarizeDiagnostics(flattenedDiagnostics, worstScoredProject?.score?.score ?? null, worstScoredProject?.score?.label ?? null);
284
+ return {
285
+ schemaVersion: 1,
286
+ version: input.version,
287
+ ok: true,
288
+ directory: input.directory,
289
+ mode: input.mode,
290
+ diff: toJsonDiff(input.diff),
291
+ projects,
292
+ diagnostics: flattenedDiagnostics,
293
+ summary,
294
+ elapsedMilliseconds: input.totalElapsedMilliseconds,
295
+ error: null
296
+ };
297
+ };
298
+ //#endregion
299
+ //#region src/utils/format-error-chain.ts
300
+ const collectErrorChain = (rootError) => {
301
+ const errorChain = [];
302
+ const visitedErrors = /* @__PURE__ */ new Set();
303
+ let currentError = rootError;
304
+ while (currentError !== void 0 && !visitedErrors.has(currentError)) {
305
+ visitedErrors.add(currentError);
306
+ errorChain.push(currentError);
307
+ currentError = currentError instanceof Error ? currentError.cause : void 0;
308
+ }
309
+ return errorChain;
310
+ };
311
+ const formatErrorMessage = (error) => error instanceof Error ? error.message || error.name : String(error);
312
+ const getErrorChainMessages = (rootError) => collectErrorChain(rootError).map(formatErrorMessage);
313
+ //#endregion
314
+ //#region src/utils/build-json-report-error.ts
315
+ const safeStringify = (value) => {
316
+ try {
317
+ return String(value);
318
+ } catch {
319
+ return "Unrepresentable error";
320
+ }
321
+ };
322
+ const safeGetErrorChain = (error) => {
323
+ try {
324
+ return getErrorChainMessages(error);
325
+ } catch {
326
+ return [safeStringify(error)];
327
+ }
328
+ };
329
+ const buildJsonReportError = (input) => {
330
+ const chain = safeGetErrorChain(input.error);
331
+ const errorPayload = input.error instanceof Error ? {
332
+ message: input.error.message || input.error.name || "Error",
333
+ name: input.error.name || "Error",
334
+ chain
335
+ } : {
336
+ message: safeStringify(input.error),
337
+ name: "Error",
338
+ chain
339
+ };
340
+ return {
341
+ schemaVersion: 1,
342
+ version: input.version,
343
+ ok: false,
344
+ directory: input.directory,
345
+ mode: input.mode ?? "full",
346
+ diff: null,
347
+ projects: [],
348
+ diagnostics: [],
349
+ summary: {
350
+ errorCount: 0,
351
+ warningCount: 0,
352
+ affectedFileCount: 0,
353
+ totalDiagnosticCount: 0,
354
+ score: null,
355
+ scoreLabel: null
356
+ },
357
+ elapsedMilliseconds: input.elapsedMilliseconds,
358
+ error: errorPayload
359
+ };
231
360
  };
232
-
233
361
  //#endregion
234
362
  //#region src/plugin/constants.ts
235
363
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
236
-
237
364
  //#endregion
238
365
  //#region src/utils/is-file.ts
239
366
  const isFile = (filePath) => {
@@ -243,10 +370,13 @@ const isFile = (filePath) => {
243
370
  return false;
244
371
  }
245
372
  };
246
-
247
373
  //#endregion
248
374
  //#region src/utils/read-package-json.ts
249
- const readPackageJson = (packageJsonPath) => {
375
+ const cachedPackageJsons = /* @__PURE__ */ new Map();
376
+ const clearPackageJsonCache = () => {
377
+ cachedPackageJsons.clear();
378
+ };
379
+ const readPackageJsonUncached = (packageJsonPath) => {
250
380
  try {
251
381
  return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
252
382
  } catch (error) {
@@ -258,11 +388,25 @@ const readPackageJson = (packageJsonPath) => {
258
388
  throw error;
259
389
  }
260
390
  };
261
-
391
+ const readPackageJson = (packageJsonPath) => {
392
+ const absolutePath = path.resolve(packageJsonPath);
393
+ const cached = cachedPackageJsons.get(absolutePath);
394
+ if (cached !== void 0) return cached;
395
+ const result = readPackageJsonUncached(absolutePath);
396
+ cachedPackageJsons.set(absolutePath, result);
397
+ return result;
398
+ };
262
399
  //#endregion
263
400
  //#region src/utils/check-reduced-motion.ts
264
401
  const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
265
- const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
402
+ const REDUCED_MOTION_FILE_GLOBS = [
403
+ "*.ts",
404
+ "*.tsx",
405
+ "*.js",
406
+ "*.jsx",
407
+ "*.css",
408
+ "*.scss"
409
+ ];
266
410
  const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
267
411
  filePath: "package.json",
268
412
  plugin: "react-doctor",
@@ -272,8 +416,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
272
416
  help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
273
417
  line: 0,
274
418
  column: 0,
275
- category: "Accessibility",
276
- weight: 2
419
+ category: "Accessibility"
277
420
  };
278
421
  const checkReducedMotion = (rootDirectory) => {
279
422
  const packageJsonPath = path.join(rootDirectory, "package.json");
@@ -290,17 +433,144 @@ const checkReducedMotion = (rootDirectory) => {
290
433
  return [];
291
434
  }
292
435
  if (!hasMotionLibrary) return [];
436
+ const result = spawnSync("git", [
437
+ "grep",
438
+ "-ql",
439
+ "-E",
440
+ REDUCED_MOTION_GREP_PATTERN,
441
+ "--",
442
+ ...REDUCED_MOTION_FILE_GLOBS
443
+ ], {
444
+ cwd: rootDirectory,
445
+ stdio: [
446
+ "ignore",
447
+ "pipe",
448
+ "pipe"
449
+ ]
450
+ });
451
+ if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
452
+ if (result.status === 0) return [];
453
+ return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
454
+ };
455
+ //#endregion
456
+ //#region src/utils/parse-gitattributes-linguist.ts
457
+ const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
458
+ const FALSY_VALUES = new Set([
459
+ "false",
460
+ "0",
461
+ "off",
462
+ "no"
463
+ ]);
464
+ const isTruthyLinguistAttribute = (token) => {
465
+ const match = LINGUIST_ATTRIBUTE_PATTERN.exec(token);
466
+ if (!match) return false;
467
+ if (match[1] === void 0) return true;
468
+ return !FALSY_VALUES.has(match[1].toLowerCase());
469
+ };
470
+ const parseGitattributesLinguistPaths = (filePath) => {
471
+ let content;
293
472
  try {
294
- execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
295
- cwd: rootDirectory,
296
- stdio: "pipe"
297
- });
298
- return [];
473
+ content = fs.readFileSync(filePath, "utf-8");
299
474
  } catch {
300
- return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
475
+ return [];
301
476
  }
477
+ const paths = [];
478
+ for (const rawLine of content.split("\n")) {
479
+ const line = rawLine.trim();
480
+ if (line.length === 0 || line.startsWith("#")) continue;
481
+ const tokens = line.split(/\s+/);
482
+ if (tokens.length < 2) continue;
483
+ const [pathSpec, ...attributes] = tokens;
484
+ if (attributes.some(isTruthyLinguistAttribute)) paths.push(pathSpec);
485
+ }
486
+ return paths;
487
+ };
488
+ //#endregion
489
+ //#region src/utils/highlighter.ts
490
+ const highlighter = {
491
+ error: pc.red,
492
+ warn: pc.yellow,
493
+ info: pc.cyan,
494
+ success: pc.green,
495
+ dim: pc.dim
496
+ };
497
+ const logger = {
498
+ error(...args) {
499
+ console.error(highlighter.error(args.join(" ")));
500
+ },
501
+ warn(...args) {
502
+ console.warn(highlighter.warn(args.join(" ")));
503
+ },
504
+ info(...args) {
505
+ console.log(highlighter.info(args.join(" ")));
506
+ },
507
+ success(...args) {
508
+ console.log(highlighter.success(args.join(" ")));
509
+ },
510
+ dim(...args) {
511
+ console.log(highlighter.dim(args.join(" ")));
512
+ },
513
+ log(...args) {
514
+ console.log(args.join(" "));
515
+ },
516
+ break() {
517
+ console.log("");
518
+ }
519
+ };
520
+ //#endregion
521
+ //#region src/utils/read-ignore-file.ts
522
+ const stripGitignoreEscape = (pattern) => {
523
+ if (pattern.startsWith("\\#") || pattern.startsWith("\\!")) return pattern.slice(1);
524
+ return pattern;
525
+ };
526
+ const readIgnoreFile = (filePath) => {
527
+ let content;
528
+ try {
529
+ content = fs.readFileSync(filePath, "utf-8");
530
+ } catch (error) {
531
+ const errnoCode = error?.code;
532
+ if (errnoCode && errnoCode !== "ENOENT") logger.warn(`Could not read ignore file ${filePath}: ${errnoCode}`);
533
+ return [];
534
+ }
535
+ const patterns = [];
536
+ for (const line of content.split("\n")) {
537
+ const trimmed = line.trim();
538
+ if (trimmed.length === 0) continue;
539
+ if (trimmed.startsWith("#")) continue;
540
+ patterns.push(stripGitignoreEscape(trimmed));
541
+ }
542
+ return patterns;
543
+ };
544
+ //#endregion
545
+ //#region src/utils/collect-ignore-patterns.ts
546
+ const IGNORE_FILENAMES = [
547
+ ".eslintignore",
548
+ ".oxlintignore",
549
+ ".prettierignore"
550
+ ];
551
+ const cachedPatternsByRoot = /* @__PURE__ */ new Map();
552
+ const clearIgnorePatternsCache = () => {
553
+ cachedPatternsByRoot.clear();
554
+ };
555
+ const computeIgnorePatterns = (rootDirectory) => {
556
+ const seen = /* @__PURE__ */ new Set();
557
+ const patterns = [];
558
+ const addPattern = (pattern) => {
559
+ if (seen.has(pattern)) return;
560
+ seen.add(pattern);
561
+ patterns.push(pattern);
562
+ };
563
+ for (const filename of IGNORE_FILENAMES) for (const pattern of readIgnoreFile(path.join(rootDirectory, filename))) addPattern(pattern);
564
+ for (const linguistPath of parseGitattributesLinguistPaths(path.join(rootDirectory, ".gitattributes"))) addPattern(linguistPath);
565
+ return patterns;
566
+ };
567
+ const collectIgnorePatterns = (rootDirectory) => {
568
+ const cached = cachedPatternsByRoot.get(rootDirectory);
569
+ if (cached !== void 0) return cached;
570
+ const patterns = computeIgnorePatterns(rootDirectory);
571
+ cachedPatternsByRoot.set(rootDirectory, patterns);
572
+ return patterns;
302
573
  };
303
-
304
574
  //#endregion
305
575
  //#region src/utils/find-monorepo-root.ts
306
576
  const isMonorepoRoot = (directory) => {
@@ -319,11 +589,13 @@ const findMonorepoRoot = (startDirectory) => {
319
589
  }
320
590
  return null;
321
591
  };
322
-
323
592
  //#endregion
324
593
  //#region src/utils/is-plain-object.ts
325
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
326
-
594
+ const isPlainObject = (value) => {
595
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
596
+ const prototype = Object.getPrototypeOf(value);
597
+ return prototype === null || prototype === Object.prototype;
598
+ };
327
599
  //#endregion
328
600
  //#region src/utils/discover-project.ts
329
601
  const REACT_COMPILER_PACKAGES = new Set([
@@ -331,6 +603,11 @@ const REACT_COMPILER_PACKAGES = new Set([
331
603
  "react-compiler-runtime",
332
604
  "eslint-plugin-react-compiler"
333
605
  ]);
606
+ const TANSTACK_QUERY_PACKAGES = new Set([
607
+ "@tanstack/react-query",
608
+ "@tanstack/query-core",
609
+ "react-query"
610
+ ]);
334
611
  const NEXT_CONFIG_FILENAMES = [
335
612
  "next.config.js",
336
613
  "next.config.mjs",
@@ -349,7 +626,11 @@ const VITE_CONFIG_FILENAMES = [
349
626
  "vite.config.js",
350
627
  "vite.config.ts",
351
628
  "vite.config.mjs",
352
- "vite.config.cjs"
629
+ "vite.config.mts",
630
+ "vite.config.cjs",
631
+ "vite.config.cts",
632
+ "vitest.config.ts",
633
+ "vitest.config.js"
353
634
  ];
354
635
  const EXPO_APP_CONFIG_FILENAMES = [
355
636
  "app.json",
@@ -357,7 +638,7 @@ const EXPO_APP_CONFIG_FILENAMES = [
357
638
  "app.config.ts"
358
639
  ];
359
640
  const REACT_COMPILER_PACKAGE_REFERENCE_PATTERN = /babel-plugin-react-compiler|react-compiler-runtime|eslint-plugin-react-compiler|["']react-compiler["']/;
360
- const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*true\b/;
641
+ const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*(?:true\b|\{)/;
361
642
  const FRAMEWORK_PACKAGES = {
362
643
  next: "nextjs",
363
644
  "@tanstack/react-start": "tanstack-start",
@@ -387,6 +668,7 @@ const countSourceFilesViaFilesystem = (rootDirectory) => {
387
668
  const countSourceFilesViaGit = (rootDirectory) => {
388
669
  const result = spawnSync("git", [
389
670
  "ls-files",
671
+ "-z",
390
672
  "--cached",
391
673
  "--others",
392
674
  "--exclude-standard"
@@ -396,7 +678,7 @@ const countSourceFilesViaGit = (rootDirectory) => {
396
678
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
397
679
  });
398
680
  if (result.error || result.status !== 0) return null;
399
- return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
681
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
400
682
  };
401
683
  const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
402
684
  const collectAllDependencies = (packageJson) => ({
@@ -494,17 +776,17 @@ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogRefer
494
776
  const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
495
777
  const rawVersion = collectAllDependencies(packageJson)[packageName];
496
778
  const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
497
- const raw = packageJson;
498
- if (isPlainObject(raw.catalog)) {
499
- const version = resolveVersionFromCatalog(raw.catalog, packageName);
779
+ if (isPlainObject(packageJson.catalog)) {
780
+ const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
500
781
  if (version) return version;
501
782
  }
502
- if (isPlainObject(raw.catalogs)) {
503
- if (catalogName && isPlainObject(raw.catalogs[catalogName])) {
504
- const version = resolveVersionFromCatalog(raw.catalogs[catalogName], packageName);
783
+ if (isPlainObject(packageJson.catalogs)) {
784
+ const namedCatalog = catalogName ? packageJson.catalogs[catalogName] : void 0;
785
+ if (namedCatalog && isPlainObject(namedCatalog)) {
786
+ const version = resolveVersionFromCatalog(namedCatalog, packageName);
505
787
  if (version) return version;
506
788
  }
507
- for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
789
+ for (const catalogEntries of Object.values(packageJson.catalogs)) if (isPlainObject(catalogEntries)) {
508
790
  const version = resolveVersionFromCatalog(catalogEntries, packageName);
509
791
  if (version) return version;
510
792
  }
@@ -545,11 +827,32 @@ const parsePnpmWorkspacePatterns = (rootDirectory) => {
545
827
  }
546
828
  return patterns;
547
829
  };
830
+ const NX_PROJECT_DISCOVERY_DIRS = [
831
+ "apps",
832
+ "libs",
833
+ "packages"
834
+ ];
835
+ const getNxWorkspaceDirectories = (rootDirectory) => {
836
+ if (!isFile(path.join(rootDirectory, "nx.json"))) return [];
837
+ const collected = [];
838
+ for (const candidate of NX_PROJECT_DISCOVERY_DIRS) {
839
+ const candidatePath = path.join(rootDirectory, candidate);
840
+ if (!fs.existsSync(candidatePath) || !fs.statSync(candidatePath).isDirectory()) continue;
841
+ for (const entry of fs.readdirSync(candidatePath, { withFileTypes: true })) {
842
+ if (!entry.isDirectory()) continue;
843
+ const projectDirectory = path.join(candidatePath, entry.name);
844
+ if (isFile(path.join(projectDirectory, "project.json")) || isFile(path.join(projectDirectory, "package.json"))) collected.push(`${candidate}/${entry.name}`);
845
+ }
846
+ }
847
+ return collected;
848
+ };
548
849
  const getWorkspacePatterns = (rootDirectory, packageJson) => {
549
850
  const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory);
550
851
  if (pnpmPatterns.length > 0) return pnpmPatterns;
551
852
  if (Array.isArray(packageJson.workspaces)) return packageJson.workspaces;
552
853
  if (packageJson.workspaces?.packages) return packageJson.workspaces.packages;
854
+ const nxPatterns = getNxWorkspaceDirectories(rootDirectory);
855
+ if (nxPatterns.length > 0) return nxPatterns;
553
856
  return [];
554
857
  };
555
858
  const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
@@ -612,23 +915,35 @@ const hasCompilerInConfigFile = (filePath) => {
612
915
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
613
916
  };
614
917
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
918
+ const isProjectBoundary$1 = (directory) => {
919
+ if (fs.existsSync(path.join(directory, ".git"))) return true;
920
+ return isMonorepoRoot(directory);
921
+ };
615
922
  const detectReactCompiler = (directory, packageJson) => {
616
923
  if (hasCompilerPackage(packageJson)) return true;
617
924
  if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
618
925
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
619
926
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
620
927
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
928
+ if (isProjectBoundary$1(directory)) return false;
621
929
  let ancestorDirectory = path.dirname(directory);
622
930
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
623
931
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
624
932
  if (isFile(ancestorPackagePath)) {
625
933
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
626
934
  }
935
+ if (isProjectBoundary$1(ancestorDirectory)) return false;
627
936
  ancestorDirectory = path.dirname(ancestorDirectory);
628
937
  }
629
938
  return false;
630
939
  };
940
+ const cachedProjectInfos = /* @__PURE__ */ new Map();
941
+ const clearProjectCache = () => {
942
+ cachedProjectInfos.clear();
943
+ };
631
944
  const discoverProject = (directory) => {
945
+ const cached = cachedProjectInfos.get(directory);
946
+ if (cached !== void 0) return cached;
632
947
  const packageJsonPath = path.join(directory, "package.json");
633
948
  if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
634
949
  const packageJson = readPackageJson(packageJsonPath);
@@ -655,17 +970,57 @@ const discoverProject = (directory) => {
655
970
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
656
971
  const sourceFileCount = countSourceFiles(directory);
657
972
  const hasReactCompiler = detectReactCompiler(directory, packageJson);
658
- return {
973
+ const allDependencies = collectAllDependencies(packageJson);
974
+ const hasTanStackQuery = Object.keys(allDependencies).some((packageName) => TANSTACK_QUERY_PACKAGES.has(packageName));
975
+ const projectInfo = {
659
976
  rootDirectory: directory,
660
977
  projectName,
661
978
  reactVersion,
662
979
  framework,
663
980
  hasTypeScript,
664
981
  hasReactCompiler,
982
+ hasTanStackQuery,
665
983
  sourceFileCount
666
984
  };
985
+ cachedProjectInfos.set(directory, projectInfo);
986
+ return projectInfo;
987
+ };
988
+ //#endregion
989
+ //#region src/utils/validate-config-types.ts
990
+ const BOOLEAN_FIELD_NAMES = [
991
+ "lint",
992
+ "deadCode",
993
+ "verbose",
994
+ "customRulesOnly",
995
+ "share",
996
+ "respectInlineDisables"
997
+ ];
998
+ const warnConfigField = (message) => {
999
+ process.stderr.write(`[react-doctor] ${message}\n`);
1000
+ };
1001
+ const coerceMaybeBooleanString = (fieldName, value) => {
1002
+ if (typeof value === "boolean" || value === void 0) return value;
1003
+ if (value === "true") {
1004
+ warnConfigField(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
1005
+ return true;
1006
+ }
1007
+ if (value === "false") {
1008
+ warnConfigField(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
1009
+ return false;
1010
+ }
1011
+ warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
1012
+ };
1013
+ const validateConfigTypes = (config) => {
1014
+ const validated = { ...config };
1015
+ for (const fieldName of BOOLEAN_FIELD_NAMES) {
1016
+ const original = config[fieldName];
1017
+ if (original === void 0) continue;
1018
+ const coerced = coerceMaybeBooleanString(fieldName, original);
1019
+ if (coerced === void 0) delete validated[fieldName];
1020
+ else validated[fieldName] = coerced;
1021
+ }
1022
+ return validated;
667
1023
  };
668
-
669
1024
  //#endregion
670
1025
  //#region src/utils/load-config.ts
671
1026
  const CONFIG_FILENAME = "react-doctor.config.json";
@@ -675,33 +1030,57 @@ const loadConfigFromDirectory = (directory) => {
675
1030
  if (isFile(configFilePath)) try {
676
1031
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
677
1032
  const parsed = JSON.parse(fileContent);
678
- if (isPlainObject(parsed)) return parsed;
679
- console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
1033
+ if (isPlainObject(parsed)) return validateConfigTypes(parsed);
1034
+ logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
680
1035
  } catch (error) {
681
- console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
1036
+ logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
682
1037
  }
683
1038
  const packageJsonPath = path.join(directory, "package.json");
684
1039
  if (isFile(packageJsonPath)) try {
685
1040
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
686
- const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
687
- if (isPlainObject(embeddedConfig)) return embeddedConfig;
1041
+ const packageJson = JSON.parse(fileContent);
1042
+ if (isPlainObject(packageJson)) {
1043
+ const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
1044
+ if (isPlainObject(embeddedConfig)) return validateConfigTypes(embeddedConfig);
1045
+ }
688
1046
  } catch {
689
1047
  return null;
690
1048
  }
691
1049
  return null;
692
1050
  };
1051
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1052
+ const cachedConfigs = /* @__PURE__ */ new Map();
1053
+ const clearConfigCache = () => {
1054
+ cachedConfigs.clear();
1055
+ };
693
1056
  const loadConfig = (rootDirectory) => {
1057
+ const cached = cachedConfigs.get(rootDirectory);
1058
+ if (cached !== void 0) return cached;
694
1059
  const localConfig = loadConfigFromDirectory(rootDirectory);
695
- if (localConfig) return localConfig;
1060
+ if (localConfig) {
1061
+ cachedConfigs.set(rootDirectory, localConfig);
1062
+ return localConfig;
1063
+ }
1064
+ if (isProjectBoundary(rootDirectory)) {
1065
+ cachedConfigs.set(rootDirectory, null);
1066
+ return null;
1067
+ }
696
1068
  let ancestorDirectory = path.dirname(rootDirectory);
697
1069
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
698
1070
  const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
699
- if (ancestorConfig) return ancestorConfig;
1071
+ if (ancestorConfig) {
1072
+ cachedConfigs.set(rootDirectory, ancestorConfig);
1073
+ return ancestorConfig;
1074
+ }
1075
+ if (isProjectBoundary(ancestorDirectory)) {
1076
+ cachedConfigs.set(rootDirectory, null);
1077
+ return null;
1078
+ }
700
1079
  ancestorDirectory = path.dirname(ancestorDirectory);
701
1080
  }
1081
+ cachedConfigs.set(rootDirectory, null);
702
1082
  return null;
703
1083
  };
704
-
705
1084
  //#endregion
706
1085
  //#region src/utils/read-file-lines-node.ts
707
1086
  const createNodeReadFileLinesSync = (rootDirectory) => {
@@ -714,22 +1093,23 @@ const createNodeReadFileLinesSync = (rootDirectory) => {
714
1093
  }
715
1094
  };
716
1095
  };
717
-
718
1096
  //#endregion
719
1097
  //#region src/utils/resolve-lint-include-paths.ts
720
1098
  const listSourceFilesViaGit = (rootDirectory) => {
721
1099
  const result = spawnSync("git", [
722
1100
  "ls-files",
1101
+ "-z",
723
1102
  "--cached",
724
1103
  "--others",
725
- "--exclude-standard"
1104
+ "--exclude-standard",
1105
+ "--recurse-submodules"
726
1106
  ], {
727
1107
  cwd: rootDirectory,
728
1108
  encoding: "utf-8",
729
1109
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
730
1110
  });
731
1111
  if (result.error || result.status !== 0) return null;
732
- return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1112
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
733
1113
  };
734
1114
  const listSourceFilesViaFilesystem = (rootDirectory) => {
735
1115
  const filePaths = [];
@@ -757,12 +1137,11 @@ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
757
1137
  return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
758
1138
  });
759
1139
  };
760
-
761
1140
  //#endregion
762
1141
  //#region src/core/calculate-score-locally.ts
763
1142
  const getScoreLabel = (score) => {
764
- if (score >= SCORE_GOOD_THRESHOLD) return "Great";
765
- if (score >= SCORE_OK_THRESHOLD) return "Needs work";
1143
+ if (score >= 75) return "Great";
1144
+ if (score >= 50) return "Needs work";
766
1145
  return "Critical";
767
1146
  };
768
1147
  const countUniqueRules = (diagnostics) => {
@@ -780,7 +1159,7 @@ const countUniqueRules = (diagnostics) => {
780
1159
  };
781
1160
  const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
782
1161
  const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
783
- return Math.max(0, Math.round(PERFECT_SCORE - penalty));
1162
+ return Math.max(0, Math.round(100 - penalty));
784
1163
  };
785
1164
  const calculateScoreLocally = (diagnostics) => {
786
1165
  const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
@@ -790,7 +1169,6 @@ const calculateScoreLocally = (diagnostics) => {
790
1169
  label: getScoreLabel(score)
791
1170
  };
792
1171
  };
793
-
794
1172
  //#endregion
795
1173
  //#region src/core/try-score-from-api.ts
796
1174
  const parseScoreResult = (value) => {
@@ -804,44 +1182,51 @@ const parseScoreResult = (value) => {
804
1182
  label: labelValue
805
1183
  };
806
1184
  };
1185
+ const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
1186
+ const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
1187
+ const describeFailure = (error) => {
1188
+ if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
1189
+ if (error instanceof Error && error.message) return error.message;
1190
+ return String(error);
1191
+ };
807
1192
  const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
1193
+ if (typeof fetchImplementation !== "function") return null;
808
1194
  const controller = new AbortController();
809
1195
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
810
1196
  try {
811
1197
  const response = await fetchImplementation(SCORE_API_URL, {
812
1198
  method: "POST",
813
1199
  headers: { "Content-Type": "application/json" },
814
- body: JSON.stringify({ diagnostics }),
1200
+ body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
815
1201
  signal: controller.signal
816
1202
  });
817
- if (!response.ok) return null;
1203
+ if (!response.ok) {
1204
+ console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText} — using local scoring`);
1205
+ return null;
1206
+ }
818
1207
  return parseScoreResult(await response.json());
819
- } catch {
1208
+ } catch (error) {
1209
+ console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)}) — using local scoring`);
820
1210
  return null;
821
1211
  } finally {
822
1212
  clearTimeout(timeoutId);
823
1213
  }
824
1214
  };
825
-
1215
+ //#endregion
1216
+ //#region src/utils/calculate-score-browser.ts
1217
+ const getGlobalFetch = () => typeof fetch === "function" ? fetch : void 0;
1218
+ const calculateScore$1 = async (diagnostics, fetchImplementation = getGlobalFetch()) => await tryScoreFromApi(diagnostics, fetchImplementation) ?? calculateScoreLocally(diagnostics);
826
1219
  //#endregion
827
1220
  //#region src/utils/proxy-fetch.ts
828
1221
  const getGlobalProcess = () => {
829
1222
  const candidate = globalThis.process;
830
1223
  return candidate?.versions?.node ? candidate : void 0;
831
1224
  };
832
- const readEnvProxy = () => {
1225
+ const getProxyUrl = () => {
833
1226
  const proc = getGlobalProcess();
834
1227
  if (!proc?.env) return void 0;
835
1228
  return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
836
1229
  };
837
- let isProxyUrlResolved = false;
838
- let resolvedProxyUrl;
839
- const getProxyUrl = () => {
840
- if (isProxyUrlResolved) return resolvedProxyUrl;
841
- isProxyUrlResolved = true;
842
- resolvedProxyUrl = readEnvProxy();
843
- return resolvedProxyUrl;
844
- };
845
1230
  const createProxyDispatcher = async (proxyUrl) => {
846
1231
  try {
847
1232
  const { ProxyAgent } = await import("undici");
@@ -851,29 +1236,17 @@ const createProxyDispatcher = async (proxyUrl) => {
851
1236
  }
852
1237
  };
853
1238
  const proxyFetch = async (url, init) => {
854
- const controller = new AbortController();
855
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
856
- try {
857
- const proxyUrl = getProxyUrl();
858
- const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
859
- return await fetch(url, {
860
- ...init,
861
- signal: controller.signal,
862
- ...dispatcher ? { dispatcher } : {}
863
- });
864
- } finally {
865
- clearTimeout(timeoutId);
866
- }
1239
+ const proxyUrl = getProxyUrl();
1240
+ const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
1241
+ const fetchInit = {
1242
+ ...init,
1243
+ ...dispatcher ? { dispatcher } : {}
1244
+ };
1245
+ return fetch(url, fetchInit);
867
1246
  };
868
-
869
1247
  //#endregion
870
1248
  //#region src/utils/calculate-score-node.ts
871
- const calculateScore = async (diagnostics) => {
872
- const apiScore = await tryScoreFromApi(diagnostics, proxyFetch);
873
- if (apiScore) return apiScore;
874
- return calculateScoreLocally(diagnostics);
875
- };
876
-
1249
+ const calculateScore = (diagnostics) => calculateScore$1(diagnostics, proxyFetch);
877
1250
  //#endregion
878
1251
  //#region src/utils/collect-unused-file-paths.ts
879
1252
  const collectUnusedFilePaths = (filesIssues) => {
@@ -887,40 +1260,64 @@ const collectUnusedFilePaths = (filesIssues) => {
887
1260
  }
888
1261
  return unusedFilePaths;
889
1262
  };
890
-
1263
+ //#endregion
1264
+ //#region src/utils/extract-failed-plugin-name.ts
1265
+ const PLUGIN_CONFIG_PATTERN = /(?:^|[/\\\s])([a-z][a-z0-9-]*)\.config\./i;
1266
+ const RC_DOTFILE_PATTERN = /(?:^|[/\\])\.([a-z][a-z0-9-]*?)rc(?:\.[a-z]+)?(?:\b|$)/i;
1267
+ const extractFailedPluginName = (error) => {
1268
+ for (const errorMessage of getErrorChainMessages(error)) {
1269
+ const pluginNameMatch = errorMessage.match(PLUGIN_CONFIG_PATTERN);
1270
+ if (pluginNameMatch?.[1]) return pluginNameMatch[1].toLowerCase();
1271
+ const rcMatch = errorMessage.match(RC_DOTFILE_PATTERN);
1272
+ if (rcMatch?.[1]) return rcMatch[1].toLowerCase();
1273
+ }
1274
+ return null;
1275
+ };
1276
+ //#endregion
1277
+ //#region src/utils/has-knip-config.ts
1278
+ const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
891
1279
  //#endregion
892
1280
  //#region src/utils/run-knip.ts
893
- const KNIP_CATEGORY_MAP = {
894
- files: "Dead Code",
895
- exports: "Dead Code",
896
- types: "Dead Code",
897
- duplicates: "Dead Code"
898
- };
899
- const KNIP_MESSAGE_MAP = {
900
- files: "Unused file",
901
- exports: "Unused export",
902
- types: "Unused type",
903
- duplicates: "Duplicate export"
904
- };
905
- const KNIP_SEVERITY_MAP = {
906
- files: "warning",
907
- exports: "warning",
908
- types: "warning",
909
- duplicates: "warning"
1281
+ const KNIP_ISSUE_TYPE_DESCRIPTORS = {
1282
+ files: {
1283
+ category: "Dead Code",
1284
+ message: "Unused file",
1285
+ severity: "warning"
1286
+ },
1287
+ exports: {
1288
+ category: "Dead Code",
1289
+ message: "Unused export",
1290
+ severity: "warning"
1291
+ },
1292
+ types: {
1293
+ category: "Dead Code",
1294
+ message: "Unused type",
1295
+ severity: "warning"
1296
+ },
1297
+ duplicates: {
1298
+ category: "Dead Code",
1299
+ message: "Duplicate export",
1300
+ severity: "warning"
1301
+ }
1302
+ };
1303
+ const FALLBACK_KNIP_DESCRIPTOR = {
1304
+ category: "Dead Code",
1305
+ message: "Issue",
1306
+ severity: "warning"
910
1307
  };
911
1308
  const collectIssueRecords = (records, issueType, rootDirectory) => {
1309
+ const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS[issueType] ?? FALLBACK_KNIP_DESCRIPTOR;
912
1310
  const diagnostics = [];
913
1311
  for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
914
1312
  filePath: path.relative(rootDirectory, issue.filePath),
915
1313
  plugin: "knip",
916
1314
  rule: issueType,
917
- severity: KNIP_SEVERITY_MAP[issueType] ?? "warning",
918
- message: `${KNIP_MESSAGE_MAP[issueType]}: ${issue.symbol}`,
1315
+ severity: descriptor.severity,
1316
+ message: `${descriptor.message}: ${issue.symbol}`,
919
1317
  help: "",
920
1318
  line: 0,
921
1319
  column: 0,
922
- category: KNIP_CATEGORY_MAP[issueType] ?? "Dead Code",
923
- weight: 1
1320
+ category: descriptor.category
924
1321
  });
925
1322
  return diagnostics;
926
1323
  };
@@ -929,10 +1326,11 @@ const silenced = async (fn) => {
929
1326
  const originalInfo = console.info;
930
1327
  const originalWarn = console.warn;
931
1328
  const originalError = console.error;
932
- console.log = () => {};
933
- console.info = () => {};
934
- console.warn = () => {};
935
- console.error = () => {};
1329
+ const noop = () => {};
1330
+ console.log = noop;
1331
+ console.info = noop;
1332
+ console.warn = noop;
1333
+ console.error = noop;
936
1334
  try {
937
1335
  return await fn();
938
1336
  } finally {
@@ -942,12 +1340,15 @@ const silenced = async (fn) => {
942
1340
  console.error = originalError;
943
1341
  }
944
1342
  };
945
- const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
946
- const extractFailedPluginName = (error) => {
947
- return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
1343
+ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
1344
+ const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
1345
+ const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
1346
+ const failedPlugin = extractFailedPluginName(error);
1347
+ if (!failedPlugin || !(failedPlugin in parsedConfig) || disabledPlugins.has(failedPlugin)) return false;
1348
+ disabledPlugins.add(failedPlugin);
1349
+ parsedConfig[failedPlugin] = false;
1350
+ return true;
948
1351
  };
949
- const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
950
- const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
951
1352
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
952
1353
  const tsConfigFile = resolveTsConfigFile(knipCwd);
953
1354
  const options = await silenced(() => createOptions({
@@ -957,45 +1358,48 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
957
1358
  ...tsConfigFile ? { tsConfigFile } : {}
958
1359
  }));
959
1360
  const parsedConfig = options.parsedConfig;
960
- for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
1361
+ const disabledPlugins = /* @__PURE__ */ new Set();
1362
+ let lastKnipError;
1363
+ for (let attempt = 0; attempt < 6; attempt++) try {
961
1364
  return await silenced(() => main(options));
962
1365
  } catch (error) {
963
- const failedPlugin = extractFailedPluginName(error);
964
- if (!failedPlugin || attempt === MAX_KNIP_RETRIES) throw error;
965
- parsedConfig[failedPlugin] = false;
1366
+ lastKnipError = error;
1367
+ if (!tryDisableFailedPlugin(error, parsedConfig, disabledPlugins)) throw error;
966
1368
  }
967
- throw new Error("Unreachable");
1369
+ throw lastKnipError;
968
1370
  };
969
1371
  const hasNodeModules = (directory) => {
970
1372
  const nodeModulesPath = path.join(directory, "node_modules");
971
1373
  return fs.existsSync(nodeModulesPath) && fs.statSync(nodeModulesPath).isDirectory();
972
1374
  };
1375
+ const resolveWorkspaceName = (rootDirectory) => {
1376
+ const packageJsonPath = path.join(rootDirectory, "package.json");
1377
+ return (isFile(packageJsonPath) ? readPackageJson(packageJsonPath) : {}).name ?? path.basename(rootDirectory);
1378
+ };
1379
+ const runKnipForProject = async (rootDirectory, monorepoRoot) => {
1380
+ if (!monorepoRoot || hasKnipConfig(rootDirectory)) return runKnipWithOptions(rootDirectory);
1381
+ try {
1382
+ return await runKnipWithOptions(monorepoRoot, resolveWorkspaceName(rootDirectory));
1383
+ } catch {
1384
+ return runKnipWithOptions(rootDirectory);
1385
+ }
1386
+ };
973
1387
  const runKnip = async (rootDirectory) => {
974
1388
  const monorepoRoot = findMonorepoRoot(rootDirectory);
975
1389
  if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
976
- let knipResult;
977
- if (monorepoRoot) {
978
- const packageJsonPath = path.join(rootDirectory, "package.json");
979
- const workspaceName = (isFile(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
980
- try {
981
- knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
982
- } catch {
983
- knipResult = await runKnipWithOptions(rootDirectory);
984
- }
985
- } else knipResult = await runKnipWithOptions(rootDirectory);
986
- const { issues } = knipResult;
1390
+ const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
987
1391
  const diagnostics = [];
1392
+ const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
988
1393
  for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
989
1394
  filePath: path.relative(rootDirectory, unusedFilePath),
990
1395
  plugin: "knip",
991
1396
  rule: "files",
992
- severity: KNIP_SEVERITY_MAP["files"],
993
- message: KNIP_MESSAGE_MAP["files"],
1397
+ severity: filesDescriptor.severity,
1398
+ message: filesDescriptor.message,
994
1399
  help: "This file is not imported by any other file in the project.",
995
1400
  line: 0,
996
1401
  column: 0,
997
- category: KNIP_CATEGORY_MAP["files"],
998
- weight: 1
1402
+ category: filesDescriptor.category
999
1403
  });
1000
1404
  for (const issueType of [
1001
1405
  "exports",
@@ -1004,7 +1408,29 @@ const runKnip = async (rootDirectory) => {
1004
1408
  ]) diagnostics.push(...collectIssueRecords(issues[issueType], issueType, rootDirectory));
1005
1409
  return diagnostics;
1006
1410
  };
1007
-
1411
+ //#endregion
1412
+ //#region src/utils/batch-include-paths.ts
1413
+ const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1414
+ const batchIncludePaths = (baseArgs, includePaths) => {
1415
+ const baseArgsLength = estimateArgsLength(baseArgs);
1416
+ const batches = [];
1417
+ let currentBatch = [];
1418
+ let currentBatchLength = baseArgsLength;
1419
+ for (const filePath of includePaths) {
1420
+ const entryLength = filePath.length + 1;
1421
+ const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > 24e3;
1422
+ const exceedsFileCount = currentBatch.length >= 500;
1423
+ if (exceedsArgLength || exceedsFileCount) {
1424
+ batches.push(currentBatch);
1425
+ currentBatch = [];
1426
+ currentBatchLength = baseArgsLength;
1427
+ }
1428
+ currentBatch.push(filePath);
1429
+ currentBatchLength += entryLength;
1430
+ }
1431
+ if (currentBatch.length > 0) batches.push(currentBatch);
1432
+ return batches;
1433
+ };
1008
1434
  //#endregion
1009
1435
  //#region src/oxlint-config.ts
1010
1436
  const esmRequire$1 = createRequire(import.meta.url);
@@ -1034,7 +1460,23 @@ const REACT_NATIVE_RULES = {
1034
1460
  "react-doctor/rn-no-inline-flatlist-renderitem": "warn",
1035
1461
  "react-doctor/rn-no-legacy-shadow-styles": "warn",
1036
1462
  "react-doctor/rn-prefer-reanimated": "warn",
1037
- "react-doctor/rn-no-single-element-style-array": "warn"
1463
+ "react-doctor/rn-no-single-element-style-array": "warn",
1464
+ "react-doctor/rn-prefer-pressable": "warn",
1465
+ "react-doctor/rn-prefer-expo-image": "warn",
1466
+ "react-doctor/rn-no-non-native-navigator": "warn",
1467
+ "react-doctor/rn-no-scroll-state": "error",
1468
+ "react-doctor/rn-no-scrollview-mapped-list": "warn",
1469
+ "react-doctor/rn-no-inline-object-in-list-item": "warn",
1470
+ "react-doctor/rn-animate-layout-property": "error",
1471
+ "react-doctor/rn-prefer-content-inset-adjustment": "warn",
1472
+ "react-doctor/rn-pressable-shared-value-mutation": "warn",
1473
+ "react-doctor/rn-list-data-mapped": "warn",
1474
+ "react-doctor/rn-list-callback-per-row": "warn",
1475
+ "react-doctor/rn-list-recyclable-without-types": "warn",
1476
+ "react-doctor/rn-animation-reaction-as-derived": "warn",
1477
+ "react-doctor/rn-bottom-sheet-prefer-native": "warn",
1478
+ "react-doctor/rn-scrollview-dynamic-padding": "warn",
1479
+ "react-doctor/rn-style-prefer-boxshadow": "warn"
1038
1480
  };
1039
1481
  const TANSTACK_START_RULES = {
1040
1482
  "react-doctor/tanstack-start-route-property-order": "error",
@@ -1053,22 +1495,41 @@ const TANSTACK_START_RULES = {
1053
1495
  "react-doctor/tanstack-start-loader-parallel-fetch": "warn"
1054
1496
  };
1055
1497
  const REACT_COMPILER_RULES = {
1056
- "react-hooks-js/set-state-in-render": "error",
1057
- "react-hooks-js/immutability": "error",
1058
- "react-hooks-js/refs": "error",
1059
- "react-hooks-js/purity": "error",
1060
- "react-hooks-js/hooks": "error",
1061
- "react-hooks-js/set-state-in-effect": "error",
1062
- "react-hooks-js/globals": "error",
1063
- "react-hooks-js/error-boundaries": "error",
1064
- "react-hooks-js/preserve-manual-memoization": "error",
1065
- "react-hooks-js/unsupported-syntax": "error",
1066
- "react-hooks-js/component-hook-factories": "error",
1067
- "react-hooks-js/static-components": "error",
1068
- "react-hooks-js/use-memo": "error",
1069
- "react-hooks-js/void-use-memo": "error",
1070
- "react-hooks-js/incompatible-library": "error",
1071
- "react-hooks-js/todo": "error"
1498
+ "react-hooks-js/set-state-in-render": "warn",
1499
+ "react-hooks-js/immutability": "warn",
1500
+ "react-hooks-js/refs": "warn",
1501
+ "react-hooks-js/purity": "warn",
1502
+ "react-hooks-js/hooks": "warn",
1503
+ "react-hooks-js/set-state-in-effect": "warn",
1504
+ "react-hooks-js/globals": "warn",
1505
+ "react-hooks-js/error-boundaries": "warn",
1506
+ "react-hooks-js/preserve-manual-memoization": "warn",
1507
+ "react-hooks-js/unsupported-syntax": "warn",
1508
+ "react-hooks-js/component-hook-factories": "warn",
1509
+ "react-hooks-js/static-components": "warn",
1510
+ "react-hooks-js/use-memo": "warn",
1511
+ "react-hooks-js/void-use-memo": "warn",
1512
+ "react-hooks-js/incompatible-library": "warn",
1513
+ "react-hooks-js/todo": "warn"
1514
+ };
1515
+ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
1516
+ if (!hasReactCompiler || customRulesOnly) return [];
1517
+ try {
1518
+ return [{
1519
+ name: "react-hooks-js",
1520
+ specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1521
+ }];
1522
+ } catch {
1523
+ return [];
1524
+ }
1525
+ };
1526
+ const TANSTACK_QUERY_RULES = {
1527
+ "react-doctor/query-stable-query-client": "warn",
1528
+ "react-doctor/query-no-rest-destructuring": "warn",
1529
+ "react-doctor/query-no-void-query-fn": "warn",
1530
+ "react-doctor/query-no-query-in-effect": "warn",
1531
+ "react-doctor/query-mutation-missing-invalidation": "warn",
1532
+ "react-doctor/query-no-usequery-for-mutation": "warn"
1072
1533
  };
1073
1534
  const BUILTIN_REACT_RULES = {
1074
1535
  "react/rules-of-hooks": "error",
@@ -1100,7 +1561,113 @@ const BUILTIN_A11Y_RULES = {
1100
1561
  "jsx-a11y/no-distracting-elements": "error",
1101
1562
  "jsx-a11y/iframe-has-title": "warn"
1102
1563
  };
1103
- const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRulesOnly = false }) => ({
1564
+ const GLOBAL_REACT_DOCTOR_RULES = {
1565
+ "react-doctor/no-derived-state-effect": "warn",
1566
+ "react-doctor/no-fetch-in-effect": "warn",
1567
+ "react-doctor/no-cascading-set-state": "warn",
1568
+ "react-doctor/no-effect-event-handler": "warn",
1569
+ "react-doctor/no-effect-event-in-deps": "error",
1570
+ "react-doctor/no-prop-callback-in-effect": "warn",
1571
+ "react-doctor/no-derived-useState": "warn",
1572
+ "react-doctor/prefer-useReducer": "warn",
1573
+ "react-doctor/rerender-lazy-state-init": "warn",
1574
+ "react-doctor/rerender-functional-setstate": "warn",
1575
+ "react-doctor/rerender-dependencies": "error",
1576
+ "react-doctor/rerender-state-only-in-handlers": "warn",
1577
+ "react-doctor/rerender-defer-reads-hook": "warn",
1578
+ "react-doctor/advanced-event-handler-refs": "warn",
1579
+ "react-doctor/no-giant-component": "warn",
1580
+ "react-doctor/no-render-in-render": "warn",
1581
+ "react-doctor/no-many-boolean-props": "warn",
1582
+ "react-doctor/no-react19-deprecated-apis": "warn",
1583
+ "react-doctor/no-render-prop-children": "warn",
1584
+ "react-doctor/no-nested-component-definition": "error",
1585
+ "react-doctor/react-compiler-destructure-method": "warn",
1586
+ "react-doctor/no-usememo-simple-expression": "warn",
1587
+ "react-doctor/no-layout-property-animation": "error",
1588
+ "react-doctor/rerender-memo-with-default-value": "warn",
1589
+ "react-doctor/rerender-memo-before-early-return": "warn",
1590
+ "react-doctor/rerender-transitions-scroll": "warn",
1591
+ "react-doctor/rerender-derived-state-from-hook": "warn",
1592
+ "react-doctor/async-defer-await": "warn",
1593
+ "react-doctor/async-await-in-loop": "warn",
1594
+ "react-doctor/rendering-animate-svg-wrapper": "warn",
1595
+ "react-doctor/rendering-hoist-jsx": "warn",
1596
+ "react-doctor/rendering-hydration-mismatch-time": "warn",
1597
+ "react-doctor/no-inline-prop-on-memo-component": "warn",
1598
+ "react-doctor/rendering-hydration-no-flicker": "warn",
1599
+ "react-doctor/rendering-script-defer-async": "warn",
1600
+ "react-doctor/rendering-usetransition-loading": "warn",
1601
+ "react-doctor/no-transition-all": "warn",
1602
+ "react-doctor/no-global-css-variable-animation": "error",
1603
+ "react-doctor/no-large-animated-blur": "warn",
1604
+ "react-doctor/no-scale-from-zero": "warn",
1605
+ "react-doctor/no-permanent-will-change": "warn",
1606
+ "react-doctor/no-eval": "error",
1607
+ "react-doctor/no-secrets-in-client-code": "warn",
1608
+ "react-doctor/no-generic-handler-names": "warn",
1609
+ "react-doctor/js-flatmap-filter": "warn",
1610
+ "react-doctor/js-combine-iterations": "warn",
1611
+ "react-doctor/js-tosorted-immutable": "warn",
1612
+ "react-doctor/js-hoist-regexp": "warn",
1613
+ "react-doctor/js-hoist-intl": "warn",
1614
+ "react-doctor/js-cache-property-access": "warn",
1615
+ "react-doctor/js-length-check-first": "warn",
1616
+ "react-doctor/js-min-max-loop": "warn",
1617
+ "react-doctor/js-set-map-lookups": "warn",
1618
+ "react-doctor/js-batch-dom-css": "warn",
1619
+ "react-doctor/js-index-maps": "warn",
1620
+ "react-doctor/js-cache-storage": "warn",
1621
+ "react-doctor/js-early-exit": "warn",
1622
+ "react-doctor/no-barrel-import": "warn",
1623
+ "react-doctor/no-dynamic-import-path": "warn",
1624
+ "react-doctor/no-full-lodash-import": "warn",
1625
+ "react-doctor/no-moment": "warn",
1626
+ "react-doctor/prefer-dynamic-import": "warn",
1627
+ "react-doctor/use-lazy-motion": "warn",
1628
+ "react-doctor/no-undeferred-third-party": "warn",
1629
+ "react-doctor/no-array-index-as-key": "warn",
1630
+ "react-doctor/no-polymorphic-children": "warn",
1631
+ "react-doctor/rendering-conditional-render": "warn",
1632
+ "react-doctor/rendering-svg-precision": "warn",
1633
+ "react-doctor/no-prevent-default": "warn",
1634
+ "react-doctor/no-document-start-view-transition": "warn",
1635
+ "react-doctor/no-flush-sync": "warn",
1636
+ "react-doctor/server-auth-actions": "error",
1637
+ "react-doctor/server-after-nonblocking": "warn",
1638
+ "react-doctor/server-no-mutable-module-state": "error",
1639
+ "react-doctor/server-cache-with-object-literal": "warn",
1640
+ "react-doctor/server-hoist-static-io": "warn",
1641
+ "react-doctor/server-dedup-props": "warn",
1642
+ "react-doctor/server-sequential-independent-await": "warn",
1643
+ "react-doctor/server-fetch-without-revalidate": "warn",
1644
+ "react-doctor/client-passive-event-listeners": "warn",
1645
+ "react-doctor/client-localstorage-no-version": "warn",
1646
+ "react-doctor/no-inline-bounce-easing": "warn",
1647
+ "react-doctor/no-z-index-9999": "warn",
1648
+ "react-doctor/no-inline-exhaustive-style": "warn",
1649
+ "react-doctor/no-side-tab-border": "warn",
1650
+ "react-doctor/no-pure-black-background": "warn",
1651
+ "react-doctor/no-gradient-text": "warn",
1652
+ "react-doctor/no-dark-mode-glow": "warn",
1653
+ "react-doctor/no-justified-text": "warn",
1654
+ "react-doctor/no-tiny-text": "warn",
1655
+ "react-doctor/no-wide-letter-spacing": "warn",
1656
+ "react-doctor/no-gray-on-colored-background": "warn",
1657
+ "react-doctor/no-layout-transition-inline": "warn",
1658
+ "react-doctor/no-disabled-zoom": "error",
1659
+ "react-doctor/no-outline-none": "warn",
1660
+ "react-doctor/no-long-transition-duration": "warn",
1661
+ "react-doctor/async-parallel": "warn"
1662
+ };
1663
+ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
1664
+ ...Object.keys(GLOBAL_REACT_DOCTOR_RULES),
1665
+ ...Object.keys(NEXTJS_RULES),
1666
+ ...Object.keys(REACT_NATIVE_RULES),
1667
+ ...Object.keys(TANSTACK_START_RULES),
1668
+ ...Object.keys(TANSTACK_QUERY_RULES)
1669
+ ]);
1670
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false }) => ({
1104
1671
  categories: {
1105
1672
  correctness: "off",
1106
1673
  suspicious: "off",
@@ -1110,88 +1677,23 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRul
1110
1677
  style: "off",
1111
1678
  nursery: "off"
1112
1679
  },
1113
- plugins: [
1114
- "react",
1115
- "jsx-a11y",
1116
- ...hasReactCompiler ? [] : ["react-perf"]
1117
- ],
1118
- jsPlugins: [...hasReactCompiler && !customRulesOnly ? [{
1119
- name: "react-hooks-js",
1120
- specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1121
- }] : [], pluginPath],
1680
+ plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
1681
+ jsPlugins: [...resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly), pluginPath],
1122
1682
  rules: {
1123
1683
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
1124
1684
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
1125
1685
  ...hasReactCompiler && !customRulesOnly ? REACT_COMPILER_RULES : {},
1126
- "react-doctor/no-derived-state-effect": "error",
1127
- "react-doctor/no-fetch-in-effect": "error",
1128
- "react-doctor/no-cascading-set-state": "warn",
1129
- "react-doctor/no-effect-event-handler": "warn",
1130
- "react-doctor/no-derived-useState": "warn",
1131
- "react-doctor/prefer-useReducer": "warn",
1132
- "react-doctor/rerender-lazy-state-init": "warn",
1133
- "react-doctor/rerender-functional-setstate": "warn",
1134
- "react-doctor/rerender-dependencies": "error",
1135
- "react-doctor/no-giant-component": "warn",
1136
- "react-doctor/no-render-in-render": "warn",
1137
- "react-doctor/no-nested-component-definition": "error",
1138
- "react-doctor/no-usememo-simple-expression": "warn",
1139
- "react-doctor/no-layout-property-animation": "error",
1140
- "react-doctor/rerender-memo-with-default-value": "warn",
1141
- "react-doctor/rendering-animate-svg-wrapper": "warn",
1142
- "react-doctor/no-inline-prop-on-memo-component": "warn",
1143
- "react-doctor/rendering-hydration-no-flicker": "warn",
1144
- "react-doctor/rendering-script-defer-async": "warn",
1145
- "react-doctor/no-transition-all": "warn",
1146
- "react-doctor/no-global-css-variable-animation": "error",
1147
- "react-doctor/no-large-animated-blur": "warn",
1148
- "react-doctor/no-scale-from-zero": "warn",
1149
- "react-doctor/no-permanent-will-change": "warn",
1150
- "react-doctor/no-secrets-in-client-code": "error",
1151
- "react-doctor/js-flatmap-filter": "warn",
1152
- "react-doctor/no-barrel-import": "warn",
1153
- "react-doctor/no-full-lodash-import": "warn",
1154
- "react-doctor/no-moment": "warn",
1155
- "react-doctor/prefer-dynamic-import": "warn",
1156
- "react-doctor/use-lazy-motion": "warn",
1157
- "react-doctor/no-undeferred-third-party": "warn",
1158
- "react-doctor/no-array-index-as-key": "warn",
1159
- "react-doctor/rendering-conditional-render": "warn",
1160
- "react-doctor/no-prevent-default": "warn",
1161
- "react-doctor/server-auth-actions": "error",
1162
- "react-doctor/server-after-nonblocking": "warn",
1163
- "react-doctor/client-passive-event-listeners": "warn",
1164
- "react-doctor/query-stable-query-client": "error",
1165
- "react-doctor/query-no-rest-destructuring": "warn",
1166
- "react-doctor/query-no-void-query-fn": "warn",
1167
- "react-doctor/query-no-query-in-effect": "warn",
1168
- "react-doctor/query-mutation-missing-invalidation": "warn",
1169
- "react-doctor/query-no-usequery-for-mutation": "warn",
1170
- "react-doctor/no-inline-bounce-easing": "warn",
1171
- "react-doctor/no-z-index-9999": "warn",
1172
- "react-doctor/no-inline-exhaustive-style": "warn",
1173
- "react-doctor/no-side-tab-border": "warn",
1174
- "react-doctor/no-pure-black-background": "warn",
1175
- "react-doctor/no-gradient-text": "warn",
1176
- "react-doctor/no-dark-mode-glow": "warn",
1177
- "react-doctor/no-justified-text": "warn",
1178
- "react-doctor/no-tiny-text": "warn",
1179
- "react-doctor/no-wide-letter-spacing": "warn",
1180
- "react-doctor/no-gray-on-colored-background": "warn",
1181
- "react-doctor/no-layout-transition-inline": "warn",
1182
- "react-doctor/no-disabled-zoom": "error",
1183
- "react-doctor/no-outline-none": "warn",
1184
- "react-doctor/no-long-transition-duration": "warn",
1185
- "react-doctor/async-parallel": "warn",
1686
+ ...GLOBAL_REACT_DOCTOR_RULES,
1186
1687
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1187
1688
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
1188
- ...framework === "tanstack-start" ? TANSTACK_START_RULES : {}
1689
+ ...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
1690
+ ...hasTanStackQuery ? TANSTACK_QUERY_RULES : {}
1189
1691
  }
1190
1692
  });
1191
-
1192
1693
  //#endregion
1193
1694
  //#region src/utils/neutralize-disable-directives.ts
1194
- const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1695
+ const DISABLE_DIRECTIVE_PATTERN = /(eslint|oxlint)-disable/;
1696
+ const findFilesWithDisableDirectivesViaGit = (rootDirectory, includePaths) => {
1195
1697
  const grepArgs = [
1196
1698
  "grep",
1197
1699
  "-l",
@@ -1205,14 +1707,65 @@ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1205
1707
  encoding: "utf-8",
1206
1708
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
1207
1709
  });
1208
- if (result.error || result.status === null) return [];
1209
- if (result.status !== 0 && result.stdout.trim().length === 0) return [];
1710
+ if (result.error || result.status === null) return null;
1711
+ if (result.status === 128) return null;
1210
1712
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1211
1713
  };
1714
+ const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
1715
+ const matches = [];
1716
+ const checkFile = (relativePath) => {
1717
+ if (!SOURCE_FILE_PATTERN.test(relativePath)) return;
1718
+ const absolutePath = path.join(rootDirectory, relativePath);
1719
+ let content;
1720
+ try {
1721
+ content = fs.readFileSync(absolutePath, "utf-8");
1722
+ } catch {
1723
+ return;
1724
+ }
1725
+ if (DISABLE_DIRECTIVE_PATTERN.test(content)) matches.push(relativePath);
1726
+ };
1727
+ if (includePaths && includePaths.length > 0) {
1728
+ for (const candidate of includePaths) checkFile(candidate);
1729
+ return matches;
1730
+ }
1731
+ const stack = [rootDirectory];
1732
+ while (stack.length > 0) {
1733
+ const current = stack.pop();
1734
+ if (current === void 0) continue;
1735
+ let entries;
1736
+ try {
1737
+ entries = fs.readdirSync(current, { withFileTypes: true });
1738
+ } catch {
1739
+ continue;
1740
+ }
1741
+ for (const entry of entries) {
1742
+ if (entry.isDirectory()) {
1743
+ if (entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
1744
+ stack.push(path.join(current, entry.name));
1745
+ continue;
1746
+ }
1747
+ if (!entry.isFile()) continue;
1748
+ const absolute = path.join(current, entry.name);
1749
+ checkFile(path.relative(rootDirectory, absolute));
1750
+ }
1751
+ }
1752
+ return matches;
1753
+ };
1754
+ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => findFilesWithDisableDirectivesViaGit(rootDirectory, includePaths) ?? findFilesWithDisableDirectivesViaFilesystem(rootDirectory, includePaths);
1212
1755
  const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
1213
1756
  const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1214
1757
  const filePaths = findFilesWithDisableDirectives(rootDirectory, includePaths);
1215
1758
  const originalContents = /* @__PURE__ */ new Map();
1759
+ let isRestored = false;
1760
+ const restore = () => {
1761
+ if (isRestored) return;
1762
+ isRestored = true;
1763
+ for (const [absolutePath, originalContent] of originalContents) try {
1764
+ fs.writeFileSync(absolutePath, originalContent);
1765
+ } catch {}
1766
+ };
1767
+ const onExit = () => restore();
1768
+ process.once("exit", onExit);
1216
1769
  for (const relativePath of filePaths) {
1217
1770
  const absolutePath = path.join(rootDirectory, relativePath);
1218
1771
  let originalContent;
@@ -1228,10 +1781,10 @@ const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1228
1781
  }
1229
1782
  }
1230
1783
  return () => {
1231
- for (const [absolutePath, originalContent] of originalContents) fs.writeFileSync(absolutePath, originalContent);
1784
+ restore();
1785
+ process.removeListener("exit", onExit);
1232
1786
  };
1233
1787
  };
1234
-
1235
1788
  //#endregion
1236
1789
  //#region src/utils/run-oxlint.ts
1237
1790
  const esmRequire = createRequire(import.meta.url);
@@ -1239,30 +1792,48 @@ const PLUGIN_CATEGORY_MAP = {
1239
1792
  react: "Correctness",
1240
1793
  "react-hooks": "Correctness",
1241
1794
  "react-hooks-js": "React Compiler",
1242
- "react-perf": "Performance",
1243
- "jsx-a11y": "Accessibility"
1795
+ "react-doctor": "Other",
1796
+ "jsx-a11y": "Accessibility",
1797
+ knip: "Dead Code"
1244
1798
  };
1245
1799
  const RULE_CATEGORY_MAP = {
1246
1800
  "react-doctor/no-derived-state-effect": "State & Effects",
1247
1801
  "react-doctor/no-fetch-in-effect": "State & Effects",
1248
1802
  "react-doctor/no-cascading-set-state": "State & Effects",
1249
1803
  "react-doctor/no-effect-event-handler": "State & Effects",
1804
+ "react-doctor/no-effect-event-in-deps": "State & Effects",
1805
+ "react-doctor/no-prop-callback-in-effect": "State & Effects",
1250
1806
  "react-doctor/no-derived-useState": "State & Effects",
1251
1807
  "react-doctor/prefer-useReducer": "State & Effects",
1252
1808
  "react-doctor/rerender-lazy-state-init": "Performance",
1253
1809
  "react-doctor/rerender-functional-setstate": "Performance",
1254
1810
  "react-doctor/rerender-dependencies": "State & Effects",
1811
+ "react-doctor/rerender-state-only-in-handlers": "Performance",
1812
+ "react-doctor/rerender-defer-reads-hook": "Performance",
1813
+ "react-doctor/advanced-event-handler-refs": "Performance",
1255
1814
  "react-doctor/no-generic-handler-names": "Architecture",
1256
1815
  "react-doctor/no-giant-component": "Architecture",
1816
+ "react-doctor/no-many-boolean-props": "Architecture",
1817
+ "react-doctor/no-react19-deprecated-apis": "Architecture",
1818
+ "react-doctor/no-render-prop-children": "Architecture",
1257
1819
  "react-doctor/no-render-in-render": "Architecture",
1258
1820
  "react-doctor/no-nested-component-definition": "Correctness",
1821
+ "react-doctor/react-compiler-destructure-method": "Architecture",
1259
1822
  "react-doctor/no-usememo-simple-expression": "Performance",
1260
1823
  "react-doctor/no-layout-property-animation": "Performance",
1261
1824
  "react-doctor/rerender-memo-with-default-value": "Performance",
1825
+ "react-doctor/rerender-memo-before-early-return": "Performance",
1826
+ "react-doctor/rerender-transitions-scroll": "Performance",
1827
+ "react-doctor/rerender-derived-state-from-hook": "Performance",
1828
+ "react-doctor/async-defer-await": "Performance",
1829
+ "react-doctor/async-await-in-loop": "Performance",
1262
1830
  "react-doctor/rendering-animate-svg-wrapper": "Performance",
1831
+ "react-doctor/rendering-hoist-jsx": "Performance",
1832
+ "react-doctor/rendering-hydration-mismatch-time": "Correctness",
1263
1833
  "react-doctor/rendering-usetransition-loading": "Performance",
1264
1834
  "react-doctor/rendering-hydration-no-flicker": "Performance",
1265
1835
  "react-doctor/rendering-script-defer-async": "Performance",
1836
+ "react-doctor/no-inline-prop-on-memo-component": "Performance",
1266
1837
  "react-doctor/no-transition-all": "Performance",
1267
1838
  "react-doctor/no-global-css-variable-animation": "Performance",
1268
1839
  "react-doctor/no-large-animated-blur": "Performance",
@@ -1270,14 +1841,19 @@ const RULE_CATEGORY_MAP = {
1270
1841
  "react-doctor/no-permanent-will-change": "Performance",
1271
1842
  "react-doctor/no-secrets-in-client-code": "Security",
1272
1843
  "react-doctor/no-barrel-import": "Bundle Size",
1844
+ "react-doctor/no-dynamic-import-path": "Bundle Size",
1273
1845
  "react-doctor/no-full-lodash-import": "Bundle Size",
1274
1846
  "react-doctor/no-moment": "Bundle Size",
1275
1847
  "react-doctor/prefer-dynamic-import": "Bundle Size",
1276
1848
  "react-doctor/use-lazy-motion": "Bundle Size",
1277
1849
  "react-doctor/no-undeferred-third-party": "Bundle Size",
1278
1850
  "react-doctor/no-array-index-as-key": "Correctness",
1851
+ "react-doctor/no-polymorphic-children": "Architecture",
1279
1852
  "react-doctor/rendering-conditional-render": "Correctness",
1853
+ "react-doctor/rendering-svg-precision": "Performance",
1280
1854
  "react-doctor/no-prevent-default": "Correctness",
1855
+ "react-doctor/no-document-start-view-transition": "Correctness",
1856
+ "react-doctor/no-flush-sync": "Performance",
1281
1857
  "react-doctor/nextjs-no-img-element": "Next.js",
1282
1858
  "react-doctor/nextjs-async-client-component": "Next.js",
1283
1859
  "react-doctor/nextjs-no-a-element": "Next.js",
@@ -1296,7 +1872,14 @@ const RULE_CATEGORY_MAP = {
1296
1872
  "react-doctor/nextjs-no-side-effect-in-get-handler": "Security",
1297
1873
  "react-doctor/server-auth-actions": "Server",
1298
1874
  "react-doctor/server-after-nonblocking": "Server",
1875
+ "react-doctor/server-no-mutable-module-state": "Server",
1876
+ "react-doctor/server-cache-with-object-literal": "Server",
1877
+ "react-doctor/server-hoist-static-io": "Server",
1878
+ "react-doctor/server-dedup-props": "Server",
1879
+ "react-doctor/server-sequential-independent-await": "Server",
1880
+ "react-doctor/server-fetch-without-revalidate": "Server",
1299
1881
  "react-doctor/client-passive-event-listeners": "Performance",
1882
+ "react-doctor/client-localstorage-no-version": "Correctness",
1300
1883
  "react-doctor/query-stable-query-client": "TanStack Query",
1301
1884
  "react-doctor/query-no-rest-destructuring": "TanStack Query",
1302
1885
  "react-doctor/query-no-void-query-fn": "TanStack Query",
@@ -1319,6 +1902,19 @@ const RULE_CATEGORY_MAP = {
1319
1902
  "react-doctor/no-outline-none": "Accessibility",
1320
1903
  "react-doctor/no-long-transition-duration": "Performance",
1321
1904
  "react-doctor/js-flatmap-filter": "Performance",
1905
+ "react-doctor/js-combine-iterations": "Performance",
1906
+ "react-doctor/js-tosorted-immutable": "Performance",
1907
+ "react-doctor/js-hoist-regexp": "Performance",
1908
+ "react-doctor/js-hoist-intl": "Performance",
1909
+ "react-doctor/js-cache-property-access": "Performance",
1910
+ "react-doctor/js-length-check-first": "Performance",
1911
+ "react-doctor/js-min-max-loop": "Performance",
1912
+ "react-doctor/js-set-map-lookups": "Performance",
1913
+ "react-doctor/js-batch-dom-css": "Performance",
1914
+ "react-doctor/js-index-maps": "Performance",
1915
+ "react-doctor/js-cache-storage": "Performance",
1916
+ "react-doctor/js-early-exit": "Performance",
1917
+ "react-doctor/no-eval": "Security",
1322
1918
  "react-doctor/async-parallel": "Performance",
1323
1919
  "react-doctor/rn-no-raw-text": "React Native",
1324
1920
  "react-doctor/rn-no-deprecated-modules": "React Native",
@@ -1328,6 +1924,22 @@ const RULE_CATEGORY_MAP = {
1328
1924
  "react-doctor/rn-no-legacy-shadow-styles": "React Native",
1329
1925
  "react-doctor/rn-prefer-reanimated": "React Native",
1330
1926
  "react-doctor/rn-no-single-element-style-array": "React Native",
1927
+ "react-doctor/rn-prefer-pressable": "React Native",
1928
+ "react-doctor/rn-prefer-expo-image": "React Native",
1929
+ "react-doctor/rn-no-non-native-navigator": "React Native",
1930
+ "react-doctor/rn-no-scroll-state": "React Native",
1931
+ "react-doctor/rn-no-scrollview-mapped-list": "React Native",
1932
+ "react-doctor/rn-no-inline-object-in-list-item": "React Native",
1933
+ "react-doctor/rn-animate-layout-property": "React Native",
1934
+ "react-doctor/rn-prefer-content-inset-adjustment": "React Native",
1935
+ "react-doctor/rn-pressable-shared-value-mutation": "React Native",
1936
+ "react-doctor/rn-list-data-mapped": "React Native",
1937
+ "react-doctor/rn-list-callback-per-row": "React Native",
1938
+ "react-doctor/rn-list-recyclable-without-types": "React Native",
1939
+ "react-doctor/rn-animation-reaction-as-derived": "React Native",
1940
+ "react-doctor/rn-bottom-sheet-prefer-native": "React Native",
1941
+ "react-doctor/rn-scrollview-dynamic-padding": "React Native",
1942
+ "react-doctor/rn-style-prefer-boxshadow": "React Native",
1331
1943
  "react-doctor/tanstack-start-route-property-order": "TanStack Start",
1332
1944
  "react-doctor/tanstack-start-no-direct-fetch-in-loader": "TanStack Start",
1333
1945
  "react-doctor/tanstack-start-server-fn-validate-input": "TanStack Start",
@@ -1353,17 +1965,44 @@ const RULE_HELP_MAP = {
1353
1965
  "rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
1354
1966
  "rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
1355
1967
  "rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
1968
+ "no-effect-event-in-deps": "Call the useEffectEvent callback inside the effect body without listing it; its identity is intentionally unstable",
1969
+ "no-prop-callback-in-effect": "Lift the shared state into a Provider so both sides read the same source — no useEffect-driven sync needed",
1356
1970
  "no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
1357
1971
  "no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
1972
+ "no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
1973
+ "no-react19-deprecated-apis": "Pass `ref` as a regular prop on function components — `forwardRef` is no longer needed in React 19+. Replace `useContext(X)` with `use(X)` for branch-aware context reads.",
1974
+ "no-render-prop-children": "Replace `renderXxx` props with compound subcomponents (e.g. `<Modal.Header>`) or `children` so the parent doesn't dictate every customization point",
1358
1975
  "no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
1359
1976
  "no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
1360
1977
  "no-usememo-simple-expression": "Remove useMemo — property access, math, and ternaries are already cheap without memoization",
1361
1978
  "no-layout-property-animation": "Use `transform: translateX()` or `scale()` instead — they run on the compositor and skip layout/paint",
1362
1979
  "rerender-memo-with-default-value": "Move to module scope: `const EMPTY_ITEMS: Item[] = []` then use as the default value",
1363
1980
  "rendering-animate-svg-wrapper": "Wrap the SVG: `<motion.div animate={...}><svg>...</svg></motion.div>`",
1981
+ "rendering-hoist-jsx": "Move the static JSX to module scope: `const ICON = <svg>...</svg>` outside the component so it isn't recreated each render",
1982
+ "rerender-memo-before-early-return": "Extract the JSX into a memoized child component so the parent's early return short-circuits before the child renders",
1983
+ "rerender-transitions-scroll": "Wrap the setState in startTransition (mark as non-urgent), use useDeferredValue, or stash in a ref + rAF throttle so scroll/pointer events don't trigger a re-render per fire",
1984
+ "rerender-state-only-in-handlers": "Replace useState with useRef when the value is only mutated and never read in render — `ref.current = ...` updates without re-rendering the component",
1985
+ "rerender-defer-reads-hook": "Read the URL state inside the handler (e.g. `new URL(window.location.href).searchParams`) so the component doesn't subscribe and re-render on every URL change",
1986
+ "rerender-derived-state-from-hook": "Use a threshold/media-query hook (e.g. `useMediaQuery(\"(max-width: 767px)\")`) — the component re-renders only when the threshold flips, not every pixel",
1987
+ "advanced-event-handler-refs": "Store the handler in a ref and have the listener read `handlerRef.current()` — the subscription stays put while the latest handler is always called",
1988
+ "async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
1989
+ "async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
1990
+ "react-compiler-destructure-method": "Destructure the method up front: `const { push } = useRouter()` then call `push(...)` directly — clearer dependency graph and easier for React Compiler to memoize",
1991
+ "client-localstorage-no-version": "Bake a version into the storage key (e.g. \"myKey:v1\"); a future schema change can ignore old data instead of crashing on it",
1992
+ "server-sequential-independent-await": "Wrap independent awaits in `Promise.all([...])` so they race instead of waterfalling — second call doesn't depend on the first",
1993
+ "server-fetch-without-revalidate": "Pass `{ next: { revalidate: <seconds> } }` (or `cache: \"no-store\"` / `next: { tags: [...] }`) so stale cached data doesn't silently persist",
1994
+ "rn-list-callback-per-row": "Hoist the handler with useCallback at list scope and pass the row id as a primitive prop, so the row's memo() shallow-compare actually hits",
1995
+ "rn-list-recyclable-without-types": "Add `getItemType={item => item.kind}` so FlashList keeps separate recycle pools per item type — heterogeneous rows shouldn't share recycled cells",
1996
+ "rn-style-prefer-boxshadow": "Use the cross-platform CSS `boxShadow` string (RN v7+): `boxShadow: \"0 2px 8px rgba(0,0,0,0.1)\"` instead of platform-specific shadow*/elevation keys",
1997
+ "rendering-hydration-mismatch-time": "Wrap dynamic time/random values in useEffect+useState (client-only) or add suppressHydrationWarning to the parent if intentional",
1998
+ "no-polymorphic-children": "Expose explicit subcomponents (`<Button.Text>`, `<Button.Icon>`) so consumers don't need to switch on `typeof children`",
1999
+ "rendering-svg-precision": "Truncate path/points/transform decimals to 1–2 digits — sub-pixel precision adds bytes with no visible difference",
2000
+ "no-document-start-view-transition": "Render a <ViewTransition> component and update inside startTransition / useDeferredValue — React calls startViewTransition for you",
2001
+ "no-flush-sync": "Use startTransition for non-urgent updates — flushSync forces a sync flush that skips View Transitions and concurrent rendering",
1364
2002
  "rendering-usetransition-loading": "Replace with `const [isPending, startTransition] = useTransition()` — avoids a re-render for the loading state",
1365
2003
  "rendering-hydration-no-flicker": "Use `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)` or add `suppressHydrationWarning` to the element",
1366
2004
  "rendering-script-defer-async": "Add `defer` for DOM-dependent scripts or `async` for independent ones (analytics). In Next.js, use `<Script strategy=\"afterInteractive\" />` instead",
2005
+ "no-inline-prop-on-memo-component": "Hoist the inline `() => ...` / `[]` / `{}` to a stable reference (useMemo, useCallback, or module scope) so the memoized child doesn't re-render every parent render",
1367
2006
  "no-transition-all": "List specific properties: `transition: \"opacity 200ms, transform 200ms\"` — or in Tailwind use `transition-colors`, `transition-opacity`, or `transition-transform`",
1368
2007
  "no-global-css-variable-animation": "Set the variable on the nearest element instead of a parent, or use `@property` with `inherits: false` to prevent cascade. Better yet, use targeted `element.style.transform` updates",
1369
2008
  "no-large-animated-blur": "Keep blur radius under 10px, or apply blur to a smaller element. Large blurs multiply GPU memory usage with layer size",
@@ -1371,6 +2010,7 @@ const RULE_HELP_MAP = {
1371
2010
  "no-permanent-will-change": "Add will-change on animation start (`onMouseEnter`) and remove on end (`onAnimationEnd`). Permanent promotion wastes GPU memory and can degrade performance",
1372
2011
  "no-secrets-in-client-code": "Move to server-side `process.env.SECRET_NAME`. Only `NEXT_PUBLIC_*` vars are safe for the client (and should not contain secrets)",
1373
2012
  "no-barrel-import": "Import from the direct path: `import { Button } from './components/Button'` instead of `./components`",
2013
+ "no-dynamic-import-path": "Use a string-literal path: `import('./feature/heavy.js')` so the bundler can split this chunk",
1374
2014
  "no-full-lodash-import": "Import the specific function: `import debounce from 'lodash/debounce'` — saves ~70kb",
1375
2015
  "no-moment": "Replace with `import { format } from 'date-fns'` (tree-shakeable) or `import dayjs from 'dayjs'` (2kb)",
1376
2016
  "prefer-dynamic-import": "Use `const Component = dynamic(() => import('library'), { ssr: false })` from next/dynamic or React.lazy()",
@@ -1412,7 +2052,11 @@ const RULE_HELP_MAP = {
1412
2052
  "nextjs-no-side-effect-in-get-handler": "Move the side effect to a POST handler and use a <form> or fetch with method POST — GET requests can be triggered by prefetching and are vulnerable to CSRF",
1413
2053
  "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
1414
2054
  "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
1415
- "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
2055
+ "server-no-mutable-module-state": "Move per-request data into the action body, headers/cookies, or a request-scope (React.cache, AsyncLocalStorage). Module-scope `let`/`var` is shared across requests.",
2056
+ "server-cache-with-object-literal": "Pass primitives to React.cache()-wrapped functions — argument identity (not deep equality) is the dedup key, so a fresh `{}` per render bypasses the cache",
2057
+ "server-hoist-static-io": "Hoist the read to module scope: `const FONT_DATA = await fetch(new URL('./fonts/Inter.ttf', import.meta.url)).then(r => r.arrayBuffer())` runs once at module load",
2058
+ "server-dedup-props": "Pass the source array once and derive the projection on the client — passing both doubles RSC serialization bytes",
2059
+ "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`. Only do this if the handler does NOT call `event.preventDefault()` — passive listeners silently ignore `preventDefault()`, which breaks features like pull-to-refresh suppression, custom gestures, and nested-scroll containment.",
1416
2060
  "query-stable-query-client": "Move `new QueryClient()` to module scope or wrap in `useState(() => new QueryClient())` — recreating it on every render resets the entire cache",
1417
2061
  "query-no-rest-destructuring": "Destructure only the fields you need: `const { data, isLoading } = useQuery(...)` — rest destructuring subscribes to all fields and causes extra re-renders",
1418
2062
  "query-no-void-query-fn": "queryFn must return a value for the cache. Use the `enabled` option to conditionally disable the query instead of returning undefined",
@@ -1420,6 +2064,19 @@ const RULE_HELP_MAP = {
1420
2064
  "query-mutation-missing-invalidation": "Add `onSuccess: () => queryClient.invalidateQueries({ queryKey: ['...'] })` so cached data stays in sync after the mutation",
1421
2065
  "query-no-usequery-for-mutation": "Use `useMutation()` for POST/PUT/DELETE — it provides onSuccess/onError callbacks, doesn't auto-refetch, and correctly models write operations",
1422
2066
  "js-flatmap-filter": "Use `.flatMap(item => condition ? [value] : [])` — transforms and filters in a single pass instead of creating an intermediate array",
2067
+ "js-hoist-intl": "Hoist `new Intl.NumberFormat(...)` to module scope or wrap in `useMemo` — Intl constructors allocate dozens of objects per locale lookup",
2068
+ "js-cache-property-access": "Hoist the deep member access into a const at the top of the loop body: `const { x, y } = obj.deeply.nested`",
2069
+ "js-length-check-first": "Short-circuit with `a.length === b.length && a.every((x, i) => x === b[i])` — unequal-length arrays exit immediately",
2070
+ "js-combine-iterations": "Combine `.map().filter()` (or similar chains) into a single pass with `.reduce()` or a `for...of` loop to avoid iterating the array twice",
2071
+ "js-tosorted-immutable": "Use `array.toSorted()` (ES2023) instead of `[...array].sort()` for immutable sorting without the spread allocation",
2072
+ "js-hoist-regexp": "Hoist `new RegExp(...)` (or large regex literals) to a module-level constant so it isn't recompiled on every loop iteration",
2073
+ "js-min-max-loop": "Use `Math.min(...array)` / `Math.max(...array)` instead of sorting just to read the first or last element",
2074
+ "js-set-map-lookups": "Use a `Set` or `Map` for repeated membership tests / keyed lookups — `Array.includes`/`find` is O(n) per call",
2075
+ "js-batch-dom-css": "Batch DOM/CSS reads and writes — interleaving them inside a loop causes layout thrashing. Read first, then write",
2076
+ "js-index-maps": "Build an index `Map` once outside the loop instead of `array.find(...)` inside it",
2077
+ "js-cache-storage": "Cache repeated `localStorage`/`sessionStorage` reads in a local variable — each access serializes/deserializes",
2078
+ "js-early-exit": "Add an early `return` / `continue` to flatten deep nesting and short-circuit when the predicate is already known",
2079
+ "no-eval": "Use `JSON.parse` for serialized data, `Function(...)` (still careful) for trusted templates, or refactor to avoid dynamic code execution",
1423
2080
  "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
1424
2081
  "rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
1425
2082
  "rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
@@ -1429,6 +2086,19 @@ const RULE_HELP_MAP = {
1429
2086
  "rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
1430
2087
  "rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
1431
2088
  "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation",
2089
+ "rn-prefer-pressable": "Use `<Pressable>` from react-native (or react-native-gesture-handler) instead of legacy Touchable* components",
2090
+ "rn-prefer-expo-image": "Use `<Image>` from `expo-image` instead of `react-native` — same prop API, plus disk + memory caching, placeholders, and crossfades",
2091
+ "rn-no-non-native-navigator": "Use `@react-navigation/native-stack` (or `native-tabs` in v7+) for platform-native transitions and gestures",
2092
+ "rn-no-scroll-state": "Track scroll position with a Reanimated shared value (`useAnimatedScrollHandler`) or a ref — `setState` on every scroll event causes re-render storms",
2093
+ "rn-no-scrollview-mapped-list": "Use FlashList, LegendList, or FlatList — `<ScrollView>{items.map(...)}</ScrollView>` mounts every row in memory",
2094
+ "rn-no-inline-object-in-list-item": "Hoist style/object props outside renderItem (StyleSheet.create, useMemo at list scope, or pass primitives) so memo() row components stop bailing",
2095
+ "rn-animate-layout-property": "Animate `transform: [{ translateX/Y }, { scale }]` and `opacity` instead of layout props — layout runs on the JS thread; transform/opacity run on the GPU compositor",
2096
+ "rn-prefer-content-inset-adjustment": "Drop the SafeAreaView wrapper and set `contentInsetAdjustmentBehavior=\"automatic\"` on the ScrollView for native safe-area handling",
2097
+ "rn-pressable-shared-value-mutation": "Wrap in <GestureDetector gesture={Gesture.Tap()...}> so the press animation runs on the UI thread instead of bouncing across the JS bridge",
2098
+ "rn-list-data-mapped": "Wrap the projection in `useMemo(() => items.map(...), [items])` so the list's `data` prop has a stable reference across parent renders",
2099
+ "rn-animation-reaction-as-derived": "Replace useAnimatedReaction with `useDerivedValue(() => ..., [deps])` — shorter, native dependency tracking, no side-effect implication",
2100
+ "rn-bottom-sheet-prefer-native": "Use `<Modal presentationStyle=\"formSheet\">` (RN v7+) for native gesture handling and snap points",
2101
+ "rn-scrollview-dynamic-padding": "Use `contentInset={{ bottom: dynamicValue }}` — the OS applies it as an offset without reflowing the scroll content",
1432
2102
  "tanstack-start-route-property-order": "Follow the order: params/validateSearch → loaderDeps → context → beforeLoad → loader → head. See https://tanstack.com/router/latest/docs/eslint/create-route-property-order",
1433
2103
  "tanstack-start-no-direct-fetch-in-loader": "Use `createServerFn()` from @tanstack/react-start — provides type-safe RPC, input validation, and proper server/client code splitting",
1434
2104
  "tanstack-start-server-fn-validate-input": "Add `.inputValidator(schema)` before `.handler()` — data crosses a network boundary and must be validated at runtime",
@@ -1483,35 +2153,61 @@ const resolvePluginPath = () => {
1483
2153
  const resolveDiagnosticCategory = (plugin, rule) => {
1484
2154
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
1485
2155
  };
1486
- const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1487
- const batchIncludePaths = (baseArgs, includePaths) => {
1488
- const baseArgsLength = estimateArgsLength(baseArgs);
1489
- const batches = [];
1490
- let currentBatch = [];
1491
- let currentBatchLength = baseArgsLength;
1492
- for (const filePath of includePaths) {
1493
- const entryLength = filePath.length + 1;
1494
- const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > SPAWN_ARGS_MAX_LENGTH_CHARS;
1495
- const exceedsFileCount = currentBatch.length >= OXLINT_MAX_FILES_PER_BATCH;
1496
- if (exceedsArgLength || exceedsFileCount) {
1497
- batches.push(currentBatch);
1498
- currentBatch = [];
1499
- currentBatchLength = baseArgsLength;
1500
- }
1501
- currentBatch.push(filePath);
1502
- currentBatchLength += entryLength;
2156
+ const SANITIZED_ENV = (() => {
2157
+ const sanitized = {};
2158
+ for (const [name, value] of Object.entries(process.env)) {
2159
+ if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
2160
+ if (name.startsWith("npm_config_")) continue;
2161
+ sanitized[name] = value;
1503
2162
  }
1504
- if (currentBatch.length > 0) batches.push(currentBatch);
1505
- return batches;
1506
- };
2163
+ return sanitized;
2164
+ })();
2165
+ const OXLINT_SPAWN_TIMEOUT_MS = 5 * 6e4;
1507
2166
  const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
1508
- const child = spawn(nodeBinaryPath, args, { cwd: rootDirectory });
2167
+ const child = spawn(nodeBinaryPath, args, {
2168
+ cwd: rootDirectory,
2169
+ env: SANITIZED_ENV
2170
+ });
2171
+ const timeoutHandle = setTimeout(() => {
2172
+ child.kill("SIGKILL");
2173
+ reject(/* @__PURE__ */ new Error(`oxlint did not return within ${OXLINT_SPAWN_TIMEOUT_MS / 1e3}s — please report`));
2174
+ }, OXLINT_SPAWN_TIMEOUT_MS);
2175
+ timeoutHandle.unref?.();
1509
2176
  const stdoutBuffers = [];
1510
2177
  const stderrBuffers = [];
1511
- child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1512
- child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1513
- child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1514
- child.on("close", (code, signal) => {
2178
+ let stdoutByteCount = 0;
2179
+ let stderrByteCount = 0;
2180
+ let didKillForSize = false;
2181
+ const killIfTooLarge = (incomingBytes, isStdout) => {
2182
+ if (isStdout) stdoutByteCount += incomingBytes;
2183
+ else stderrByteCount += incomingBytes;
2184
+ if (stdoutByteCount + stderrByteCount > 52428800 && !didKillForSize) {
2185
+ didKillForSize = true;
2186
+ child.kill("SIGKILL");
2187
+ return true;
2188
+ }
2189
+ return false;
2190
+ };
2191
+ child.stdout.on("data", (buffer) => {
2192
+ if (didKillForSize) return;
2193
+ stdoutBuffers.push(buffer);
2194
+ killIfTooLarge(buffer.length, true);
2195
+ });
2196
+ child.stderr.on("data", (buffer) => {
2197
+ if (didKillForSize) return;
2198
+ stderrBuffers.push(buffer);
2199
+ killIfTooLarge(buffer.length, false);
2200
+ });
2201
+ child.on("error", (error) => {
2202
+ clearTimeout(timeoutHandle);
2203
+ reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`));
2204
+ });
2205
+ child.on("close", (_code, signal) => {
2206
+ clearTimeout(timeoutHandle);
2207
+ if (didKillForSize) {
2208
+ reject(/* @__PURE__ */ new Error(`oxlint output exceeded ${PROXY_OUTPUT_MAX_BYTES} bytes — scan a smaller subset with --diff or --staged`));
2209
+ return;
2210
+ }
1515
2211
  if (signal) {
1516
2212
  const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1517
2213
  const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
@@ -1530,15 +2226,23 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
1530
2226
  resolve(output);
1531
2227
  });
1532
2228
  });
2229
+ const isOxlintOutput = (value) => {
2230
+ if (typeof value !== "object" || value === null) return false;
2231
+ const candidate = value;
2232
+ return Array.isArray(candidate.diagnostics);
2233
+ };
1533
2234
  const parseOxlintOutput = (stdout) => {
1534
2235
  if (!stdout) return [];
1535
- let output;
2236
+ const jsonStart = stdout.indexOf("{");
2237
+ const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
2238
+ let parsed;
1536
2239
  try {
1537
- output = JSON.parse(stdout);
2240
+ parsed = JSON.parse(sanitizedStdout);
1538
2241
  } catch {
1539
- throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
2242
+ throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
1540
2243
  }
1541
- return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2244
+ if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
2245
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
1542
2246
  const { plugin, rule } = parseRuleCode(diagnostic.code);
1543
2247
  const primaryLabel = diagnostic.labels[0];
1544
2248
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -1555,18 +2259,48 @@ const parseOxlintOutput = (stdout) => {
1555
2259
  };
1556
2260
  });
1557
2261
  };
1558
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false) => {
2262
+ const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
2263
+ const resolveTsConfigRelativePath = (rootDirectory) => {
2264
+ for (const filename of TSCONFIG_FILENAMES) if (fs.existsSync(path.join(rootDirectory, filename))) return `./${filename}`;
2265
+ return null;
2266
+ };
2267
+ let didValidateRuleRegistration = false;
2268
+ const validateRuleRegistration = () => {
2269
+ if (didValidateRuleRegistration) return;
2270
+ didValidateRuleRegistration = true;
2271
+ const missingHelp = [];
2272
+ const missingCategory = [];
2273
+ for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2274
+ const ruleName = fullKey.replace(/^react-doctor\//, "");
2275
+ if (!(fullKey in RULE_CATEGORY_MAP)) missingCategory.push(fullKey);
2276
+ if (!(ruleName in RULE_HELP_MAP)) missingHelp.push(fullKey);
2277
+ }
2278
+ if (missingCategory.length > 0 || missingHelp.length > 0) {
2279
+ const detail = [missingCategory.length > 0 ? `Missing RULE_CATEGORY_MAP entries: ${missingCategory.join(", ")}` : null, missingHelp.length > 0 ? `Missing RULE_HELP_MAP entries: ${missingHelp.join(", ")}` : null].filter((entry) => entry !== null).join("; ");
2280
+ console.warn(`[react-doctor] rule-registration drift: ${detail}`);
2281
+ }
2282
+ };
2283
+ const runOxlint = async (options) => {
2284
+ const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
2285
+ validateRuleRegistration();
1559
2286
  if (includePaths !== void 0 && includePaths.length === 0) return [];
1560
- const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
2287
+ const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
2288
+ const configPath = path.join(configDirectory, "oxlintrc.json");
1561
2289
  const config = createOxlintConfig({
1562
2290
  pluginPath: resolvePluginPath(),
1563
2291
  framework,
1564
2292
  hasReactCompiler,
2293
+ hasTanStackQuery,
1565
2294
  customRulesOnly
1566
2295
  });
1567
- const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory, includePaths);
2296
+ const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
1568
2297
  try {
1569
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
2298
+ const fileHandle = fs.openSync(configPath, "wx", 384);
2299
+ try {
2300
+ fs.writeFileSync(fileHandle, JSON.stringify(config));
2301
+ } finally {
2302
+ fs.closeSync(fileHandle);
2303
+ }
1570
2304
  const baseArgs = [
1571
2305
  resolveOxlintBinary(),
1572
2306
  "-c",
@@ -1574,7 +2308,16 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1574
2308
  "--format",
1575
2309
  "json"
1576
2310
  ];
1577
- if (hasTypeScript) baseArgs.push("--tsconfig", "./tsconfig.json");
2311
+ if (hasTypeScript) {
2312
+ const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
2313
+ if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
2314
+ }
2315
+ const combinedPatterns = collectIgnorePatterns(rootDirectory);
2316
+ if (combinedPatterns.length > 0) {
2317
+ const combinedIgnorePath = path.join(configDirectory, "combined.ignore");
2318
+ fs.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
2319
+ baseArgs.push("--ignore-path", combinedIgnorePath);
2320
+ }
1578
2321
  const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
1579
2322
  const allDiagnostics = [];
1580
2323
  for (const batch of fileBatches) {
@@ -1584,72 +2327,111 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1584
2327
  return allDiagnostics;
1585
2328
  } finally {
1586
2329
  restoreDisableDirectives();
1587
- if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
2330
+ fs.rmSync(configDirectory, {
2331
+ recursive: true,
2332
+ force: true
2333
+ });
1588
2334
  }
1589
2335
  };
1590
-
1591
2336
  //#endregion
1592
2337
  //#region src/utils/get-diff-files.ts
2338
+ const runGit = (cwd, args) => {
2339
+ const result = spawnSync("git", args, {
2340
+ cwd,
2341
+ stdio: [
2342
+ "ignore",
2343
+ "pipe",
2344
+ "pipe"
2345
+ ],
2346
+ encoding: "utf-8"
2347
+ });
2348
+ if (result.error || result.status !== 0) return null;
2349
+ return result.stdout.toString().trim();
2350
+ };
1593
2351
  const getCurrentBranch = (directory) => {
1594
- try {
1595
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1596
- cwd: directory,
1597
- stdio: "pipe"
1598
- }).toString().trim();
1599
- return branch === "HEAD" ? null : branch;
1600
- } catch {
1601
- return null;
1602
- }
2352
+ const branch = runGit(directory, [
2353
+ "rev-parse",
2354
+ "--abbrev-ref",
2355
+ "HEAD"
2356
+ ]);
2357
+ if (!branch) return null;
2358
+ return branch === "HEAD" ? null : branch;
1603
2359
  };
1604
2360
  const detectDefaultBranch = (directory) => {
1605
- try {
1606
- return execSync("git symbolic-ref refs/remotes/origin/HEAD", {
1607
- cwd: directory,
1608
- stdio: "pipe"
1609
- }).toString().trim().replace("refs/remotes/origin/", "");
1610
- } catch {
1611
- for (const candidate of DEFAULT_BRANCH_CANDIDATES) try {
1612
- execSync(`git rev-parse --verify ${candidate}`, {
1613
- cwd: directory,
1614
- stdio: "pipe"
1615
- });
1616
- return candidate;
1617
- } catch {}
1618
- return null;
2361
+ const reference = runGit(directory, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
2362
+ if (reference) return reference.replace("refs/remotes/origin/", "");
2363
+ const output = runGit(directory, [
2364
+ "for-each-ref",
2365
+ "--format=%(refname:short)",
2366
+ ...DEFAULT_BRANCH_CANDIDATES.map((candidate) => `refs/heads/${candidate}`)
2367
+ ]);
2368
+ if (output) {
2369
+ const firstLine = output.split("\n")[0]?.trim();
2370
+ if (firstLine) return firstLine;
1619
2371
  }
2372
+ return null;
2373
+ };
2374
+ const branchExists = (directory, branch) => {
2375
+ const result = spawnSync("git", [
2376
+ "rev-parse",
2377
+ "--verify",
2378
+ branch
2379
+ ], {
2380
+ cwd: directory,
2381
+ stdio: [
2382
+ "ignore",
2383
+ "pipe",
2384
+ "pipe"
2385
+ ]
2386
+ });
2387
+ return !result.error && result.status === 0;
2388
+ };
2389
+ const runGitNullSeparated = (cwd, args) => {
2390
+ const result = spawnSync("git", args, {
2391
+ cwd,
2392
+ stdio: [
2393
+ "ignore",
2394
+ "pipe",
2395
+ "pipe"
2396
+ ],
2397
+ encoding: "utf-8"
2398
+ });
2399
+ if (result.error || result.status !== 0) return null;
2400
+ return result.stdout.toString().split("\0").filter((filePath) => filePath.length > 0);
1620
2401
  };
1621
2402
  const getChangedFilesSinceBranch = (directory, baseBranch) => {
1622
- try {
1623
- const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${execSync(`git merge-base ${baseBranch} HEAD`, {
1624
- cwd: directory,
1625
- stdio: "pipe"
1626
- }).toString().trim()}`, {
1627
- cwd: directory,
1628
- stdio: "pipe"
1629
- }).toString().trim();
1630
- if (!output) return [];
1631
- return output.split("\n").filter(Boolean);
1632
- } catch {
1633
- return [];
1634
- }
2403
+ const mergeBase = runGit(directory, [
2404
+ "merge-base",
2405
+ baseBranch,
2406
+ "HEAD"
2407
+ ]);
2408
+ if (mergeBase === null) return null;
2409
+ return runGitNullSeparated(directory, [
2410
+ "diff",
2411
+ "-z",
2412
+ "--name-only",
2413
+ "--diff-filter=ACMR",
2414
+ "--relative",
2415
+ mergeBase
2416
+ ]);
1635
2417
  };
1636
2418
  const getUncommittedChangedFiles = (directory) => {
1637
- try {
1638
- const output = execSync("git diff --name-only --diff-filter=ACMR --relative HEAD", {
1639
- cwd: directory,
1640
- stdio: "pipe"
1641
- }).toString().trim();
1642
- if (!output) return [];
1643
- return output.split("\n").filter(Boolean);
1644
- } catch {
1645
- return [];
1646
- }
2419
+ return runGitNullSeparated(directory, [
2420
+ "diff",
2421
+ "-z",
2422
+ "--name-only",
2423
+ "--diff-filter=ACMR",
2424
+ "--relative",
2425
+ "HEAD"
2426
+ ]) ?? [];
1647
2427
  };
1648
2428
  const getDiffInfo = (directory, explicitBaseBranch) => {
2429
+ if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) throw new Error("Diff base branch cannot be empty.");
1649
2430
  const currentBranch = getCurrentBranch(directory);
1650
2431
  if (!currentBranch) return null;
1651
2432
  const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
1652
2433
  if (!baseBranch) return null;
2434
+ if (explicitBaseBranch && !branchExists(directory, explicitBaseBranch)) throw new Error(`Diff base branch "${explicitBaseBranch}" does not exist (run \`git fetch\` to update remote refs).`);
1653
2435
  if (currentBranch === baseBranch) {
1654
2436
  const uncommittedFiles = getUncommittedChangedFiles(directory);
1655
2437
  if (uncommittedFiles.length === 0) return null;
@@ -1660,16 +2442,40 @@ const getDiffInfo = (directory, explicitBaseBranch) => {
1660
2442
  isCurrentChanges: true
1661
2443
  };
1662
2444
  }
2445
+ const changedFiles = getChangedFilesSinceBranch(directory, baseBranch);
2446
+ if (changedFiles === null) return null;
1663
2447
  return {
1664
2448
  currentBranch,
1665
2449
  baseBranch,
1666
- changedFiles: getChangedFilesSinceBranch(directory, baseBranch)
2450
+ changedFiles
1667
2451
  };
1668
2452
  };
1669
2453
  const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
1670
-
1671
2454
  //#endregion
1672
2455
  //#region src/index.ts
2456
+ const clearCaches = () => {
2457
+ clearProjectCache();
2458
+ clearConfigCache();
2459
+ clearPackageJsonCache();
2460
+ clearIgnorePatternsCache();
2461
+ };
2462
+ const toJsonReport = (result, options) => buildJsonReport({
2463
+ version: options.version,
2464
+ directory: options.directory ?? result.project.rootDirectory,
2465
+ mode: options.mode ?? "full",
2466
+ diff: null,
2467
+ scans: [{
2468
+ directory: result.project.rootDirectory,
2469
+ result: {
2470
+ diagnostics: result.diagnostics,
2471
+ score: result.score,
2472
+ skippedChecks: [],
2473
+ project: result.project,
2474
+ elapsedMilliseconds: result.elapsedMilliseconds
2475
+ }
2476
+ }],
2477
+ totalElapsedMilliseconds: result.elapsedMilliseconds
2478
+ });
1673
2479
  const diagnose = async (directory, options = {}) => {
1674
2480
  const resolvedDirectory = path.resolve(directory);
1675
2481
  const userConfig = loadConfig(resolvedDirectory);
@@ -1684,7 +2490,16 @@ const diagnose = async (directory, options = {}) => {
1684
2490
  calculateDiagnosticsScore: calculateScore,
1685
2491
  getExtraDiagnostics: () => isDiffMode ? [] : checkReducedMotion(resolvedDirectory),
1686
2492
  createRunners: ({ resolvedDirectory: projectRoot, projectInfo, userConfig: config }) => ({
1687
- runLint: () => runOxlint(projectRoot, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, void 0, config?.customRulesOnly ?? false),
2493
+ runLint: () => runOxlint({
2494
+ rootDirectory: projectRoot,
2495
+ hasTypeScript: projectInfo.hasTypeScript,
2496
+ framework: projectInfo.framework,
2497
+ hasReactCompiler: projectInfo.hasReactCompiler,
2498
+ hasTanStackQuery: projectInfo.hasTanStackQuery,
2499
+ includePaths: lintIncludePaths,
2500
+ customRulesOnly: config?.customRulesOnly ?? false,
2501
+ respectInlineDisables: options.respectInlineDisables ?? config?.respectInlineDisables ?? true
2502
+ }),
1688
2503
  runDeadCode: () => runKnip(projectRoot)
1689
2504
  })
1690
2505
  }, {
@@ -1692,7 +2507,7 @@ const diagnose = async (directory, options = {}) => {
1692
2507
  lintIncludePaths
1693
2508
  });
1694
2509
  };
1695
-
1696
2510
  //#endregion
1697
- export { diagnose, filterSourceFiles, getDiffInfo };
2511
+ export { buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, summarizeDiagnostics, toJsonReport };
2512
+
1698
2513
  //# sourceMappingURL=index.js.map