react-doctor 0.0.42 → 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/cli.js CHANGED
@@ -1,18 +1,51 @@
1
- #!/usr/bin/env node
2
1
  import { createRequire } from "node:module";
3
2
  import fs, { accessSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
3
  import os, { tmpdir } from "node:os";
5
4
  import path, { join } from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
6
  import { Command } from "commander";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import pc from "picocolors";
9
9
  import basePrompts from "prompts";
10
10
  import ora from "ora";
11
11
  import { randomUUID } from "node:crypto";
12
- import { performance } from "node:perf_hooks";
13
- import { execSync, spawn, spawnSync } from "node:child_process";
12
+ import { spawn, spawnSync } from "node:child_process";
14
13
  import { main } from "knip";
15
14
  import { createOptions } from "knip/session";
15
+ //#region src/constants.ts
16
+ const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
17
+ const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
18
+ const MILLISECONDS_PER_SECOND = 1e3;
19
+ const SCORE_API_URL = "https://www.react.doctor/api/score";
20
+ const SHARE_BASE_URL = "https://www.react.doctor/share";
21
+ const FETCH_TIMEOUT_MS = 1e4;
22
+ const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
23
+ const OFFLINE_MESSAGE = "Score calculated locally (offline mode).";
24
+ const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
25
+ const ERROR_RULE_PENALTY = 1.5;
26
+ const WARNING_RULE_PENALTY = .75;
27
+ const KNIP_CONFIG_LOCATIONS = [
28
+ "knip.json",
29
+ "knip.jsonc",
30
+ ".knip.json",
31
+ ".knip.jsonc",
32
+ "knip.ts",
33
+ "knip.js",
34
+ "knip.config.ts",
35
+ "knip.config.js"
36
+ ];
37
+ const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
38
+ const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
39
+ const IGNORED_DIRECTORIES = new Set([
40
+ "node_modules",
41
+ "dist",
42
+ "build",
43
+ "coverage"
44
+ ]);
45
+ const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
46
+ const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
47
+ const buildNoReactDependencyError = (directory) => `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`;
48
+ //#endregion
16
49
  //#region src/utils/detect-agents.ts
17
50
  const AGENTS_SKILL_DIR = ".agents/skills";
18
51
  const SUPPORTED_AGENTS = {
@@ -98,26 +131,38 @@ const installSkillForAgent = (projectRoot, agent, skillSourceDirectory, skillNam
98
131
  };
99
132
  //#endregion
100
133
  //#region src/utils/logger.ts
134
+ let isSilent$1 = false;
135
+ const setLoggerSilent = (silent) => {
136
+ isSilent$1 = silent;
137
+ };
138
+ const isLoggerSilent = () => isSilent$1;
101
139
  const logger = {
102
140
  error(...args) {
103
- console.log(highlighter.error(args.join(" ")));
141
+ if (isSilent$1) return;
142
+ console.error(highlighter.error(args.join(" ")));
104
143
  },
105
144
  warn(...args) {
106
- console.log(highlighter.warn(args.join(" ")));
145
+ if (isSilent$1) return;
146
+ console.warn(highlighter.warn(args.join(" ")));
107
147
  },
108
148
  info(...args) {
149
+ if (isSilent$1) return;
109
150
  console.log(highlighter.info(args.join(" ")));
110
151
  },
111
152
  success(...args) {
153
+ if (isSilent$1) return;
112
154
  console.log(highlighter.success(args.join(" ")));
113
155
  },
114
156
  dim(...args) {
157
+ if (isSilent$1) return;
115
158
  console.log(highlighter.dim(args.join(" ")));
116
159
  },
117
160
  log(...args) {
161
+ if (isSilent$1) return;
118
162
  console.log(args.join(" "));
119
163
  },
120
164
  break() {
165
+ if (isSilent$1) return;
121
166
  console.log("");
122
167
  }
123
168
  };
@@ -137,15 +182,11 @@ const shouldSelectAllChoices = (choiceStates) => {
137
182
  //#region src/utils/prompts.ts
138
183
  const require = createRequire(import.meta.url);
139
184
  const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
140
- const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
141
185
  let didPatchMultiselectToggleAll = false;
142
186
  let didPatchMultiselectSubmit = false;
143
- let didPatchSelectBanner = false;
144
- const selectBannerMap = /* @__PURE__ */ new Map();
145
187
  const onCancel = () => {
146
188
  logger.break();
147
189
  logger.log("Cancelled.");
148
- logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
149
190
  logger.break();
150
191
  process.exit(0);
151
192
  };
@@ -177,25 +218,9 @@ const patchMultiselectSubmit = () => {
177
218
  originalSubmit.call(this);
178
219
  };
179
220
  };
180
- const patchSelectBanner = () => {
181
- if (didPatchSelectBanner) return;
182
- didPatchSelectBanner = true;
183
- const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
184
- const promptsClear = require("prompts/lib/util/clear");
185
- const originalRender = selectConstructor.prototype.render;
186
- selectConstructor.prototype.render = function() {
187
- originalRender.call(this);
188
- const banner = selectBannerMap.get(this.cursor);
189
- if (!banner || this.closed || this.done) return;
190
- this.out.write(promptsClear(this.outputText, this.out.columns));
191
- this.outputText = `${banner}\n\n${this.outputText}`;
192
- this.out.write(this.outputText);
193
- };
194
- };
195
221
  const prompts = (questions) => {
196
222
  patchMultiselectToggleAll();
197
223
  patchMultiselectSubmit();
198
- patchSelectBanner();
199
224
  return basePrompts(questions, { onCancel });
200
225
  };
201
226
  //#endregion
@@ -203,10 +228,20 @@ const prompts = (questions) => {
203
228
  let sharedInstance = null;
204
229
  let activeCount = 0;
205
230
  const pendingTexts = /* @__PURE__ */ new Set();
231
+ const finalizedHandles = /* @__PURE__ */ new WeakSet();
232
+ let isSilent = false;
233
+ const setSpinnerSilent = (silent) => {
234
+ isSilent = silent;
235
+ };
236
+ const isSpinnerSilent = () => isSilent;
237
+ const noopHandle = Object.freeze({
238
+ succeed: () => {},
239
+ fail: () => {}
240
+ });
206
241
  const finalize = (method, originalText, displayText) => {
207
242
  pendingTexts.delete(originalText);
208
- activeCount--;
209
- if (activeCount <= 0 || !sharedInstance) {
243
+ activeCount = Math.max(0, activeCount - 1);
244
+ if (activeCount === 0 || !sharedInstance) {
210
245
  sharedInstance?.[method](displayText);
211
246
  sharedInstance = null;
212
247
  activeCount = 0;
@@ -219,14 +254,24 @@ const finalize = (method, originalText, displayText) => {
219
254
  sharedInstance.start();
220
255
  };
221
256
  const spinner = (text) => ({ start() {
257
+ if (isSilent) return noopHandle;
222
258
  activeCount++;
223
259
  pendingTexts.add(text);
224
260
  if (!sharedInstance) sharedInstance = ora({ text }).start();
225
261
  else sharedInstance.text = text;
226
- return {
227
- succeed: (displayText) => finalize("succeed", text, displayText),
228
- fail: (displayText) => finalize("fail", text, displayText)
262
+ const handle = {
263
+ succeed: (displayText) => {
264
+ if (finalizedHandles.has(handle)) return;
265
+ finalizedHandles.add(handle);
266
+ finalize("succeed", text, displayText);
267
+ },
268
+ fail: (displayText) => {
269
+ if (finalizedHandles.has(handle)) return;
270
+ finalizedHandles.add(handle);
271
+ finalize("fail", text, displayText);
272
+ }
229
273
  };
274
+ return handle;
230
275
  } });
231
276
  //#endregion
232
277
  //#region src/install-skill.ts
@@ -263,6 +308,12 @@ const runInstallSkill = async (options = {}) => {
263
308
  min: 1
264
309
  })).agents ?? [];
265
310
  if (selectedAgents.length === 0) return;
311
+ if (options.dryRun) {
312
+ logger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
313
+ for (const agent of selectedAgents) logger.dim(` - ${toDisplayName(agent)}`);
314
+ logger.dim(` Source: ${sourceDir}`);
315
+ return;
316
+ }
266
317
  const installSpinner = spinner(`Installing ${SKILL_NAME} skill...`).start();
267
318
  const installedDirectories = /* @__PURE__ */ new Set();
268
319
  for (const agent of selectedAgents) {
@@ -272,37 +323,6 @@ const runInstallSkill = async (options = {}) => {
272
323
  installSpinner.succeed(`${SKILL_NAME} skill installed for ${selectedAgents.map(toDisplayName).join(", ")}.`);
273
324
  };
274
325
  //#endregion
275
- //#region src/constants.ts
276
- const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
277
- const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
278
- const MILLISECONDS_PER_SECOND = 1e3;
279
- const SCORE_API_URL = "https://www.react.doctor/api/score";
280
- const SHARE_BASE_URL = "https://www.react.doctor/share";
281
- const FETCH_TIMEOUT_MS = 1e4;
282
- const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
283
- const OFFLINE_MESSAGE = "Score calculated locally (offline mode).";
284
- const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
285
- const ERROR_RULE_PENALTY = 1.5;
286
- const WARNING_RULE_PENALTY = .75;
287
- const KNIP_CONFIG_LOCATIONS = [
288
- "knip.json",
289
- "knip.jsonc",
290
- ".knip.json",
291
- ".knip.jsonc",
292
- "knip.ts",
293
- "knip.js",
294
- "knip.config.ts",
295
- "knip.config.js"
296
- ];
297
- const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
298
- const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
299
- const IGNORED_DIRECTORIES = new Set([
300
- "node_modules",
301
- "dist",
302
- "build",
303
- "coverage"
304
- ]);
305
- //#endregion
306
326
  //#region src/core/calculate-score-locally.ts
307
327
  const getScoreLabel = (score) => {
308
328
  if (score >= 75) return "Great";
@@ -347,43 +367,51 @@ const parseScoreResult = (value) => {
347
367
  label: labelValue
348
368
  };
349
369
  };
370
+ const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
371
+ const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
372
+ const describeFailure = (error) => {
373
+ if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
374
+ if (error instanceof Error && error.message) return error.message;
375
+ return String(error);
376
+ };
350
377
  const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
378
+ if (typeof fetchImplementation !== "function") return null;
351
379
  const controller = new AbortController();
352
380
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
353
381
  try {
354
382
  const response = await fetchImplementation(SCORE_API_URL, {
355
383
  method: "POST",
356
384
  headers: { "Content-Type": "application/json" },
357
- body: JSON.stringify({ diagnostics }),
385
+ body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
358
386
  signal: controller.signal
359
387
  });
360
- if (!response.ok) return null;
388
+ if (!response.ok) {
389
+ console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText} — using local scoring`);
390
+ return null;
391
+ }
361
392
  return parseScoreResult(await response.json());
362
- } catch {
393
+ } catch (error) {
394
+ console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)}) — using local scoring`);
363
395
  return null;
364
396
  } finally {
365
397
  clearTimeout(timeoutId);
366
398
  }
367
399
  };
368
400
  //#endregion
401
+ //#region src/utils/calculate-score-browser.ts
402
+ const getGlobalFetch = () => typeof fetch === "function" ? fetch : void 0;
403
+ const calculateScore$1 = async (diagnostics, fetchImplementation = getGlobalFetch()) => await tryScoreFromApi(diagnostics, fetchImplementation) ?? calculateScoreLocally(diagnostics);
404
+ //#endregion
369
405
  //#region src/utils/proxy-fetch.ts
370
406
  const getGlobalProcess = () => {
371
407
  const candidate = globalThis.process;
372
408
  return candidate?.versions?.node ? candidate : void 0;
373
409
  };
374
- const readEnvProxy = () => {
410
+ const getProxyUrl = () => {
375
411
  const proc = getGlobalProcess();
376
412
  if (!proc?.env) return void 0;
377
413
  return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
378
414
  };
379
- let isProxyUrlResolved = false;
380
- let resolvedProxyUrl;
381
- const getProxyUrl = () => {
382
- if (isProxyUrlResolved) return resolvedProxyUrl;
383
- isProxyUrlResolved = true;
384
- resolvedProxyUrl = readEnvProxy();
385
- return resolvedProxyUrl;
386
- };
387
415
  const createProxyDispatcher = async (proxyUrl) => {
388
416
  try {
389
417
  const { ProxyAgent } = await import("undici");
@@ -393,27 +421,17 @@ const createProxyDispatcher = async (proxyUrl) => {
393
421
  }
394
422
  };
395
423
  const proxyFetch = async (url, init) => {
396
- const controller = new AbortController();
397
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
398
- try {
399
- const proxyUrl = getProxyUrl();
400
- const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
401
- return await fetch(url, {
402
- ...init,
403
- signal: controller.signal,
404
- ...dispatcher ? { dispatcher } : {}
405
- });
406
- } finally {
407
- clearTimeout(timeoutId);
408
- }
424
+ const proxyUrl = getProxyUrl();
425
+ const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
426
+ const fetchInit = {
427
+ ...init,
428
+ ...dispatcher ? { dispatcher } : {}
429
+ };
430
+ return fetch(url, fetchInit);
409
431
  };
410
432
  //#endregion
411
433
  //#region src/utils/calculate-score-node.ts
412
- const calculateScore = async (diagnostics) => {
413
- const apiScore = await tryScoreFromApi(diagnostics, proxyFetch);
414
- if (apiScore) return apiScore;
415
- return calculateScoreLocally(diagnostics);
416
- };
434
+ const calculateScore = (diagnostics) => calculateScore$1(diagnostics, proxyFetch);
417
435
  //#endregion
418
436
  //#region src/utils/colorize-by-score.ts
419
437
  const colorizeByScore = (text, score) => {
@@ -435,7 +453,8 @@ const isFile = (filePath) => {
435
453
  };
436
454
  //#endregion
437
455
  //#region src/utils/read-package-json.ts
438
- const readPackageJson = (packageJsonPath) => {
456
+ const cachedPackageJsons = /* @__PURE__ */ new Map();
457
+ const readPackageJsonUncached = (packageJsonPath) => {
439
458
  try {
440
459
  return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
441
460
  } catch (error) {
@@ -447,10 +466,25 @@ const readPackageJson = (packageJsonPath) => {
447
466
  throw error;
448
467
  }
449
468
  };
469
+ const readPackageJson = (packageJsonPath) => {
470
+ const absolutePath = path.resolve(packageJsonPath);
471
+ const cached = cachedPackageJsons.get(absolutePath);
472
+ if (cached !== void 0) return cached;
473
+ const result = readPackageJsonUncached(absolutePath);
474
+ cachedPackageJsons.set(absolutePath, result);
475
+ return result;
476
+ };
450
477
  //#endregion
451
478
  //#region src/utils/check-reduced-motion.ts
452
479
  const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
453
- const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
480
+ const REDUCED_MOTION_FILE_GLOBS = [
481
+ "*.ts",
482
+ "*.tsx",
483
+ "*.js",
484
+ "*.jsx",
485
+ "*.css",
486
+ "*.scss"
487
+ ];
454
488
  const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
455
489
  filePath: "package.json",
456
490
  plugin: "react-doctor",
@@ -460,8 +494,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
460
494
  help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
461
495
  line: 0,
462
496
  column: 0,
463
- category: "Accessibility",
464
- weight: 2
497
+ category: "Accessibility"
465
498
  };
466
499
  const checkReducedMotion = (rootDirectory) => {
467
500
  const packageJsonPath = path.join(rootDirectory, "package.json");
@@ -478,15 +511,24 @@ const checkReducedMotion = (rootDirectory) => {
478
511
  return [];
479
512
  }
480
513
  if (!hasMotionLibrary) return [];
481
- try {
482
- execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
483
- cwd: rootDirectory,
484
- stdio: "pipe"
485
- });
486
- return [];
487
- } catch {
488
- return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
489
- }
514
+ const result = spawnSync("git", [
515
+ "grep",
516
+ "-ql",
517
+ "-E",
518
+ REDUCED_MOTION_GREP_PATTERN,
519
+ "--",
520
+ ...REDUCED_MOTION_FILE_GLOBS
521
+ ], {
522
+ cwd: rootDirectory,
523
+ stdio: [
524
+ "ignore",
525
+ "pipe",
526
+ "pipe"
527
+ ]
528
+ });
529
+ if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
530
+ if (result.status === 0) return [];
531
+ return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
490
532
  };
491
533
  //#endregion
492
534
  //#region src/utils/read-file-lines-node.ts
@@ -535,7 +577,11 @@ const toRelativePath = (filePath, rootDirectory) => {
535
577
  if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
536
578
  return normalizedFilePath.replace(/^\.\//, "");
537
579
  };
538
- const compileIgnoredFilePatterns = (userConfig) => Array.isArray(userConfig?.ignore?.files) ? userConfig.ignore.files.map(compileGlobPattern) : [];
580
+ const compileIgnoredFilePatterns = (userConfig) => {
581
+ const files = userConfig?.ignore?.files;
582
+ if (!Array.isArray(files)) return [];
583
+ return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
584
+ };
539
585
  const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
540
586
  if (patterns.length === 0) return false;
541
587
  const relativePath = toRelativePath(filePath, rootDirectory);
@@ -576,9 +622,9 @@ const isRuleSuppressed = (commentRules, ruleId) => {
576
622
  return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
577
623
  };
578
624
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
579
- const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
625
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
580
626
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
581
- const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents : []);
627
+ const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
582
628
  const hasTextComponents = textComponentNames.size > 0;
583
629
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
584
630
  return diagnostics.filter((diagnostic) => {
@@ -620,11 +666,9 @@ const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, rea
620
666
  return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics, directory, readFileLinesSync);
621
667
  };
622
668
  //#endregion
623
- //#region src/utils/jsx-include-paths.ts
624
- const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
625
- //#endregion
626
669
  //#region src/utils/combine-diagnostics.ts
627
- const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true) => {
670
+ const combineDiagnostics = (input) => {
671
+ const { lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true } = input;
628
672
  const extraDiagnostics = isDiffMode || !includeEnvironmentChecks ? [] : checkReducedMotion(directory);
629
673
  return mergeAndFilterDiagnostics([
630
674
  ...lintDiagnostics,
@@ -633,6 +677,9 @@ const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isD
633
677
  ], directory, userConfig, readFileLinesSync);
634
678
  };
635
679
  //#endregion
680
+ //#region src/utils/jsx-include-paths.ts
681
+ const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
682
+ //#endregion
636
683
  //#region src/utils/find-monorepo-root.ts
637
684
  const isMonorepoRoot = (directory) => {
638
685
  if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
@@ -652,7 +699,11 @@ const findMonorepoRoot = (startDirectory) => {
652
699
  };
653
700
  //#endregion
654
701
  //#region src/utils/is-plain-object.ts
655
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
702
+ const isPlainObject = (value) => {
703
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
704
+ const prototype = Object.getPrototypeOf(value);
705
+ return prototype === null || prototype === Object.prototype;
706
+ };
656
707
  //#endregion
657
708
  //#region src/utils/discover-project.ts
658
709
  const REACT_COMPILER_PACKAGES = new Set([
@@ -660,6 +711,11 @@ const REACT_COMPILER_PACKAGES = new Set([
660
711
  "react-compiler-runtime",
661
712
  "eslint-plugin-react-compiler"
662
713
  ]);
714
+ const TANSTACK_QUERY_PACKAGES = new Set([
715
+ "@tanstack/react-query",
716
+ "@tanstack/query-core",
717
+ "react-query"
718
+ ]);
663
719
  const NEXT_CONFIG_FILENAMES = [
664
720
  "next.config.js",
665
721
  "next.config.mjs",
@@ -678,7 +734,11 @@ const VITE_CONFIG_FILENAMES = [
678
734
  "vite.config.js",
679
735
  "vite.config.ts",
680
736
  "vite.config.mjs",
681
- "vite.config.cjs"
737
+ "vite.config.mts",
738
+ "vite.config.cjs",
739
+ "vite.config.cts",
740
+ "vitest.config.ts",
741
+ "vitest.config.js"
682
742
  ];
683
743
  const EXPO_APP_CONFIG_FILENAMES = [
684
744
  "app.json",
@@ -686,7 +746,7 @@ const EXPO_APP_CONFIG_FILENAMES = [
686
746
  "app.config.ts"
687
747
  ];
688
748
  const REACT_COMPILER_PACKAGE_REFERENCE_PATTERN = /babel-plugin-react-compiler|react-compiler-runtime|eslint-plugin-react-compiler|["']react-compiler["']/;
689
- const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*true\b/;
749
+ const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*(?:true\b|\{)/;
690
750
  const FRAMEWORK_PACKAGES = {
691
751
  next: "nextjs",
692
752
  "@tanstack/react-start": "tanstack-start",
@@ -728,6 +788,7 @@ const countSourceFilesViaFilesystem = (rootDirectory) => {
728
788
  const countSourceFilesViaGit = (rootDirectory) => {
729
789
  const result = spawnSync("git", [
730
790
  "ls-files",
791
+ "-z",
731
792
  "--cached",
732
793
  "--others",
733
794
  "--exclude-standard"
@@ -737,7 +798,7 @@ const countSourceFilesViaGit = (rootDirectory) => {
737
798
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
738
799
  });
739
800
  if (result.error || result.status !== 0) return null;
740
- return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
801
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
741
802
  };
742
803
  const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
743
804
  const collectAllDependencies = (packageJson) => ({
@@ -835,17 +896,17 @@ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogRefer
835
896
  const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
836
897
  const rawVersion = collectAllDependencies(packageJson)[packageName];
837
898
  const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
838
- const raw = packageJson;
839
- if (isPlainObject(raw.catalog)) {
840
- const version = resolveVersionFromCatalog(raw.catalog, packageName);
899
+ if (isPlainObject(packageJson.catalog)) {
900
+ const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
841
901
  if (version) return version;
842
902
  }
843
- if (isPlainObject(raw.catalogs)) {
844
- if (catalogName && isPlainObject(raw.catalogs[catalogName])) {
845
- const version = resolveVersionFromCatalog(raw.catalogs[catalogName], packageName);
903
+ if (isPlainObject(packageJson.catalogs)) {
904
+ const namedCatalog = catalogName ? packageJson.catalogs[catalogName] : void 0;
905
+ if (namedCatalog && isPlainObject(namedCatalog)) {
906
+ const version = resolveVersionFromCatalog(namedCatalog, packageName);
846
907
  if (version) return version;
847
908
  }
848
- for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
909
+ for (const catalogEntries of Object.values(packageJson.catalogs)) if (isPlainObject(catalogEntries)) {
849
910
  const version = resolveVersionFromCatalog(catalogEntries, packageName);
850
911
  if (version) return version;
851
912
  }
@@ -886,11 +947,32 @@ const parsePnpmWorkspacePatterns = (rootDirectory) => {
886
947
  }
887
948
  return patterns;
888
949
  };
950
+ const NX_PROJECT_DISCOVERY_DIRS = [
951
+ "apps",
952
+ "libs",
953
+ "packages"
954
+ ];
955
+ const getNxWorkspaceDirectories = (rootDirectory) => {
956
+ if (!isFile(path.join(rootDirectory, "nx.json"))) return [];
957
+ const collected = [];
958
+ for (const candidate of NX_PROJECT_DISCOVERY_DIRS) {
959
+ const candidatePath = path.join(rootDirectory, candidate);
960
+ if (!fs.existsSync(candidatePath) || !fs.statSync(candidatePath).isDirectory()) continue;
961
+ for (const entry of fs.readdirSync(candidatePath, { withFileTypes: true })) {
962
+ if (!entry.isDirectory()) continue;
963
+ const projectDirectory = path.join(candidatePath, entry.name);
964
+ if (isFile(path.join(projectDirectory, "project.json")) || isFile(path.join(projectDirectory, "package.json"))) collected.push(`${candidate}/${entry.name}`);
965
+ }
966
+ }
967
+ return collected;
968
+ };
889
969
  const getWorkspacePatterns = (rootDirectory, packageJson) => {
890
970
  const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory);
891
971
  if (pnpmPatterns.length > 0) return pnpmPatterns;
892
972
  if (Array.isArray(packageJson.workspaces)) return packageJson.workspaces;
893
973
  if (packageJson.workspaces?.packages) return packageJson.workspaces.packages;
974
+ const nxPatterns = getNxWorkspaceDirectories(rootDirectory);
975
+ if (nxPatterns.length > 0) return nxPatterns;
894
976
  return [];
895
977
  };
896
978
  const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
@@ -1020,23 +1102,32 @@ const hasCompilerInConfigFile = (filePath) => {
1020
1102
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
1021
1103
  };
1022
1104
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
1105
+ const isProjectBoundary$1 = (directory) => {
1106
+ if (fs.existsSync(path.join(directory, ".git"))) return true;
1107
+ return isMonorepoRoot(directory);
1108
+ };
1023
1109
  const detectReactCompiler = (directory, packageJson) => {
1024
1110
  if (hasCompilerPackage(packageJson)) return true;
1025
1111
  if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
1026
1112
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
1027
1113
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
1028
1114
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
1115
+ if (isProjectBoundary$1(directory)) return false;
1029
1116
  let ancestorDirectory = path.dirname(directory);
1030
1117
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1031
1118
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
1032
1119
  if (isFile(ancestorPackagePath)) {
1033
1120
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
1034
1121
  }
1122
+ if (isProjectBoundary$1(ancestorDirectory)) return false;
1035
1123
  ancestorDirectory = path.dirname(ancestorDirectory);
1036
1124
  }
1037
1125
  return false;
1038
1126
  };
1127
+ const cachedProjectInfos = /* @__PURE__ */ new Map();
1039
1128
  const discoverProject = (directory) => {
1129
+ const cached = cachedProjectInfos.get(directory);
1130
+ if (cached !== void 0) return cached;
1040
1131
  const packageJsonPath = path.join(directory, "package.json");
1041
1132
  if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
1042
1133
  const packageJson = readPackageJson(packageJsonPath);
@@ -1063,15 +1154,20 @@ const discoverProject = (directory) => {
1063
1154
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
1064
1155
  const sourceFileCount = countSourceFiles(directory);
1065
1156
  const hasReactCompiler = detectReactCompiler(directory, packageJson);
1066
- return {
1157
+ const allDependencies = collectAllDependencies(packageJson);
1158
+ const hasTanStackQuery = Object.keys(allDependencies).some((packageName) => TANSTACK_QUERY_PACKAGES.has(packageName));
1159
+ const projectInfo = {
1067
1160
  rootDirectory: directory,
1068
1161
  projectName,
1069
1162
  reactVersion,
1070
1163
  framework,
1071
1164
  hasTypeScript,
1072
1165
  hasReactCompiler,
1166
+ hasTanStackQuery,
1073
1167
  sourceFileCount
1074
1168
  };
1169
+ cachedProjectInfos.set(directory, projectInfo);
1170
+ return projectInfo;
1075
1171
  };
1076
1172
  //#endregion
1077
1173
  //#region src/utils/format-error-chain.ts
@@ -1131,6 +1227,42 @@ const groupBy = (items, keyFn) => {
1131
1227
  //#region src/utils/indent-multiline-text.ts
1132
1228
  const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
1133
1229
  //#endregion
1230
+ //#region src/utils/validate-config-types.ts
1231
+ const BOOLEAN_FIELD_NAMES = [
1232
+ "lint",
1233
+ "deadCode",
1234
+ "verbose",
1235
+ "customRulesOnly",
1236
+ "share",
1237
+ "respectInlineDisables"
1238
+ ];
1239
+ const warnConfigField = (message) => {
1240
+ process.stderr.write(`[react-doctor] ${message}\n`);
1241
+ };
1242
+ const coerceMaybeBooleanString = (fieldName, value) => {
1243
+ if (typeof value === "boolean" || value === void 0) return value;
1244
+ if (value === "true") {
1245
+ warnConfigField(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
1246
+ return true;
1247
+ }
1248
+ if (value === "false") {
1249
+ warnConfigField(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
1250
+ return false;
1251
+ }
1252
+ warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
1253
+ };
1254
+ const validateConfigTypes = (config) => {
1255
+ const validated = { ...config };
1256
+ for (const fieldName of BOOLEAN_FIELD_NAMES) {
1257
+ const original = config[fieldName];
1258
+ if (original === void 0) continue;
1259
+ const coerced = coerceMaybeBooleanString(fieldName, original);
1260
+ if (coerced === void 0) delete validated[fieldName];
1261
+ else validated[fieldName] = coerced;
1262
+ }
1263
+ return validated;
1264
+ };
1265
+ //#endregion
1134
1266
  //#region src/utils/load-config.ts
1135
1267
  const CONFIG_FILENAME = "react-doctor.config.json";
1136
1268
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
@@ -1139,30 +1271,52 @@ const loadConfigFromDirectory = (directory) => {
1139
1271
  if (isFile(configFilePath)) try {
1140
1272
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
1141
1273
  const parsed = JSON.parse(fileContent);
1142
- if (isPlainObject(parsed)) return parsed;
1143
- console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
1274
+ if (isPlainObject(parsed)) return validateConfigTypes(parsed);
1275
+ logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
1144
1276
  } catch (error) {
1145
- console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
1277
+ logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
1146
1278
  }
1147
1279
  const packageJsonPath = path.join(directory, "package.json");
1148
1280
  if (isFile(packageJsonPath)) try {
1149
1281
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
1150
- const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
1151
- if (isPlainObject(embeddedConfig)) return embeddedConfig;
1282
+ const packageJson = JSON.parse(fileContent);
1283
+ if (isPlainObject(packageJson)) {
1284
+ const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
1285
+ if (isPlainObject(embeddedConfig)) return validateConfigTypes(embeddedConfig);
1286
+ }
1152
1287
  } catch {
1153
1288
  return null;
1154
1289
  }
1155
1290
  return null;
1156
1291
  };
1292
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1293
+ const cachedConfigs = /* @__PURE__ */ new Map();
1157
1294
  const loadConfig = (rootDirectory) => {
1295
+ const cached = cachedConfigs.get(rootDirectory);
1296
+ if (cached !== void 0) return cached;
1158
1297
  const localConfig = loadConfigFromDirectory(rootDirectory);
1159
- if (localConfig) return localConfig;
1298
+ if (localConfig) {
1299
+ cachedConfigs.set(rootDirectory, localConfig);
1300
+ return localConfig;
1301
+ }
1302
+ if (isProjectBoundary(rootDirectory)) {
1303
+ cachedConfigs.set(rootDirectory, null);
1304
+ return null;
1305
+ }
1160
1306
  let ancestorDirectory = path.dirname(rootDirectory);
1161
1307
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1162
1308
  const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
1163
- if (ancestorConfig) return ancestorConfig;
1309
+ if (ancestorConfig) {
1310
+ cachedConfigs.set(rootDirectory, ancestorConfig);
1311
+ return ancestorConfig;
1312
+ }
1313
+ if (isProjectBoundary(ancestorDirectory)) {
1314
+ cachedConfigs.set(rootDirectory, null);
1315
+ return null;
1316
+ }
1164
1317
  ancestorDirectory = path.dirname(ancestorDirectory);
1165
1318
  }
1319
+ cachedConfigs.set(rootDirectory, null);
1166
1320
  return null;
1167
1321
  };
1168
1322
  //#endregion
@@ -1205,23 +1359,25 @@ const findCompatibleNvmBinary = () => {
1205
1359
  return existsSync(binaryPath) ? binaryPath : null;
1206
1360
  };
1207
1361
  const getNodeVersionFromBinary = (binaryPath) => {
1208
- try {
1209
- return execSync(`"${binaryPath}" --version`, { encoding: "utf-8" }).trim();
1210
- } catch {
1211
- return null;
1212
- }
1362
+ const result = spawnSync(binaryPath, ["--version"], { encoding: "utf-8" });
1363
+ if (result.error || result.status !== 0) return null;
1364
+ return result.stdout.toString().trim();
1213
1365
  };
1214
1366
  const installNodeViaNvm = () => {
1215
1367
  const nvmDirectory = getNvmDirectory();
1216
1368
  if (!nvmDirectory) return false;
1217
1369
  const nvmScript = path.join(nvmDirectory, "nvm.sh");
1218
1370
  if (!existsSync(nvmScript)) return false;
1219
- try {
1220
- execSync(`bash -c ". '${nvmScript}' && nvm install 24"`, { stdio: "inherit" });
1221
- return findCompatibleNvmBinary() !== null;
1222
- } catch {
1223
- return false;
1224
- }
1371
+ const result = spawnSync("bash", ["-c", ". \"$NVM_SCRIPT\" && nvm install \"$NODE_MAJOR\""], {
1372
+ stdio: "inherit",
1373
+ env: {
1374
+ ...process.env,
1375
+ NVM_SCRIPT: nvmScript,
1376
+ NODE_MAJOR: String(24)
1377
+ }
1378
+ });
1379
+ if (result.error || result.status !== 0) return false;
1380
+ return findCompatibleNvmBinary() !== null;
1225
1381
  };
1226
1382
  const resolveNodeForOxlint = () => {
1227
1383
  if (isCurrentNodeCompatibleWithOxlint()) return {
@@ -1244,16 +1400,18 @@ const resolveNodeForOxlint = () => {
1244
1400
  const listSourceFilesViaGit = (rootDirectory) => {
1245
1401
  const result = spawnSync("git", [
1246
1402
  "ls-files",
1403
+ "-z",
1247
1404
  "--cached",
1248
1405
  "--others",
1249
- "--exclude-standard"
1406
+ "--exclude-standard",
1407
+ "--recurse-submodules"
1250
1408
  ], {
1251
1409
  cwd: rootDirectory,
1252
1410
  encoding: "utf-8",
1253
1411
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
1254
1412
  });
1255
1413
  if (result.error || result.status !== 0) return null;
1256
- return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1414
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1257
1415
  };
1258
1416
  const listSourceFilesViaFilesystem = (rootDirectory) => {
1259
1417
  const filePaths = [];
@@ -1297,10 +1455,13 @@ const collectUnusedFilePaths = (filesIssues) => {
1297
1455
  //#endregion
1298
1456
  //#region src/utils/extract-failed-plugin-name.ts
1299
1457
  const PLUGIN_CONFIG_PATTERN = /(?:^|[/\\\s])([a-z][a-z0-9-]*)\.config\./i;
1458
+ const RC_DOTFILE_PATTERN = /(?:^|[/\\])\.([a-z][a-z0-9-]*?)rc(?:\.[a-z]+)?(?:\b|$)/i;
1300
1459
  const extractFailedPluginName = (error) => {
1301
1460
  for (const errorMessage of getErrorChainMessages(error)) {
1302
1461
  const pluginNameMatch = errorMessage.match(PLUGIN_CONFIG_PATTERN);
1303
1462
  if (pluginNameMatch?.[1]) return pluginNameMatch[1].toLowerCase();
1463
+ const rcMatch = errorMessage.match(RC_DOTFILE_PATTERN);
1464
+ if (rcMatch?.[1]) return rcMatch[1].toLowerCase();
1304
1465
  }
1305
1466
  return null;
1306
1467
  };
@@ -1309,37 +1470,46 @@ const extractFailedPluginName = (error) => {
1309
1470
  const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
1310
1471
  //#endregion
1311
1472
  //#region src/utils/run-knip.ts
1312
- const KNIP_CATEGORY_MAP = {
1313
- files: "Dead Code",
1314
- exports: "Dead Code",
1315
- types: "Dead Code",
1316
- duplicates: "Dead Code"
1317
- };
1318
- const KNIP_MESSAGE_MAP = {
1319
- files: "Unused file",
1320
- exports: "Unused export",
1321
- types: "Unused type",
1322
- duplicates: "Duplicate export"
1323
- };
1324
- const KNIP_SEVERITY_MAP = {
1325
- files: "warning",
1326
- exports: "warning",
1327
- types: "warning",
1328
- duplicates: "warning"
1473
+ const KNIP_ISSUE_TYPE_DESCRIPTORS = {
1474
+ files: {
1475
+ category: "Dead Code",
1476
+ message: "Unused file",
1477
+ severity: "warning"
1478
+ },
1479
+ exports: {
1480
+ category: "Dead Code",
1481
+ message: "Unused export",
1482
+ severity: "warning"
1483
+ },
1484
+ types: {
1485
+ category: "Dead Code",
1486
+ message: "Unused type",
1487
+ severity: "warning"
1488
+ },
1489
+ duplicates: {
1490
+ category: "Dead Code",
1491
+ message: "Duplicate export",
1492
+ severity: "warning"
1493
+ }
1494
+ };
1495
+ const FALLBACK_KNIP_DESCRIPTOR = {
1496
+ category: "Dead Code",
1497
+ message: "Issue",
1498
+ severity: "warning"
1329
1499
  };
1330
1500
  const collectIssueRecords = (records, issueType, rootDirectory) => {
1501
+ const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS[issueType] ?? FALLBACK_KNIP_DESCRIPTOR;
1331
1502
  const diagnostics = [];
1332
1503
  for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
1333
1504
  filePath: path.relative(rootDirectory, issue.filePath),
1334
1505
  plugin: "knip",
1335
1506
  rule: issueType,
1336
- severity: KNIP_SEVERITY_MAP[issueType] ?? "warning",
1337
- message: `${KNIP_MESSAGE_MAP[issueType]}: ${issue.symbol}`,
1507
+ severity: descriptor.severity,
1508
+ message: `${descriptor.message}: ${issue.symbol}`,
1338
1509
  help: "",
1339
1510
  line: 0,
1340
1511
  column: 0,
1341
- category: KNIP_CATEGORY_MAP[issueType] ?? "Dead Code",
1342
- weight: 1
1512
+ category: descriptor.category
1343
1513
  });
1344
1514
  return diagnostics;
1345
1515
  };
@@ -1348,10 +1518,11 @@ const silenced = async (fn) => {
1348
1518
  const originalInfo = console.info;
1349
1519
  const originalWarn = console.warn;
1350
1520
  const originalError = console.error;
1351
- console.log = () => {};
1352
- console.info = () => {};
1353
- console.warn = () => {};
1354
- console.error = () => {};
1521
+ const noop = () => {};
1522
+ console.log = noop;
1523
+ console.info = noop;
1524
+ console.warn = noop;
1525
+ console.error = noop;
1355
1526
  try {
1356
1527
  return await fn();
1357
1528
  } finally {
@@ -1361,8 +1532,8 @@ const silenced = async (fn) => {
1361
1532
  console.error = originalError;
1362
1533
  }
1363
1534
  };
1364
- const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
1365
- const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
1535
+ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
1536
+ const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
1366
1537
  const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
1367
1538
  const failedPlugin = extractFailedPluginName(error);
1368
1539
  if (!failedPlugin || !(failedPlugin in parsedConfig) || disabledPlugins.has(failedPlugin)) return false;
@@ -1381,7 +1552,7 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
1381
1552
  const parsedConfig = options.parsedConfig;
1382
1553
  const disabledPlugins = /* @__PURE__ */ new Set();
1383
1554
  let lastKnipError;
1384
- for (let attempt = 0; attempt <= 5; attempt++) try {
1555
+ for (let attempt = 0; attempt < 6; attempt++) try {
1385
1556
  return await silenced(() => main(options));
1386
1557
  } catch (error) {
1387
1558
  lastKnipError = error;
@@ -1410,17 +1581,17 @@ const runKnip = async (rootDirectory) => {
1410
1581
  if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
1411
1582
  const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
1412
1583
  const diagnostics = [];
1584
+ const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
1413
1585
  for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
1414
1586
  filePath: path.relative(rootDirectory, unusedFilePath),
1415
1587
  plugin: "knip",
1416
1588
  rule: "files",
1417
- severity: KNIP_SEVERITY_MAP["files"],
1418
- message: KNIP_MESSAGE_MAP["files"],
1589
+ severity: filesDescriptor.severity,
1590
+ message: filesDescriptor.message,
1419
1591
  help: "This file is not imported by any other file in the project.",
1420
1592
  line: 0,
1421
1593
  column: 0,
1422
- category: KNIP_CATEGORY_MAP["files"],
1423
- weight: 1
1594
+ category: filesDescriptor.category
1424
1595
  });
1425
1596
  for (const issueType of [
1426
1597
  "exports",
@@ -1430,6 +1601,113 @@ const runKnip = async (rootDirectory) => {
1430
1601
  return diagnostics;
1431
1602
  };
1432
1603
  //#endregion
1604
+ //#region src/utils/batch-include-paths.ts
1605
+ const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1606
+ const batchIncludePaths = (baseArgs, includePaths) => {
1607
+ const baseArgsLength = estimateArgsLength(baseArgs);
1608
+ const batches = [];
1609
+ let currentBatch = [];
1610
+ let currentBatchLength = baseArgsLength;
1611
+ for (const filePath of includePaths) {
1612
+ const entryLength = filePath.length + 1;
1613
+ const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > 24e3;
1614
+ const exceedsFileCount = currentBatch.length >= 500;
1615
+ if (exceedsArgLength || exceedsFileCount) {
1616
+ batches.push(currentBatch);
1617
+ currentBatch = [];
1618
+ currentBatchLength = baseArgsLength;
1619
+ }
1620
+ currentBatch.push(filePath);
1621
+ currentBatchLength += entryLength;
1622
+ }
1623
+ if (currentBatch.length > 0) batches.push(currentBatch);
1624
+ return batches;
1625
+ };
1626
+ //#endregion
1627
+ //#region src/utils/parse-gitattributes-linguist.ts
1628
+ const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
1629
+ const FALSY_VALUES = new Set([
1630
+ "false",
1631
+ "0",
1632
+ "off",
1633
+ "no"
1634
+ ]);
1635
+ const isTruthyLinguistAttribute = (token) => {
1636
+ const match = LINGUIST_ATTRIBUTE_PATTERN.exec(token);
1637
+ if (!match) return false;
1638
+ if (match[1] === void 0) return true;
1639
+ return !FALSY_VALUES.has(match[1].toLowerCase());
1640
+ };
1641
+ const parseGitattributesLinguistPaths = (filePath) => {
1642
+ let content;
1643
+ try {
1644
+ content = fs.readFileSync(filePath, "utf-8");
1645
+ } catch {
1646
+ return [];
1647
+ }
1648
+ const paths = [];
1649
+ for (const rawLine of content.split("\n")) {
1650
+ const line = rawLine.trim();
1651
+ if (line.length === 0 || line.startsWith("#")) continue;
1652
+ const tokens = line.split(/\s+/);
1653
+ if (tokens.length < 2) continue;
1654
+ const [pathSpec, ...attributes] = tokens;
1655
+ if (attributes.some(isTruthyLinguistAttribute)) paths.push(pathSpec);
1656
+ }
1657
+ return paths;
1658
+ };
1659
+ //#endregion
1660
+ //#region src/utils/read-ignore-file.ts
1661
+ const stripGitignoreEscape = (pattern) => {
1662
+ if (pattern.startsWith("\\#") || pattern.startsWith("\\!")) return pattern.slice(1);
1663
+ return pattern;
1664
+ };
1665
+ const readIgnoreFile = (filePath) => {
1666
+ let content;
1667
+ try {
1668
+ content = fs.readFileSync(filePath, "utf-8");
1669
+ } catch (error) {
1670
+ const errnoCode = error?.code;
1671
+ if (errnoCode && errnoCode !== "ENOENT") logger.warn(`Could not read ignore file ${filePath}: ${errnoCode}`);
1672
+ return [];
1673
+ }
1674
+ const patterns = [];
1675
+ for (const line of content.split("\n")) {
1676
+ const trimmed = line.trim();
1677
+ if (trimmed.length === 0) continue;
1678
+ if (trimmed.startsWith("#")) continue;
1679
+ patterns.push(stripGitignoreEscape(trimmed));
1680
+ }
1681
+ return patterns;
1682
+ };
1683
+ //#endregion
1684
+ //#region src/utils/collect-ignore-patterns.ts
1685
+ const IGNORE_FILENAMES = [
1686
+ ".eslintignore",
1687
+ ".oxlintignore",
1688
+ ".prettierignore"
1689
+ ];
1690
+ const cachedPatternsByRoot = /* @__PURE__ */ new Map();
1691
+ const computeIgnorePatterns = (rootDirectory) => {
1692
+ const seen = /* @__PURE__ */ new Set();
1693
+ const patterns = [];
1694
+ const addPattern = (pattern) => {
1695
+ if (seen.has(pattern)) return;
1696
+ seen.add(pattern);
1697
+ patterns.push(pattern);
1698
+ };
1699
+ for (const filename of IGNORE_FILENAMES) for (const pattern of readIgnoreFile(path.join(rootDirectory, filename))) addPattern(pattern);
1700
+ for (const linguistPath of parseGitattributesLinguistPaths(path.join(rootDirectory, ".gitattributes"))) addPattern(linguistPath);
1701
+ return patterns;
1702
+ };
1703
+ const collectIgnorePatterns = (rootDirectory) => {
1704
+ const cached = cachedPatternsByRoot.get(rootDirectory);
1705
+ if (cached !== void 0) return cached;
1706
+ const patterns = computeIgnorePatterns(rootDirectory);
1707
+ cachedPatternsByRoot.set(rootDirectory, patterns);
1708
+ return patterns;
1709
+ };
1710
+ //#endregion
1433
1711
  //#region src/oxlint-config.ts
1434
1712
  const esmRequire$1 = createRequire(import.meta.url);
1435
1713
  const NEXTJS_RULES = {
@@ -1458,7 +1736,23 @@ const REACT_NATIVE_RULES = {
1458
1736
  "react-doctor/rn-no-inline-flatlist-renderitem": "warn",
1459
1737
  "react-doctor/rn-no-legacy-shadow-styles": "warn",
1460
1738
  "react-doctor/rn-prefer-reanimated": "warn",
1461
- "react-doctor/rn-no-single-element-style-array": "warn"
1739
+ "react-doctor/rn-no-single-element-style-array": "warn",
1740
+ "react-doctor/rn-prefer-pressable": "warn",
1741
+ "react-doctor/rn-prefer-expo-image": "warn",
1742
+ "react-doctor/rn-no-non-native-navigator": "warn",
1743
+ "react-doctor/rn-no-scroll-state": "error",
1744
+ "react-doctor/rn-no-scrollview-mapped-list": "warn",
1745
+ "react-doctor/rn-no-inline-object-in-list-item": "warn",
1746
+ "react-doctor/rn-animate-layout-property": "error",
1747
+ "react-doctor/rn-prefer-content-inset-adjustment": "warn",
1748
+ "react-doctor/rn-pressable-shared-value-mutation": "warn",
1749
+ "react-doctor/rn-list-data-mapped": "warn",
1750
+ "react-doctor/rn-list-callback-per-row": "warn",
1751
+ "react-doctor/rn-list-recyclable-without-types": "warn",
1752
+ "react-doctor/rn-animation-reaction-as-derived": "warn",
1753
+ "react-doctor/rn-bottom-sheet-prefer-native": "warn",
1754
+ "react-doctor/rn-scrollview-dynamic-padding": "warn",
1755
+ "react-doctor/rn-style-prefer-boxshadow": "warn"
1462
1756
  };
1463
1757
  const TANSTACK_START_RULES = {
1464
1758
  "react-doctor/tanstack-start-route-property-order": "error",
@@ -1477,22 +1771,41 @@ const TANSTACK_START_RULES = {
1477
1771
  "react-doctor/tanstack-start-loader-parallel-fetch": "warn"
1478
1772
  };
1479
1773
  const REACT_COMPILER_RULES = {
1480
- "react-hooks-js/set-state-in-render": "error",
1481
- "react-hooks-js/immutability": "error",
1482
- "react-hooks-js/refs": "error",
1483
- "react-hooks-js/purity": "error",
1484
- "react-hooks-js/hooks": "error",
1485
- "react-hooks-js/set-state-in-effect": "error",
1486
- "react-hooks-js/globals": "error",
1487
- "react-hooks-js/error-boundaries": "error",
1488
- "react-hooks-js/preserve-manual-memoization": "error",
1489
- "react-hooks-js/unsupported-syntax": "error",
1490
- "react-hooks-js/component-hook-factories": "error",
1491
- "react-hooks-js/static-components": "error",
1492
- "react-hooks-js/use-memo": "error",
1493
- "react-hooks-js/void-use-memo": "error",
1494
- "react-hooks-js/incompatible-library": "error",
1495
- "react-hooks-js/todo": "error"
1774
+ "react-hooks-js/set-state-in-render": "warn",
1775
+ "react-hooks-js/immutability": "warn",
1776
+ "react-hooks-js/refs": "warn",
1777
+ "react-hooks-js/purity": "warn",
1778
+ "react-hooks-js/hooks": "warn",
1779
+ "react-hooks-js/set-state-in-effect": "warn",
1780
+ "react-hooks-js/globals": "warn",
1781
+ "react-hooks-js/error-boundaries": "warn",
1782
+ "react-hooks-js/preserve-manual-memoization": "warn",
1783
+ "react-hooks-js/unsupported-syntax": "warn",
1784
+ "react-hooks-js/component-hook-factories": "warn",
1785
+ "react-hooks-js/static-components": "warn",
1786
+ "react-hooks-js/use-memo": "warn",
1787
+ "react-hooks-js/void-use-memo": "warn",
1788
+ "react-hooks-js/incompatible-library": "warn",
1789
+ "react-hooks-js/todo": "warn"
1790
+ };
1791
+ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
1792
+ if (!hasReactCompiler || customRulesOnly) return [];
1793
+ try {
1794
+ return [{
1795
+ name: "react-hooks-js",
1796
+ specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1797
+ }];
1798
+ } catch {
1799
+ return [];
1800
+ }
1801
+ };
1802
+ const TANSTACK_QUERY_RULES = {
1803
+ "react-doctor/query-stable-query-client": "warn",
1804
+ "react-doctor/query-no-rest-destructuring": "warn",
1805
+ "react-doctor/query-no-void-query-fn": "warn",
1806
+ "react-doctor/query-no-query-in-effect": "warn",
1807
+ "react-doctor/query-mutation-missing-invalidation": "warn",
1808
+ "react-doctor/query-no-usequery-for-mutation": "warn"
1496
1809
  };
1497
1810
  const BUILTIN_REACT_RULES = {
1498
1811
  "react/rules-of-hooks": "error",
@@ -1524,7 +1837,113 @@ const BUILTIN_A11Y_RULES = {
1524
1837
  "jsx-a11y/no-distracting-elements": "error",
1525
1838
  "jsx-a11y/iframe-has-title": "warn"
1526
1839
  };
1527
- const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRulesOnly = false }) => ({
1840
+ const GLOBAL_REACT_DOCTOR_RULES = {
1841
+ "react-doctor/no-derived-state-effect": "warn",
1842
+ "react-doctor/no-fetch-in-effect": "warn",
1843
+ "react-doctor/no-cascading-set-state": "warn",
1844
+ "react-doctor/no-effect-event-handler": "warn",
1845
+ "react-doctor/no-effect-event-in-deps": "error",
1846
+ "react-doctor/no-prop-callback-in-effect": "warn",
1847
+ "react-doctor/no-derived-useState": "warn",
1848
+ "react-doctor/prefer-useReducer": "warn",
1849
+ "react-doctor/rerender-lazy-state-init": "warn",
1850
+ "react-doctor/rerender-functional-setstate": "warn",
1851
+ "react-doctor/rerender-dependencies": "error",
1852
+ "react-doctor/rerender-state-only-in-handlers": "warn",
1853
+ "react-doctor/rerender-defer-reads-hook": "warn",
1854
+ "react-doctor/advanced-event-handler-refs": "warn",
1855
+ "react-doctor/no-giant-component": "warn",
1856
+ "react-doctor/no-render-in-render": "warn",
1857
+ "react-doctor/no-many-boolean-props": "warn",
1858
+ "react-doctor/no-react19-deprecated-apis": "warn",
1859
+ "react-doctor/no-render-prop-children": "warn",
1860
+ "react-doctor/no-nested-component-definition": "error",
1861
+ "react-doctor/react-compiler-destructure-method": "warn",
1862
+ "react-doctor/no-usememo-simple-expression": "warn",
1863
+ "react-doctor/no-layout-property-animation": "error",
1864
+ "react-doctor/rerender-memo-with-default-value": "warn",
1865
+ "react-doctor/rerender-memo-before-early-return": "warn",
1866
+ "react-doctor/rerender-transitions-scroll": "warn",
1867
+ "react-doctor/rerender-derived-state-from-hook": "warn",
1868
+ "react-doctor/async-defer-await": "warn",
1869
+ "react-doctor/async-await-in-loop": "warn",
1870
+ "react-doctor/rendering-animate-svg-wrapper": "warn",
1871
+ "react-doctor/rendering-hoist-jsx": "warn",
1872
+ "react-doctor/rendering-hydration-mismatch-time": "warn",
1873
+ "react-doctor/no-inline-prop-on-memo-component": "warn",
1874
+ "react-doctor/rendering-hydration-no-flicker": "warn",
1875
+ "react-doctor/rendering-script-defer-async": "warn",
1876
+ "react-doctor/rendering-usetransition-loading": "warn",
1877
+ "react-doctor/no-transition-all": "warn",
1878
+ "react-doctor/no-global-css-variable-animation": "error",
1879
+ "react-doctor/no-large-animated-blur": "warn",
1880
+ "react-doctor/no-scale-from-zero": "warn",
1881
+ "react-doctor/no-permanent-will-change": "warn",
1882
+ "react-doctor/no-eval": "error",
1883
+ "react-doctor/no-secrets-in-client-code": "warn",
1884
+ "react-doctor/no-generic-handler-names": "warn",
1885
+ "react-doctor/js-flatmap-filter": "warn",
1886
+ "react-doctor/js-combine-iterations": "warn",
1887
+ "react-doctor/js-tosorted-immutable": "warn",
1888
+ "react-doctor/js-hoist-regexp": "warn",
1889
+ "react-doctor/js-hoist-intl": "warn",
1890
+ "react-doctor/js-cache-property-access": "warn",
1891
+ "react-doctor/js-length-check-first": "warn",
1892
+ "react-doctor/js-min-max-loop": "warn",
1893
+ "react-doctor/js-set-map-lookups": "warn",
1894
+ "react-doctor/js-batch-dom-css": "warn",
1895
+ "react-doctor/js-index-maps": "warn",
1896
+ "react-doctor/js-cache-storage": "warn",
1897
+ "react-doctor/js-early-exit": "warn",
1898
+ "react-doctor/no-barrel-import": "warn",
1899
+ "react-doctor/no-dynamic-import-path": "warn",
1900
+ "react-doctor/no-full-lodash-import": "warn",
1901
+ "react-doctor/no-moment": "warn",
1902
+ "react-doctor/prefer-dynamic-import": "warn",
1903
+ "react-doctor/use-lazy-motion": "warn",
1904
+ "react-doctor/no-undeferred-third-party": "warn",
1905
+ "react-doctor/no-array-index-as-key": "warn",
1906
+ "react-doctor/no-polymorphic-children": "warn",
1907
+ "react-doctor/rendering-conditional-render": "warn",
1908
+ "react-doctor/rendering-svg-precision": "warn",
1909
+ "react-doctor/no-prevent-default": "warn",
1910
+ "react-doctor/no-document-start-view-transition": "warn",
1911
+ "react-doctor/no-flush-sync": "warn",
1912
+ "react-doctor/server-auth-actions": "error",
1913
+ "react-doctor/server-after-nonblocking": "warn",
1914
+ "react-doctor/server-no-mutable-module-state": "error",
1915
+ "react-doctor/server-cache-with-object-literal": "warn",
1916
+ "react-doctor/server-hoist-static-io": "warn",
1917
+ "react-doctor/server-dedup-props": "warn",
1918
+ "react-doctor/server-sequential-independent-await": "warn",
1919
+ "react-doctor/server-fetch-without-revalidate": "warn",
1920
+ "react-doctor/client-passive-event-listeners": "warn",
1921
+ "react-doctor/client-localstorage-no-version": "warn",
1922
+ "react-doctor/no-inline-bounce-easing": "warn",
1923
+ "react-doctor/no-z-index-9999": "warn",
1924
+ "react-doctor/no-inline-exhaustive-style": "warn",
1925
+ "react-doctor/no-side-tab-border": "warn",
1926
+ "react-doctor/no-pure-black-background": "warn",
1927
+ "react-doctor/no-gradient-text": "warn",
1928
+ "react-doctor/no-dark-mode-glow": "warn",
1929
+ "react-doctor/no-justified-text": "warn",
1930
+ "react-doctor/no-tiny-text": "warn",
1931
+ "react-doctor/no-wide-letter-spacing": "warn",
1932
+ "react-doctor/no-gray-on-colored-background": "warn",
1933
+ "react-doctor/no-layout-transition-inline": "warn",
1934
+ "react-doctor/no-disabled-zoom": "error",
1935
+ "react-doctor/no-outline-none": "warn",
1936
+ "react-doctor/no-long-transition-duration": "warn",
1937
+ "react-doctor/async-parallel": "warn"
1938
+ };
1939
+ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
1940
+ ...Object.keys(GLOBAL_REACT_DOCTOR_RULES),
1941
+ ...Object.keys(NEXTJS_RULES),
1942
+ ...Object.keys(REACT_NATIVE_RULES),
1943
+ ...Object.keys(TANSTACK_START_RULES),
1944
+ ...Object.keys(TANSTACK_QUERY_RULES)
1945
+ ]);
1946
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false }) => ({
1528
1947
  categories: {
1529
1948
  correctness: "off",
1530
1949
  suspicious: "off",
@@ -1534,87 +1953,23 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRul
1534
1953
  style: "off",
1535
1954
  nursery: "off"
1536
1955
  },
1537
- plugins: [
1538
- "react",
1539
- "jsx-a11y",
1540
- ...hasReactCompiler ? [] : ["react-perf"]
1541
- ],
1542
- jsPlugins: [...hasReactCompiler && !customRulesOnly ? [{
1543
- name: "react-hooks-js",
1544
- specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1545
- }] : [], pluginPath],
1956
+ plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
1957
+ jsPlugins: [...resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly), pluginPath],
1546
1958
  rules: {
1547
1959
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
1548
1960
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
1549
1961
  ...hasReactCompiler && !customRulesOnly ? REACT_COMPILER_RULES : {},
1550
- "react-doctor/no-derived-state-effect": "error",
1551
- "react-doctor/no-fetch-in-effect": "error",
1552
- "react-doctor/no-cascading-set-state": "warn",
1553
- "react-doctor/no-effect-event-handler": "warn",
1554
- "react-doctor/no-derived-useState": "warn",
1555
- "react-doctor/prefer-useReducer": "warn",
1556
- "react-doctor/rerender-lazy-state-init": "warn",
1557
- "react-doctor/rerender-functional-setstate": "warn",
1558
- "react-doctor/rerender-dependencies": "error",
1559
- "react-doctor/no-giant-component": "warn",
1560
- "react-doctor/no-render-in-render": "warn",
1561
- "react-doctor/no-nested-component-definition": "error",
1562
- "react-doctor/no-usememo-simple-expression": "warn",
1563
- "react-doctor/no-layout-property-animation": "error",
1564
- "react-doctor/rerender-memo-with-default-value": "warn",
1565
- "react-doctor/rendering-animate-svg-wrapper": "warn",
1566
- "react-doctor/no-inline-prop-on-memo-component": "warn",
1567
- "react-doctor/rendering-hydration-no-flicker": "warn",
1568
- "react-doctor/rendering-script-defer-async": "warn",
1569
- "react-doctor/no-transition-all": "warn",
1570
- "react-doctor/no-global-css-variable-animation": "error",
1571
- "react-doctor/no-large-animated-blur": "warn",
1572
- "react-doctor/no-scale-from-zero": "warn",
1573
- "react-doctor/no-permanent-will-change": "warn",
1574
- "react-doctor/no-secrets-in-client-code": "error",
1575
- "react-doctor/js-flatmap-filter": "warn",
1576
- "react-doctor/no-barrel-import": "warn",
1577
- "react-doctor/no-full-lodash-import": "warn",
1578
- "react-doctor/no-moment": "warn",
1579
- "react-doctor/prefer-dynamic-import": "warn",
1580
- "react-doctor/use-lazy-motion": "warn",
1581
- "react-doctor/no-undeferred-third-party": "warn",
1582
- "react-doctor/no-array-index-as-key": "warn",
1583
- "react-doctor/rendering-conditional-render": "warn",
1584
- "react-doctor/no-prevent-default": "warn",
1585
- "react-doctor/server-auth-actions": "error",
1586
- "react-doctor/server-after-nonblocking": "warn",
1587
- "react-doctor/client-passive-event-listeners": "warn",
1588
- "react-doctor/query-stable-query-client": "error",
1589
- "react-doctor/query-no-rest-destructuring": "warn",
1590
- "react-doctor/query-no-void-query-fn": "warn",
1591
- "react-doctor/query-no-query-in-effect": "warn",
1592
- "react-doctor/query-mutation-missing-invalidation": "warn",
1593
- "react-doctor/query-no-usequery-for-mutation": "warn",
1594
- "react-doctor/no-inline-bounce-easing": "warn",
1595
- "react-doctor/no-z-index-9999": "warn",
1596
- "react-doctor/no-inline-exhaustive-style": "warn",
1597
- "react-doctor/no-side-tab-border": "warn",
1598
- "react-doctor/no-pure-black-background": "warn",
1599
- "react-doctor/no-gradient-text": "warn",
1600
- "react-doctor/no-dark-mode-glow": "warn",
1601
- "react-doctor/no-justified-text": "warn",
1602
- "react-doctor/no-tiny-text": "warn",
1603
- "react-doctor/no-wide-letter-spacing": "warn",
1604
- "react-doctor/no-gray-on-colored-background": "warn",
1605
- "react-doctor/no-layout-transition-inline": "warn",
1606
- "react-doctor/no-disabled-zoom": "error",
1607
- "react-doctor/no-outline-none": "warn",
1608
- "react-doctor/no-long-transition-duration": "warn",
1609
- "react-doctor/async-parallel": "warn",
1962
+ ...GLOBAL_REACT_DOCTOR_RULES,
1610
1963
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1611
1964
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
1612
- ...framework === "tanstack-start" ? TANSTACK_START_RULES : {}
1965
+ ...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
1966
+ ...hasTanStackQuery ? TANSTACK_QUERY_RULES : {}
1613
1967
  }
1614
1968
  });
1615
1969
  //#endregion
1616
1970
  //#region src/utils/neutralize-disable-directives.ts
1617
- const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1971
+ const DISABLE_DIRECTIVE_PATTERN = /(eslint|oxlint)-disable/;
1972
+ const findFilesWithDisableDirectivesViaGit = (rootDirectory, includePaths) => {
1618
1973
  const grepArgs = [
1619
1974
  "grep",
1620
1975
  "-l",
@@ -1628,14 +1983,65 @@ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1628
1983
  encoding: "utf-8",
1629
1984
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
1630
1985
  });
1631
- if (result.error || result.status === null) return [];
1632
- if (result.status !== 0 && result.stdout.trim().length === 0) return [];
1986
+ if (result.error || result.status === null) return null;
1987
+ if (result.status === 128) return null;
1633
1988
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1634
1989
  };
1990
+ const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
1991
+ const matches = [];
1992
+ const checkFile = (relativePath) => {
1993
+ if (!SOURCE_FILE_PATTERN.test(relativePath)) return;
1994
+ const absolutePath = path.join(rootDirectory, relativePath);
1995
+ let content;
1996
+ try {
1997
+ content = fs.readFileSync(absolutePath, "utf-8");
1998
+ } catch {
1999
+ return;
2000
+ }
2001
+ if (DISABLE_DIRECTIVE_PATTERN.test(content)) matches.push(relativePath);
2002
+ };
2003
+ if (includePaths && includePaths.length > 0) {
2004
+ for (const candidate of includePaths) checkFile(candidate);
2005
+ return matches;
2006
+ }
2007
+ const stack = [rootDirectory];
2008
+ while (stack.length > 0) {
2009
+ const current = stack.pop();
2010
+ if (current === void 0) continue;
2011
+ let entries;
2012
+ try {
2013
+ entries = fs.readdirSync(current, { withFileTypes: true });
2014
+ } catch {
2015
+ continue;
2016
+ }
2017
+ for (const entry of entries) {
2018
+ if (entry.isDirectory()) {
2019
+ if (entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
2020
+ stack.push(path.join(current, entry.name));
2021
+ continue;
2022
+ }
2023
+ if (!entry.isFile()) continue;
2024
+ const absolute = path.join(current, entry.name);
2025
+ checkFile(path.relative(rootDirectory, absolute));
2026
+ }
2027
+ }
2028
+ return matches;
2029
+ };
2030
+ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => findFilesWithDisableDirectivesViaGit(rootDirectory, includePaths) ?? findFilesWithDisableDirectivesViaFilesystem(rootDirectory, includePaths);
1635
2031
  const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
1636
2032
  const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1637
2033
  const filePaths = findFilesWithDisableDirectives(rootDirectory, includePaths);
1638
2034
  const originalContents = /* @__PURE__ */ new Map();
2035
+ let isRestored = false;
2036
+ const restore = () => {
2037
+ if (isRestored) return;
2038
+ isRestored = true;
2039
+ for (const [absolutePath, originalContent] of originalContents) try {
2040
+ fs.writeFileSync(absolutePath, originalContent);
2041
+ } catch {}
2042
+ };
2043
+ const onExit = () => restore();
2044
+ process.once("exit", onExit);
1639
2045
  for (const relativePath of filePaths) {
1640
2046
  const absolutePath = path.join(rootDirectory, relativePath);
1641
2047
  let originalContent;
@@ -1651,7 +2057,8 @@ const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1651
2057
  }
1652
2058
  }
1653
2059
  return () => {
1654
- for (const [absolutePath, originalContent] of originalContents) fs.writeFileSync(absolutePath, originalContent);
2060
+ restore();
2061
+ process.removeListener("exit", onExit);
1655
2062
  };
1656
2063
  };
1657
2064
  //#endregion
@@ -1661,30 +2068,48 @@ const PLUGIN_CATEGORY_MAP = {
1661
2068
  react: "Correctness",
1662
2069
  "react-hooks": "Correctness",
1663
2070
  "react-hooks-js": "React Compiler",
1664
- "react-perf": "Performance",
1665
- "jsx-a11y": "Accessibility"
2071
+ "react-doctor": "Other",
2072
+ "jsx-a11y": "Accessibility",
2073
+ knip: "Dead Code"
1666
2074
  };
1667
2075
  const RULE_CATEGORY_MAP = {
1668
2076
  "react-doctor/no-derived-state-effect": "State & Effects",
1669
2077
  "react-doctor/no-fetch-in-effect": "State & Effects",
1670
2078
  "react-doctor/no-cascading-set-state": "State & Effects",
1671
2079
  "react-doctor/no-effect-event-handler": "State & Effects",
2080
+ "react-doctor/no-effect-event-in-deps": "State & Effects",
2081
+ "react-doctor/no-prop-callback-in-effect": "State & Effects",
1672
2082
  "react-doctor/no-derived-useState": "State & Effects",
1673
2083
  "react-doctor/prefer-useReducer": "State & Effects",
1674
2084
  "react-doctor/rerender-lazy-state-init": "Performance",
1675
2085
  "react-doctor/rerender-functional-setstate": "Performance",
1676
2086
  "react-doctor/rerender-dependencies": "State & Effects",
2087
+ "react-doctor/rerender-state-only-in-handlers": "Performance",
2088
+ "react-doctor/rerender-defer-reads-hook": "Performance",
2089
+ "react-doctor/advanced-event-handler-refs": "Performance",
1677
2090
  "react-doctor/no-generic-handler-names": "Architecture",
1678
2091
  "react-doctor/no-giant-component": "Architecture",
2092
+ "react-doctor/no-many-boolean-props": "Architecture",
2093
+ "react-doctor/no-react19-deprecated-apis": "Architecture",
2094
+ "react-doctor/no-render-prop-children": "Architecture",
1679
2095
  "react-doctor/no-render-in-render": "Architecture",
1680
2096
  "react-doctor/no-nested-component-definition": "Correctness",
2097
+ "react-doctor/react-compiler-destructure-method": "Architecture",
1681
2098
  "react-doctor/no-usememo-simple-expression": "Performance",
1682
2099
  "react-doctor/no-layout-property-animation": "Performance",
1683
2100
  "react-doctor/rerender-memo-with-default-value": "Performance",
2101
+ "react-doctor/rerender-memo-before-early-return": "Performance",
2102
+ "react-doctor/rerender-transitions-scroll": "Performance",
2103
+ "react-doctor/rerender-derived-state-from-hook": "Performance",
2104
+ "react-doctor/async-defer-await": "Performance",
2105
+ "react-doctor/async-await-in-loop": "Performance",
1684
2106
  "react-doctor/rendering-animate-svg-wrapper": "Performance",
2107
+ "react-doctor/rendering-hoist-jsx": "Performance",
2108
+ "react-doctor/rendering-hydration-mismatch-time": "Correctness",
1685
2109
  "react-doctor/rendering-usetransition-loading": "Performance",
1686
2110
  "react-doctor/rendering-hydration-no-flicker": "Performance",
1687
2111
  "react-doctor/rendering-script-defer-async": "Performance",
2112
+ "react-doctor/no-inline-prop-on-memo-component": "Performance",
1688
2113
  "react-doctor/no-transition-all": "Performance",
1689
2114
  "react-doctor/no-global-css-variable-animation": "Performance",
1690
2115
  "react-doctor/no-large-animated-blur": "Performance",
@@ -1692,14 +2117,19 @@ const RULE_CATEGORY_MAP = {
1692
2117
  "react-doctor/no-permanent-will-change": "Performance",
1693
2118
  "react-doctor/no-secrets-in-client-code": "Security",
1694
2119
  "react-doctor/no-barrel-import": "Bundle Size",
2120
+ "react-doctor/no-dynamic-import-path": "Bundle Size",
1695
2121
  "react-doctor/no-full-lodash-import": "Bundle Size",
1696
2122
  "react-doctor/no-moment": "Bundle Size",
1697
2123
  "react-doctor/prefer-dynamic-import": "Bundle Size",
1698
2124
  "react-doctor/use-lazy-motion": "Bundle Size",
1699
2125
  "react-doctor/no-undeferred-third-party": "Bundle Size",
1700
2126
  "react-doctor/no-array-index-as-key": "Correctness",
2127
+ "react-doctor/no-polymorphic-children": "Architecture",
1701
2128
  "react-doctor/rendering-conditional-render": "Correctness",
2129
+ "react-doctor/rendering-svg-precision": "Performance",
1702
2130
  "react-doctor/no-prevent-default": "Correctness",
2131
+ "react-doctor/no-document-start-view-transition": "Correctness",
2132
+ "react-doctor/no-flush-sync": "Performance",
1703
2133
  "react-doctor/nextjs-no-img-element": "Next.js",
1704
2134
  "react-doctor/nextjs-async-client-component": "Next.js",
1705
2135
  "react-doctor/nextjs-no-a-element": "Next.js",
@@ -1718,7 +2148,14 @@ const RULE_CATEGORY_MAP = {
1718
2148
  "react-doctor/nextjs-no-side-effect-in-get-handler": "Security",
1719
2149
  "react-doctor/server-auth-actions": "Server",
1720
2150
  "react-doctor/server-after-nonblocking": "Server",
2151
+ "react-doctor/server-no-mutable-module-state": "Server",
2152
+ "react-doctor/server-cache-with-object-literal": "Server",
2153
+ "react-doctor/server-hoist-static-io": "Server",
2154
+ "react-doctor/server-dedup-props": "Server",
2155
+ "react-doctor/server-sequential-independent-await": "Server",
2156
+ "react-doctor/server-fetch-without-revalidate": "Server",
1721
2157
  "react-doctor/client-passive-event-listeners": "Performance",
2158
+ "react-doctor/client-localstorage-no-version": "Correctness",
1722
2159
  "react-doctor/query-stable-query-client": "TanStack Query",
1723
2160
  "react-doctor/query-no-rest-destructuring": "TanStack Query",
1724
2161
  "react-doctor/query-no-void-query-fn": "TanStack Query",
@@ -1741,6 +2178,19 @@ const RULE_CATEGORY_MAP = {
1741
2178
  "react-doctor/no-outline-none": "Accessibility",
1742
2179
  "react-doctor/no-long-transition-duration": "Performance",
1743
2180
  "react-doctor/js-flatmap-filter": "Performance",
2181
+ "react-doctor/js-combine-iterations": "Performance",
2182
+ "react-doctor/js-tosorted-immutable": "Performance",
2183
+ "react-doctor/js-hoist-regexp": "Performance",
2184
+ "react-doctor/js-hoist-intl": "Performance",
2185
+ "react-doctor/js-cache-property-access": "Performance",
2186
+ "react-doctor/js-length-check-first": "Performance",
2187
+ "react-doctor/js-min-max-loop": "Performance",
2188
+ "react-doctor/js-set-map-lookups": "Performance",
2189
+ "react-doctor/js-batch-dom-css": "Performance",
2190
+ "react-doctor/js-index-maps": "Performance",
2191
+ "react-doctor/js-cache-storage": "Performance",
2192
+ "react-doctor/js-early-exit": "Performance",
2193
+ "react-doctor/no-eval": "Security",
1744
2194
  "react-doctor/async-parallel": "Performance",
1745
2195
  "react-doctor/rn-no-raw-text": "React Native",
1746
2196
  "react-doctor/rn-no-deprecated-modules": "React Native",
@@ -1750,6 +2200,22 @@ const RULE_CATEGORY_MAP = {
1750
2200
  "react-doctor/rn-no-legacy-shadow-styles": "React Native",
1751
2201
  "react-doctor/rn-prefer-reanimated": "React Native",
1752
2202
  "react-doctor/rn-no-single-element-style-array": "React Native",
2203
+ "react-doctor/rn-prefer-pressable": "React Native",
2204
+ "react-doctor/rn-prefer-expo-image": "React Native",
2205
+ "react-doctor/rn-no-non-native-navigator": "React Native",
2206
+ "react-doctor/rn-no-scroll-state": "React Native",
2207
+ "react-doctor/rn-no-scrollview-mapped-list": "React Native",
2208
+ "react-doctor/rn-no-inline-object-in-list-item": "React Native",
2209
+ "react-doctor/rn-animate-layout-property": "React Native",
2210
+ "react-doctor/rn-prefer-content-inset-adjustment": "React Native",
2211
+ "react-doctor/rn-pressable-shared-value-mutation": "React Native",
2212
+ "react-doctor/rn-list-data-mapped": "React Native",
2213
+ "react-doctor/rn-list-callback-per-row": "React Native",
2214
+ "react-doctor/rn-list-recyclable-without-types": "React Native",
2215
+ "react-doctor/rn-animation-reaction-as-derived": "React Native",
2216
+ "react-doctor/rn-bottom-sheet-prefer-native": "React Native",
2217
+ "react-doctor/rn-scrollview-dynamic-padding": "React Native",
2218
+ "react-doctor/rn-style-prefer-boxshadow": "React Native",
1753
2219
  "react-doctor/tanstack-start-route-property-order": "TanStack Start",
1754
2220
  "react-doctor/tanstack-start-no-direct-fetch-in-loader": "TanStack Start",
1755
2221
  "react-doctor/tanstack-start-server-fn-validate-input": "TanStack Start",
@@ -1775,17 +2241,44 @@ const RULE_HELP_MAP = {
1775
2241
  "rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
1776
2242
  "rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
1777
2243
  "rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
2244
+ "no-effect-event-in-deps": "Call the useEffectEvent callback inside the effect body without listing it; its identity is intentionally unstable",
2245
+ "no-prop-callback-in-effect": "Lift the shared state into a Provider so both sides read the same source — no useEffect-driven sync needed",
1778
2246
  "no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
1779
2247
  "no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
2248
+ "no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
2249
+ "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.",
2250
+ "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",
1780
2251
  "no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
1781
2252
  "no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
1782
2253
  "no-usememo-simple-expression": "Remove useMemo — property access, math, and ternaries are already cheap without memoization",
1783
2254
  "no-layout-property-animation": "Use `transform: translateX()` or `scale()` instead — they run on the compositor and skip layout/paint",
1784
2255
  "rerender-memo-with-default-value": "Move to module scope: `const EMPTY_ITEMS: Item[] = []` then use as the default value",
1785
2256
  "rendering-animate-svg-wrapper": "Wrap the SVG: `<motion.div animate={...}><svg>...</svg></motion.div>`",
2257
+ "rendering-hoist-jsx": "Move the static JSX to module scope: `const ICON = <svg>...</svg>` outside the component so it isn't recreated each render",
2258
+ "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",
2259
+ "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",
2260
+ "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",
2261
+ "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",
2262
+ "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",
2263
+ "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",
2264
+ "async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
2265
+ "async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
2266
+ "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",
2267
+ "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",
2268
+ "server-sequential-independent-await": "Wrap independent awaits in `Promise.all([...])` so they race instead of waterfalling — second call doesn't depend on the first",
2269
+ "server-fetch-without-revalidate": "Pass `{ next: { revalidate: <seconds> } }` (or `cache: \"no-store\"` / `next: { tags: [...] }`) so stale cached data doesn't silently persist",
2270
+ "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",
2271
+ "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",
2272
+ "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",
2273
+ "rendering-hydration-mismatch-time": "Wrap dynamic time/random values in useEffect+useState (client-only) or add suppressHydrationWarning to the parent if intentional",
2274
+ "no-polymorphic-children": "Expose explicit subcomponents (`<Button.Text>`, `<Button.Icon>`) so consumers don't need to switch on `typeof children`",
2275
+ "rendering-svg-precision": "Truncate path/points/transform decimals to 1–2 digits — sub-pixel precision adds bytes with no visible difference",
2276
+ "no-document-start-view-transition": "Render a <ViewTransition> component and update inside startTransition / useDeferredValue — React calls startViewTransition for you",
2277
+ "no-flush-sync": "Use startTransition for non-urgent updates — flushSync forces a sync flush that skips View Transitions and concurrent rendering",
1786
2278
  "rendering-usetransition-loading": "Replace with `const [isPending, startTransition] = useTransition()` — avoids a re-render for the loading state",
1787
2279
  "rendering-hydration-no-flicker": "Use `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)` or add `suppressHydrationWarning` to the element",
1788
2280
  "rendering-script-defer-async": "Add `defer` for DOM-dependent scripts or `async` for independent ones (analytics). In Next.js, use `<Script strategy=\"afterInteractive\" />` instead",
2281
+ "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",
1789
2282
  "no-transition-all": "List specific properties: `transition: \"opacity 200ms, transform 200ms\"` — or in Tailwind use `transition-colors`, `transition-opacity`, or `transition-transform`",
1790
2283
  "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",
1791
2284
  "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",
@@ -1793,6 +2286,7 @@ const RULE_HELP_MAP = {
1793
2286
  "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",
1794
2287
  "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)",
1795
2288
  "no-barrel-import": "Import from the direct path: `import { Button } from './components/Button'` instead of `./components`",
2289
+ "no-dynamic-import-path": "Use a string-literal path: `import('./feature/heavy.js')` so the bundler can split this chunk",
1796
2290
  "no-full-lodash-import": "Import the specific function: `import debounce from 'lodash/debounce'` — saves ~70kb",
1797
2291
  "no-moment": "Replace with `import { format } from 'date-fns'` (tree-shakeable) or `import dayjs from 'dayjs'` (2kb)",
1798
2292
  "prefer-dynamic-import": "Use `const Component = dynamic(() => import('library'), { ssr: false })` from next/dynamic or React.lazy()",
@@ -1834,7 +2328,11 @@ const RULE_HELP_MAP = {
1834
2328
  "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",
1835
2329
  "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
1836
2330
  "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
1837
- "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
2331
+ "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.",
2332
+ "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",
2333
+ "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",
2334
+ "server-dedup-props": "Pass the source array once and derive the projection on the client — passing both doubles RSC serialization bytes",
2335
+ "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.",
1838
2336
  "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",
1839
2337
  "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",
1840
2338
  "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",
@@ -1842,6 +2340,19 @@ const RULE_HELP_MAP = {
1842
2340
  "query-mutation-missing-invalidation": "Add `onSuccess: () => queryClient.invalidateQueries({ queryKey: ['...'] })` so cached data stays in sync after the mutation",
1843
2341
  "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",
1844
2342
  "js-flatmap-filter": "Use `.flatMap(item => condition ? [value] : [])` — transforms and filters in a single pass instead of creating an intermediate array",
2343
+ "js-hoist-intl": "Hoist `new Intl.NumberFormat(...)` to module scope or wrap in `useMemo` — Intl constructors allocate dozens of objects per locale lookup",
2344
+ "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`",
2345
+ "js-length-check-first": "Short-circuit with `a.length === b.length && a.every((x, i) => x === b[i])` — unequal-length arrays exit immediately",
2346
+ "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",
2347
+ "js-tosorted-immutable": "Use `array.toSorted()` (ES2023) instead of `[...array].sort()` for immutable sorting without the spread allocation",
2348
+ "js-hoist-regexp": "Hoist `new RegExp(...)` (or large regex literals) to a module-level constant so it isn't recompiled on every loop iteration",
2349
+ "js-min-max-loop": "Use `Math.min(...array)` / `Math.max(...array)` instead of sorting just to read the first or last element",
2350
+ "js-set-map-lookups": "Use a `Set` or `Map` for repeated membership tests / keyed lookups — `Array.includes`/`find` is O(n) per call",
2351
+ "js-batch-dom-css": "Batch DOM/CSS reads and writes — interleaving them inside a loop causes layout thrashing. Read first, then write",
2352
+ "js-index-maps": "Build an index `Map` once outside the loop instead of `array.find(...)` inside it",
2353
+ "js-cache-storage": "Cache repeated `localStorage`/`sessionStorage` reads in a local variable — each access serializes/deserializes",
2354
+ "js-early-exit": "Add an early `return` / `continue` to flatten deep nesting and short-circuit when the predicate is already known",
2355
+ "no-eval": "Use `JSON.parse` for serialized data, `Function(...)` (still careful) for trusted templates, or refactor to avoid dynamic code execution",
1845
2356
  "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
1846
2357
  "rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
1847
2358
  "rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
@@ -1851,6 +2362,19 @@ const RULE_HELP_MAP = {
1851
2362
  "rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
1852
2363
  "rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
1853
2364
  "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation",
2365
+ "rn-prefer-pressable": "Use `<Pressable>` from react-native (or react-native-gesture-handler) instead of legacy Touchable* components",
2366
+ "rn-prefer-expo-image": "Use `<Image>` from `expo-image` instead of `react-native` — same prop API, plus disk + memory caching, placeholders, and crossfades",
2367
+ "rn-no-non-native-navigator": "Use `@react-navigation/native-stack` (or `native-tabs` in v7+) for platform-native transitions and gestures",
2368
+ "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",
2369
+ "rn-no-scrollview-mapped-list": "Use FlashList, LegendList, or FlatList — `<ScrollView>{items.map(...)}</ScrollView>` mounts every row in memory",
2370
+ "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",
2371
+ "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",
2372
+ "rn-prefer-content-inset-adjustment": "Drop the SafeAreaView wrapper and set `contentInsetAdjustmentBehavior=\"automatic\"` on the ScrollView for native safe-area handling",
2373
+ "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",
2374
+ "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",
2375
+ "rn-animation-reaction-as-derived": "Replace useAnimatedReaction with `useDerivedValue(() => ..., [deps])` — shorter, native dependency tracking, no side-effect implication",
2376
+ "rn-bottom-sheet-prefer-native": "Use `<Modal presentationStyle=\"formSheet\">` (RN v7+) for native gesture handling and snap points",
2377
+ "rn-scrollview-dynamic-padding": "Use `contentInset={{ bottom: dynamicValue }}` — the OS applies it as an offset without reflowing the scroll content",
1854
2378
  "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",
1855
2379
  "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",
1856
2380
  "tanstack-start-server-fn-validate-input": "Add `.inputValidator(schema)` before `.handler()` — data crosses a network boundary and must be validated at runtime",
@@ -1905,35 +2429,61 @@ const resolvePluginPath = () => {
1905
2429
  const resolveDiagnosticCategory = (plugin, rule) => {
1906
2430
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
1907
2431
  };
1908
- const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1909
- const batchIncludePaths = (baseArgs, includePaths) => {
1910
- const baseArgsLength = estimateArgsLength(baseArgs);
1911
- const batches = [];
1912
- let currentBatch = [];
1913
- let currentBatchLength = baseArgsLength;
1914
- for (const filePath of includePaths) {
1915
- const entryLength = filePath.length + 1;
1916
- const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > 24e3;
1917
- const exceedsFileCount = currentBatch.length >= 500;
1918
- if (exceedsArgLength || exceedsFileCount) {
1919
- batches.push(currentBatch);
1920
- currentBatch = [];
1921
- currentBatchLength = baseArgsLength;
1922
- }
1923
- currentBatch.push(filePath);
1924
- currentBatchLength += entryLength;
2432
+ const SANITIZED_ENV = (() => {
2433
+ const sanitized = {};
2434
+ for (const [name, value] of Object.entries(process.env)) {
2435
+ if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
2436
+ if (name.startsWith("npm_config_")) continue;
2437
+ sanitized[name] = value;
1925
2438
  }
1926
- if (currentBatch.length > 0) batches.push(currentBatch);
1927
- return batches;
1928
- };
2439
+ return sanitized;
2440
+ })();
2441
+ const OXLINT_SPAWN_TIMEOUT_MS = 5 * 6e4;
1929
2442
  const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
1930
- const child = spawn(nodeBinaryPath, args, { cwd: rootDirectory });
2443
+ const child = spawn(nodeBinaryPath, args, {
2444
+ cwd: rootDirectory,
2445
+ env: SANITIZED_ENV
2446
+ });
2447
+ const timeoutHandle = setTimeout(() => {
2448
+ child.kill("SIGKILL");
2449
+ reject(/* @__PURE__ */ new Error(`oxlint did not return within ${OXLINT_SPAWN_TIMEOUT_MS / 1e3}s — please report`));
2450
+ }, OXLINT_SPAWN_TIMEOUT_MS);
2451
+ timeoutHandle.unref?.();
1931
2452
  const stdoutBuffers = [];
1932
2453
  const stderrBuffers = [];
1933
- child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1934
- child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1935
- child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1936
- child.on("close", (code, signal) => {
2454
+ let stdoutByteCount = 0;
2455
+ let stderrByteCount = 0;
2456
+ let didKillForSize = false;
2457
+ const killIfTooLarge = (incomingBytes, isStdout) => {
2458
+ if (isStdout) stdoutByteCount += incomingBytes;
2459
+ else stderrByteCount += incomingBytes;
2460
+ if (stdoutByteCount + stderrByteCount > 52428800 && !didKillForSize) {
2461
+ didKillForSize = true;
2462
+ child.kill("SIGKILL");
2463
+ return true;
2464
+ }
2465
+ return false;
2466
+ };
2467
+ child.stdout.on("data", (buffer) => {
2468
+ if (didKillForSize) return;
2469
+ stdoutBuffers.push(buffer);
2470
+ killIfTooLarge(buffer.length, true);
2471
+ });
2472
+ child.stderr.on("data", (buffer) => {
2473
+ if (didKillForSize) return;
2474
+ stderrBuffers.push(buffer);
2475
+ killIfTooLarge(buffer.length, false);
2476
+ });
2477
+ child.on("error", (error) => {
2478
+ clearTimeout(timeoutHandle);
2479
+ reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`));
2480
+ });
2481
+ child.on("close", (_code, signal) => {
2482
+ clearTimeout(timeoutHandle);
2483
+ if (didKillForSize) {
2484
+ reject(/* @__PURE__ */ new Error(`oxlint output exceeded ${PROXY_OUTPUT_MAX_BYTES} bytes — scan a smaller subset with --diff or --staged`));
2485
+ return;
2486
+ }
1937
2487
  if (signal) {
1938
2488
  const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1939
2489
  const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
@@ -1952,15 +2502,23 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
1952
2502
  resolve(output);
1953
2503
  });
1954
2504
  });
2505
+ const isOxlintOutput = (value) => {
2506
+ if (typeof value !== "object" || value === null) return false;
2507
+ const candidate = value;
2508
+ return Array.isArray(candidate.diagnostics);
2509
+ };
1955
2510
  const parseOxlintOutput = (stdout) => {
1956
2511
  if (!stdout) return [];
1957
- let output;
2512
+ const jsonStart = stdout.indexOf("{");
2513
+ const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
2514
+ let parsed;
1958
2515
  try {
1959
- output = JSON.parse(stdout);
2516
+ parsed = JSON.parse(sanitizedStdout);
1960
2517
  } catch {
1961
2518
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
1962
2519
  }
1963
- return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2520
+ if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
2521
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
1964
2522
  const { plugin, rule } = parseRuleCode(diagnostic.code);
1965
2523
  const primaryLabel = diagnostic.labels[0];
1966
2524
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -1977,18 +2535,48 @@ const parseOxlintOutput = (stdout) => {
1977
2535
  };
1978
2536
  });
1979
2537
  };
1980
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false) => {
2538
+ const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
2539
+ const resolveTsConfigRelativePath = (rootDirectory) => {
2540
+ for (const filename of TSCONFIG_FILENAMES) if (fs.existsSync(path.join(rootDirectory, filename))) return `./${filename}`;
2541
+ return null;
2542
+ };
2543
+ let didValidateRuleRegistration = false;
2544
+ const validateRuleRegistration = () => {
2545
+ if (didValidateRuleRegistration) return;
2546
+ didValidateRuleRegistration = true;
2547
+ const missingHelp = [];
2548
+ const missingCategory = [];
2549
+ for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2550
+ const ruleName = fullKey.replace(/^react-doctor\//, "");
2551
+ if (!(fullKey in RULE_CATEGORY_MAP)) missingCategory.push(fullKey);
2552
+ if (!(ruleName in RULE_HELP_MAP)) missingHelp.push(fullKey);
2553
+ }
2554
+ if (missingCategory.length > 0 || missingHelp.length > 0) {
2555
+ 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("; ");
2556
+ console.warn(`[react-doctor] rule-registration drift: ${detail}`);
2557
+ }
2558
+ };
2559
+ const runOxlint = async (options) => {
2560
+ const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
2561
+ validateRuleRegistration();
1981
2562
  if (includePaths !== void 0 && includePaths.length === 0) return [];
1982
- const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
2563
+ const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
2564
+ const configPath = path.join(configDirectory, "oxlintrc.json");
1983
2565
  const config = createOxlintConfig({
1984
2566
  pluginPath: resolvePluginPath(),
1985
2567
  framework,
1986
2568
  hasReactCompiler,
2569
+ hasTanStackQuery,
1987
2570
  customRulesOnly
1988
2571
  });
1989
- const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory, includePaths);
2572
+ const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
1990
2573
  try {
1991
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
2574
+ const fileHandle = fs.openSync(configPath, "wx", 384);
2575
+ try {
2576
+ fs.writeFileSync(fileHandle, JSON.stringify(config));
2577
+ } finally {
2578
+ fs.closeSync(fileHandle);
2579
+ }
1992
2580
  const baseArgs = [
1993
2581
  resolveOxlintBinary(),
1994
2582
  "-c",
@@ -1996,7 +2584,16 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1996
2584
  "--format",
1997
2585
  "json"
1998
2586
  ];
1999
- if (hasTypeScript) baseArgs.push("--tsconfig", "./tsconfig.json");
2587
+ if (hasTypeScript) {
2588
+ const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
2589
+ if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
2590
+ }
2591
+ const combinedPatterns = collectIgnorePatterns(rootDirectory);
2592
+ if (combinedPatterns.length > 0) {
2593
+ const combinedIgnorePath = path.join(configDirectory, "combined.ignore");
2594
+ fs.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
2595
+ baseArgs.push("--ignore-path", combinedIgnorePath);
2596
+ }
2000
2597
  const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
2001
2598
  const allDiagnostics = [];
2002
2599
  for (const batch of fileBatches) {
@@ -2006,7 +2603,10 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
2006
2603
  return allDiagnostics;
2007
2604
  } finally {
2008
2605
  restoreDisableDirectives();
2009
- if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
2606
+ fs.rmSync(configDirectory, {
2607
+ recursive: true,
2608
+ force: true
2609
+ });
2010
2610
  }
2011
2611
  };
2012
2612
  //#endregion
@@ -2069,10 +2669,10 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
2069
2669
  };
2070
2670
  const writeDiagnosticsDirectory = (diagnostics) => {
2071
2671
  const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
2072
- mkdirSync(outputDirectory);
2672
+ mkdirSync(outputDirectory, { recursive: true });
2073
2673
  const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
2074
2674
  for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
2075
- writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
2675
+ writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
2076
2676
  return outputDirectory;
2077
2677
  };
2078
2678
  const buildScoreBarSegments = (score) => {
@@ -2191,17 +2791,17 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
2191
2791
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
2192
2792
  }
2193
2793
  };
2194
- const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
2794
+ const resolveOxlintNode = async (isLintEnabled, isQuiet) => {
2195
2795
  if (!isLintEnabled) return null;
2196
2796
  const nodeResolution = resolveNodeForOxlint();
2197
2797
  if (nodeResolution) {
2198
- if (!nodeResolution.isCurrentNode && !isScoreOnly) {
2798
+ if (!nodeResolution.isCurrentNode && !isQuiet) {
2199
2799
  logger.warn(`Node ${process.version} is unsupported by oxlint. Using Node ${nodeResolution.version} from nvm.`);
2200
2800
  logger.break();
2201
2801
  }
2202
2802
  return nodeResolution.binaryPath;
2203
2803
  }
2204
- if (isScoreOnly) return null;
2804
+ if (isQuiet) return null;
2205
2805
  logger.warn(`Node ${process.version} is not compatible with oxlint (requires ${OXLINT_NODE_REQUIREMENT}). Lint checks will be skipped.`);
2206
2806
  if (isNvmInstalled() && process.stdin.isTTY) {
2207
2807
  const { shouldInstallNode } = await prompts({
@@ -2235,9 +2835,11 @@ const mergeScanOptions = (inputOptions, userConfig) => ({
2235
2835
  verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
2236
2836
  scoreOnly: inputOptions.scoreOnly ?? false,
2237
2837
  offline: inputOptions.offline ?? false,
2838
+ silent: inputOptions.silent ?? false,
2238
2839
  includePaths: inputOptions.includePaths ?? [],
2239
2840
  customRulesOnly: userConfig?.customRulesOnly ?? false,
2240
- share: userConfig?.share ?? true
2841
+ share: userConfig?.share ?? true,
2842
+ respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true
2241
2843
  });
2242
2844
  const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount) => {
2243
2845
  const frameworkLabel = formatFrameworkName(projectInfo.framework);
@@ -2256,23 +2858,49 @@ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths
2256
2858
  };
2257
2859
  const scan = async (directory, inputOptions = {}) => {
2258
2860
  const startTime = performance.now();
2259
- const projectInfo = discoverProject(directory);
2260
2861
  const userConfig = inputOptions.configOverride !== void 0 ? inputOptions.configOverride : loadConfig(directory);
2261
2862
  const options = mergeScanOptions(inputOptions, userConfig);
2863
+ const wasLoggerSilent = isLoggerSilent();
2864
+ const wasSpinnerSilent = isSpinnerSilent();
2865
+ if (options.silent) {
2866
+ setLoggerSilent(true);
2867
+ setSpinnerSilent(true);
2868
+ }
2869
+ try {
2870
+ return await runScan(directory, options, userConfig, startTime);
2871
+ } finally {
2872
+ if (options.silent) {
2873
+ setLoggerSilent(wasLoggerSilent);
2874
+ setSpinnerSilent(wasSpinnerSilent);
2875
+ }
2876
+ }
2877
+ };
2878
+ const runScan = async (directory, options, userConfig, startTime) => {
2879
+ const projectInfo = discoverProject(directory);
2262
2880
  const { includePaths } = options;
2263
2881
  const isDiffMode = includePaths.length > 0;
2264
- if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
2882
+ if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(directory));
2265
2883
  const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(directory, userConfig);
2266
2884
  const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
2267
2885
  if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount);
2268
2886
  let didLintFail = false;
2269
2887
  let didDeadCodeFail = false;
2270
- const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly);
2888
+ const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
2271
2889
  if (options.lint && !resolvedNodeBinaryPath) didLintFail = true;
2272
2890
  const lintPromise = resolvedNodeBinaryPath ? (async () => {
2273
2891
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
2274
2892
  try {
2275
- const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, resolvedNodeBinaryPath, options.customRulesOnly);
2893
+ const lintDiagnostics = await runOxlint({
2894
+ rootDirectory: directory,
2895
+ hasTypeScript: projectInfo.hasTypeScript,
2896
+ framework: projectInfo.framework,
2897
+ hasReactCompiler: projectInfo.hasReactCompiler,
2898
+ hasTanStackQuery: projectInfo.hasTanStackQuery,
2899
+ includePaths: lintIncludePaths,
2900
+ nodeBinaryPath: resolvedNodeBinaryPath,
2901
+ customRulesOnly: options.customRulesOnly,
2902
+ respectInlineDisables: options.respectInlineDisables
2903
+ });
2276
2904
  lintSpinner?.succeed("Running lint checks.");
2277
2905
  return lintDiagnostics;
2278
2906
  } catch (error) {
@@ -2306,7 +2934,13 @@ const scan = async (directory, inputOptions = {}) => {
2306
2934
  }
2307
2935
  })() : Promise.resolve([]);
2308
2936
  const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
2309
- const diagnostics = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig);
2937
+ const diagnostics = combineDiagnostics({
2938
+ lintDiagnostics,
2939
+ deadCodeDiagnostics,
2940
+ directory,
2941
+ isDiffMode,
2942
+ userConfig
2943
+ });
2310
2944
  const elapsedMilliseconds = performance.now() - startTime;
2311
2945
  const skippedChecks = [];
2312
2946
  if (didLintFail) skippedChecks.push("lint");
@@ -2314,14 +2948,17 @@ const scan = async (directory, inputOptions = {}) => {
2314
2948
  const hasSkippedChecks = skippedChecks.length > 0;
2315
2949
  const scoreResult = options.offline ? calculateScoreLocally(diagnostics) : await calculateScore(diagnostics);
2316
2950
  const noScoreMessage = OFFLINE_MESSAGE;
2951
+ const buildResult = () => ({
2952
+ diagnostics,
2953
+ score: scoreResult,
2954
+ skippedChecks,
2955
+ project: projectInfo,
2956
+ elapsedMilliseconds
2957
+ });
2317
2958
  if (options.scoreOnly) {
2318
2959
  if (scoreResult) logger.log(`${scoreResult.score}`);
2319
2960
  else logger.dim(noScoreMessage);
2320
- return {
2321
- diagnostics,
2322
- scoreResult,
2323
- skippedChecks
2324
- };
2961
+ return buildResult();
2325
2962
  }
2326
2963
  if (diagnostics.length === 0) {
2327
2964
  if (hasSkippedChecks) {
@@ -2336,11 +2973,7 @@ const scan = async (directory, inputOptions = {}) => {
2336
2973
  printBranding(scoreResult.score);
2337
2974
  printScoreGauge(scoreResult.score, scoreResult.label);
2338
2975
  } else logger.dim(` ${noScoreMessage}`);
2339
- return {
2340
- diagnostics,
2341
- scoreResult,
2342
- skippedChecks
2343
- };
2976
+ return buildResult();
2344
2977
  }
2345
2978
  printDiagnostics(diagnostics, options.verbose);
2346
2979
  const displayedSourceFileCount = isDiffMode ? includePaths.length : lintSourceFileCount;
@@ -2351,74 +2984,225 @@ const scan = async (directory, inputOptions = {}) => {
2351
2984
  logger.break();
2352
2985
  logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`);
2353
2986
  }
2987
+ return buildResult();
2988
+ };
2989
+ //#endregion
2990
+ //#region src/utils/summarize-diagnostics.ts
2991
+ const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
2992
+ let errorCount = 0;
2993
+ let warningCount = 0;
2994
+ const affectedFiles = /* @__PURE__ */ new Set();
2995
+ for (const diagnostic of diagnostics) {
2996
+ if (diagnostic.severity === "error") errorCount++;
2997
+ else warningCount++;
2998
+ affectedFiles.add(diagnostic.filePath);
2999
+ }
2354
3000
  return {
2355
- diagnostics,
2356
- scoreResult,
2357
- skippedChecks
3001
+ errorCount,
3002
+ warningCount,
3003
+ affectedFileCount: affectedFiles.size,
3004
+ totalDiagnosticCount: diagnostics.length,
3005
+ score: worstScore,
3006
+ scoreLabel: worstScoreLabel
2358
3007
  };
2359
3008
  };
2360
3009
  //#endregion
2361
- //#region src/utils/get-diff-files.ts
2362
- const getCurrentBranch = (directory) => {
2363
- try {
2364
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
2365
- cwd: directory,
2366
- stdio: "pipe"
2367
- }).toString().trim();
2368
- return branch === "HEAD" ? null : branch;
2369
- } catch {
2370
- return null;
3010
+ //#region src/utils/build-json-report.ts
3011
+ const toJsonDiff = (diff) => {
3012
+ if (!diff) return null;
3013
+ return {
3014
+ baseBranch: diff.baseBranch,
3015
+ currentBranch: diff.currentBranch,
3016
+ changedFileCount: diff.changedFiles.length,
3017
+ isCurrentChanges: Boolean(diff.isCurrentChanges)
3018
+ };
3019
+ };
3020
+ const findWorstScoredProject = (projects) => {
3021
+ let worst = null;
3022
+ let worstScore = Number.POSITIVE_INFINITY;
3023
+ for (const project of projects) {
3024
+ const score = project.score?.score;
3025
+ if (typeof score !== "number") continue;
3026
+ if (score < worstScore) {
3027
+ worstScore = score;
3028
+ worst = project;
3029
+ }
2371
3030
  }
3031
+ return worst;
2372
3032
  };
2373
- const detectDefaultBranch = (directory) => {
3033
+ const buildJsonReport = (input) => {
3034
+ const projects = input.scans.map(({ directory, result }) => ({
3035
+ directory,
3036
+ project: result.project,
3037
+ diagnostics: result.diagnostics,
3038
+ score: result.score,
3039
+ skippedChecks: result.skippedChecks,
3040
+ elapsedMilliseconds: result.elapsedMilliseconds
3041
+ }));
3042
+ const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
3043
+ const worstScoredProject = findWorstScoredProject(projects);
3044
+ const summary = summarizeDiagnostics(flattenedDiagnostics, worstScoredProject?.score?.score ?? null, worstScoredProject?.score?.label ?? null);
3045
+ return {
3046
+ schemaVersion: 1,
3047
+ version: input.version,
3048
+ ok: true,
3049
+ directory: input.directory,
3050
+ mode: input.mode,
3051
+ diff: toJsonDiff(input.diff),
3052
+ projects,
3053
+ diagnostics: flattenedDiagnostics,
3054
+ summary,
3055
+ elapsedMilliseconds: input.totalElapsedMilliseconds,
3056
+ error: null
3057
+ };
3058
+ };
3059
+ //#endregion
3060
+ //#region src/utils/build-json-report-error.ts
3061
+ const safeStringify = (value) => {
2374
3062
  try {
2375
- return execSync("git symbolic-ref refs/remotes/origin/HEAD", {
2376
- cwd: directory,
2377
- stdio: "pipe"
2378
- }).toString().trim().replace("refs/remotes/origin/", "");
3063
+ return String(value);
2379
3064
  } catch {
2380
- for (const candidate of DEFAULT_BRANCH_CANDIDATES) try {
2381
- execSync(`git rev-parse --verify ${candidate}`, {
2382
- cwd: directory,
2383
- stdio: "pipe"
2384
- });
2385
- return candidate;
2386
- } catch {}
2387
- return null;
3065
+ return "Unrepresentable error";
2388
3066
  }
2389
3067
  };
2390
- const getChangedFilesSinceBranch = (directory, baseBranch) => {
3068
+ const safeGetErrorChain = (error) => {
2391
3069
  try {
2392
- const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${execSync(`git merge-base ${baseBranch} HEAD`, {
2393
- cwd: directory,
2394
- stdio: "pipe"
2395
- }).toString().trim()}`, {
2396
- cwd: directory,
2397
- stdio: "pipe"
2398
- }).toString().trim();
2399
- if (!output) return [];
2400
- return output.split("\n").filter(Boolean);
3070
+ return getErrorChainMessages(error);
2401
3071
  } catch {
2402
- return [];
3072
+ return [safeStringify(error)];
3073
+ }
3074
+ };
3075
+ const buildJsonReportError = (input) => {
3076
+ const chain = safeGetErrorChain(input.error);
3077
+ const errorPayload = input.error instanceof Error ? {
3078
+ message: input.error.message || input.error.name || "Error",
3079
+ name: input.error.name || "Error",
3080
+ chain
3081
+ } : {
3082
+ message: safeStringify(input.error),
3083
+ name: "Error",
3084
+ chain
3085
+ };
3086
+ return {
3087
+ schemaVersion: 1,
3088
+ version: input.version,
3089
+ ok: false,
3090
+ directory: input.directory,
3091
+ mode: input.mode ?? "full",
3092
+ diff: null,
3093
+ projects: [],
3094
+ diagnostics: [],
3095
+ summary: {
3096
+ errorCount: 0,
3097
+ warningCount: 0,
3098
+ affectedFileCount: 0,
3099
+ totalDiagnosticCount: 0,
3100
+ score: null,
3101
+ scoreLabel: null
3102
+ },
3103
+ elapsedMilliseconds: input.elapsedMilliseconds,
3104
+ error: errorPayload
3105
+ };
3106
+ };
3107
+ //#endregion
3108
+ //#region src/utils/get-diff-files.ts
3109
+ const runGit = (cwd, args) => {
3110
+ const result = spawnSync("git", args, {
3111
+ cwd,
3112
+ stdio: [
3113
+ "ignore",
3114
+ "pipe",
3115
+ "pipe"
3116
+ ],
3117
+ encoding: "utf-8"
3118
+ });
3119
+ if (result.error || result.status !== 0) return null;
3120
+ return result.stdout.toString().trim();
3121
+ };
3122
+ const getCurrentBranch = (directory) => {
3123
+ const branch = runGit(directory, [
3124
+ "rev-parse",
3125
+ "--abbrev-ref",
3126
+ "HEAD"
3127
+ ]);
3128
+ if (!branch) return null;
3129
+ return branch === "HEAD" ? null : branch;
3130
+ };
3131
+ const detectDefaultBranch = (directory) => {
3132
+ const reference = runGit(directory, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
3133
+ if (reference) return reference.replace("refs/remotes/origin/", "");
3134
+ const output = runGit(directory, [
3135
+ "for-each-ref",
3136
+ "--format=%(refname:short)",
3137
+ ...DEFAULT_BRANCH_CANDIDATES.map((candidate) => `refs/heads/${candidate}`)
3138
+ ]);
3139
+ if (output) {
3140
+ const firstLine = output.split("\n")[0]?.trim();
3141
+ if (firstLine) return firstLine;
2403
3142
  }
3143
+ return null;
3144
+ };
3145
+ const branchExists = (directory, branch) => {
3146
+ const result = spawnSync("git", [
3147
+ "rev-parse",
3148
+ "--verify",
3149
+ branch
3150
+ ], {
3151
+ cwd: directory,
3152
+ stdio: [
3153
+ "ignore",
3154
+ "pipe",
3155
+ "pipe"
3156
+ ]
3157
+ });
3158
+ return !result.error && result.status === 0;
3159
+ };
3160
+ const runGitNullSeparated = (cwd, args) => {
3161
+ const result = spawnSync("git", args, {
3162
+ cwd,
3163
+ stdio: [
3164
+ "ignore",
3165
+ "pipe",
3166
+ "pipe"
3167
+ ],
3168
+ encoding: "utf-8"
3169
+ });
3170
+ if (result.error || result.status !== 0) return null;
3171
+ return result.stdout.toString().split("\0").filter((filePath) => filePath.length > 0);
3172
+ };
3173
+ const getChangedFilesSinceBranch = (directory, baseBranch) => {
3174
+ const mergeBase = runGit(directory, [
3175
+ "merge-base",
3176
+ baseBranch,
3177
+ "HEAD"
3178
+ ]);
3179
+ if (mergeBase === null) return null;
3180
+ return runGitNullSeparated(directory, [
3181
+ "diff",
3182
+ "-z",
3183
+ "--name-only",
3184
+ "--diff-filter=ACMR",
3185
+ "--relative",
3186
+ mergeBase
3187
+ ]);
2404
3188
  };
2405
3189
  const getUncommittedChangedFiles = (directory) => {
2406
- try {
2407
- const output = execSync("git diff --name-only --diff-filter=ACMR --relative HEAD", {
2408
- cwd: directory,
2409
- stdio: "pipe"
2410
- }).toString().trim();
2411
- if (!output) return [];
2412
- return output.split("\n").filter(Boolean);
2413
- } catch {
2414
- return [];
2415
- }
3190
+ return runGitNullSeparated(directory, [
3191
+ "diff",
3192
+ "-z",
3193
+ "--name-only",
3194
+ "--diff-filter=ACMR",
3195
+ "--relative",
3196
+ "HEAD"
3197
+ ]) ?? [];
2416
3198
  };
2417
3199
  const getDiffInfo = (directory, explicitBaseBranch) => {
3200
+ if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) throw new Error("Diff base branch cannot be empty.");
2418
3201
  const currentBranch = getCurrentBranch(directory);
2419
3202
  if (!currentBranch) return null;
2420
3203
  const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
2421
3204
  if (!baseBranch) return null;
3205
+ if (explicitBaseBranch && !branchExists(directory, explicitBaseBranch)) throw new Error(`Diff base branch "${explicitBaseBranch}" does not exist (run \`git fetch\` to update remote refs).`);
2422
3206
  if (currentBranch === baseBranch) {
2423
3207
  const uncommittedFiles = getUncommittedChangedFiles(directory);
2424
3208
  if (uncommittedFiles.length === 0) return null;
@@ -2429,10 +3213,12 @@ const getDiffInfo = (directory, explicitBaseBranch) => {
2429
3213
  isCurrentChanges: true
2430
3214
  };
2431
3215
  }
3216
+ const changedFiles = getChangedFilesSinceBranch(directory, baseBranch);
3217
+ if (changedFiles === null) return null;
2432
3218
  return {
2433
3219
  currentBranch,
2434
3220
  baseBranch,
2435
- changedFiles: getChangedFilesSinceBranch(directory, baseBranch)
3221
+ changedFiles
2436
3222
  };
2437
3223
  };
2438
3224
  const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
@@ -2442,6 +3228,7 @@ const getStagedFilePaths = (directory) => {
2442
3228
  const result = spawnSync("git", [
2443
3229
  "diff",
2444
3230
  "--cached",
3231
+ "-z",
2445
3232
  "--name-only",
2446
3233
  "--diff-filter=ACMR",
2447
3234
  "--relative"
@@ -2451,9 +3238,9 @@ const getStagedFilePaths = (directory) => {
2451
3238
  maxBuffer: GIT_SHOW_MAX_BUFFER_BYTES
2452
3239
  });
2453
3240
  if (result.error || result.status !== 0) return [];
2454
- const output = result.stdout.toString().trim();
3241
+ const output = result.stdout.toString();
2455
3242
  if (!output) return [];
2456
- return output.split("\n").filter(Boolean);
3243
+ return output.split("\0").filter((filePath) => filePath.length > 0);
2457
3244
  };
2458
3245
  const readStagedContent = (directory, relativePath) => {
2459
3246
  const result = spawnSync("git", ["show", `:${relativePath}`], {
@@ -2465,6 +3252,18 @@ const readStagedContent = (directory, relativePath) => {
2465
3252
  return result.stdout.toString();
2466
3253
  };
2467
3254
  const getStagedSourceFiles = (directory) => getStagedFilePaths(directory).filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
3255
+ const PROJECT_CONFIG_FILENAMES = [
3256
+ "tsconfig.json",
3257
+ "tsconfig.base.json",
3258
+ "package.json",
3259
+ "react-doctor.config.json",
3260
+ "knip.json",
3261
+ "knip.jsonc",
3262
+ ".knip.json",
3263
+ ".knip.jsonc",
3264
+ "oxlint.json",
3265
+ ".oxlintrc.json"
3266
+ ];
2468
3267
  const materializeStagedFiles = (directory, stagedFiles, tempDirectory) => {
2469
3268
  const materializedFiles = [];
2470
3269
  for (const relativePath of stagedFiles) {
@@ -2475,11 +3274,7 @@ const materializeStagedFiles = (directory, stagedFiles, tempDirectory) => {
2475
3274
  fs.writeFileSync(targetPath, content);
2476
3275
  materializedFiles.push(relativePath);
2477
3276
  }
2478
- for (const configFilename of [
2479
- "tsconfig.json",
2480
- "package.json",
2481
- "react-doctor.config.json"
2482
- ]) {
3277
+ for (const configFilename of PROJECT_CONFIG_FILENAMES) {
2483
3278
  const sourcePath = path.join(directory, configFilename);
2484
3279
  const targetPath = path.join(tempDirectory, configFilename);
2485
3280
  if (fs.existsSync(sourcePath) && !fs.existsSync(targetPath)) fs.cpSync(sourcePath, targetPath);
@@ -2503,14 +3298,18 @@ const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
2503
3298
  const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
2504
3299
  logger.break();
2505
3300
  logger.error("Something went wrong. Please check the error below for more details.");
2506
- logger.error("If the problem persists, please open an issue on GitHub.");
3301
+ logger.error(`If the problem persists, please open an issue at ${CANONICAL_GITHUB_URL}/issues.`);
2507
3302
  logger.error("");
2508
- if (error instanceof Error) logger.error(error.message);
3303
+ logger.error(formatErrorChain(error));
2509
3304
  logger.break();
2510
3305
  if (options.shouldExit) process.exit(1);
2511
3306
  process.exitCode = 1;
2512
3307
  };
2513
3308
  //#endregion
3309
+ //#region src/utils/annotation-encoding.ts
3310
+ const encodeAnnotationProperty = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
3311
+ const encodeAnnotationMessage = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
3312
+ //#endregion
2514
3313
  //#region src/utils/select-projects.ts
2515
3314
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
2516
3315
  let packages = listWorkspacePackages(rootDirectory);
@@ -2559,7 +3358,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
2559
3358
  };
2560
3359
  //#endregion
2561
3360
  //#region src/cli.ts
2562
- const VERSION = "0.0.42";
3361
+ const VERSION = "0.0.44";
2563
3362
  const VALID_FAIL_ON_LEVELS = new Set([
2564
3363
  "error",
2565
3364
  "warning",
@@ -2572,48 +3371,104 @@ const shouldFailForDiagnostics = (diagnostics, failOnLevel) => {
2572
3371
  return diagnostics.some((diagnostic) => diagnostic.severity === "error");
2573
3372
  };
2574
3373
  const resolveFailOnLevel = (programInstance, flags, userConfig) => {
2575
- const resolvedFailOn = programInstance.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
2576
- return isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none";
3374
+ const sourceValue = programInstance.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
3375
+ if (isValidFailOnLevel(sourceValue)) return sourceValue;
3376
+ logger.warn(`Invalid failOn level "${sourceValue}". Expected one of: error, warning, none. Falling back to "none".`);
3377
+ return "none";
2577
3378
  };
2578
- const printAnnotations = (diagnostics) => {
3379
+ const printAnnotations = (diagnostics, routeToStderr) => {
3380
+ const writeLine = routeToStderr ? (line) => process.stderr.write(`${line}\n`) : (line) => process.stdout.write(`${line}\n`);
2579
3381
  for (const diagnostic of diagnostics) {
2580
3382
  const level = diagnostic.severity === "error" ? "error" : "warning";
2581
3383
  const title = `${diagnostic.plugin}/${diagnostic.rule}`;
2582
- const fileLocation = diagnostic.line > 0 ? `file=${diagnostic.filePath},line=${diagnostic.line}` : `file=${diagnostic.filePath}`;
2583
- console.log(`::${level} ${fileLocation},title=${title}::${diagnostic.message}`);
3384
+ writeLine(`::${level} ${`file=${encodeAnnotationProperty(diagnostic.filePath)}`}${diagnostic.line > 0 ? `,line=${diagnostic.line}` : ""}${`,title=${encodeAnnotationProperty(title)}`}::${encodeAnnotationMessage(diagnostic.message)}`);
2584
3385
  }
2585
3386
  };
3387
+ let isJsonModeActive = false;
3388
+ let resolvedDirectoryForCancel = null;
3389
+ let cancelStartTime = 0;
3390
+ let currentReportMode = "full";
2586
3391
  const exitGracefully = () => {
3392
+ if (isJsonModeActive) {
3393
+ writeJsonReport(buildJsonReportError({
3394
+ version: VERSION,
3395
+ directory: resolvedDirectoryForCancel ?? process.cwd(),
3396
+ error: /* @__PURE__ */ new Error("Scan cancelled by user (SIGINT/SIGTERM)"),
3397
+ elapsedMilliseconds: performance.now() - cancelStartTime,
3398
+ mode: currentReportMode
3399
+ }));
3400
+ process.exit(130);
3401
+ }
2587
3402
  logger.break();
2588
3403
  logger.log("Cancelled.");
2589
3404
  logger.break();
2590
- process.exit(0);
3405
+ process.exit(130);
2591
3406
  };
2592
3407
  process.on("SIGINT", exitGracefully);
2593
3408
  process.on("SIGTERM", exitGracefully);
2594
- const AUTOMATED_ENVIRONMENT_VARIABLES = [
3409
+ const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
2595
3410
  "CI",
3411
+ "GITHUB_ACTIONS",
3412
+ "GITLAB_CI",
3413
+ "BUILDKITE",
3414
+ "JENKINS_URL",
3415
+ "TF_BUILD",
3416
+ "CODEBUILD_BUILD_ID",
3417
+ "TEAMCITY_VERSION",
3418
+ "BITBUCKET_BUILD_NUMBER",
3419
+ "CIRCLECI",
3420
+ "TRAVIS",
3421
+ "DRONE",
2596
3422
  "CLAUDECODE",
3423
+ "CLAUDE_CODE",
2597
3424
  "CURSOR_AGENT",
2598
3425
  "CODEX_CI",
2599
3426
  "OPENCODE",
2600
3427
  "AMP_HOME"
2601
3428
  ];
2602
- const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
3429
+ const CI_ENVIRONMENT_VARIABLES = [
3430
+ "GITHUB_ACTIONS",
3431
+ "GITLAB_CI",
3432
+ "CIRCLECI"
3433
+ ];
3434
+ const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
3435
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
2603
3436
  const resolveCliScanOptions = (flags, userConfig, programInstance) => {
2604
3437
  const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
2605
3438
  return {
2606
3439
  lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? true,
2607
3440
  deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? true,
2608
- verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
3441
+ verbose: isCliOverride("verbose") ? flags.verbose : userConfig?.verbose ?? false,
2609
3442
  scoreOnly: flags.score,
2610
- offline: flags.offline
3443
+ offline: flags.offline || isCiEnvironment(),
3444
+ silent: flags.json,
3445
+ respectInlineDisables: isCliOverride("respectInlineDisables") ? flags.respectInlineDisables : userConfig?.respectInlineDisables ?? true
2611
3446
  };
2612
3447
  };
2613
- const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
3448
+ let isCompactJsonOutput = false;
3449
+ const writeJsonReport = (report) => {
3450
+ const serialized = isCompactJsonOutput ? JSON.stringify(report) : JSON.stringify(report, null, 2);
3451
+ process.stdout.write(`${serialized}\n`);
3452
+ };
3453
+ const coerceDiffValue = (value) => {
3454
+ if (value === void 0) return void 0;
3455
+ if (typeof value === "boolean") return value;
3456
+ if (typeof value === "string") {
3457
+ if (value.length === 0) return void 0;
3458
+ if (value === "false") return false;
3459
+ if (value === "true") return true;
3460
+ return value;
3461
+ }
3462
+ process.stderr.write(`[react-doctor] invalid diff value (expected boolean or string): ${typeof value}. Falling back to no diff.\n`);
3463
+ };
3464
+ const resolveEffectiveDiff = (flags, userConfig, programInstance) => {
3465
+ if (flags.full) return false;
3466
+ return coerceDiffValue(programInstance.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff);
3467
+ };
3468
+ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQuiet) => {
2614
3469
  if (effectiveDiff !== void 0 && effectiveDiff !== false) {
2615
3470
  if (diffInfo) return true;
2616
- if (!isScoreOnly) {
3471
+ if (!isQuiet) {
2617
3472
  logger.warn("No feature branch or uncommitted changes detected. Running full scan.");
2618
3473
  logger.break();
2619
3474
  }
@@ -2623,81 +3478,139 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
2623
3478
  const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles);
2624
3479
  if (changedSourceFiles.length === 0) return false;
2625
3480
  if (shouldSkipPrompts) return false;
2626
- if (isScoreOnly) return false;
3481
+ if (isQuiet) return false;
2627
3482
  const { shouldScanChangedOnly } = await prompts({
2628
3483
  type: "confirm",
2629
3484
  name: "shouldScanChangedOnly",
2630
- message: diffInfo.isCurrentChanges ? `Found ${changedSourceFiles.length} uncommitted changed files. Only scan current changes?` : `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} changed files vs ${diffInfo.baseBranch}). Only scan this branch?`,
3485
+ message: diffInfo.isCurrentChanges ? `Found ${changedSourceFiles.length} uncommitted changed files. Only scan those?` : `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} files changed vs ${diffInfo.baseBranch}). Only scan changed files?`,
2631
3486
  initial: true
2632
3487
  });
2633
3488
  return Boolean(shouldScanChangedOnly);
2634
3489
  };
2635
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("-n, --no", "skip prompts, always run a full scan (decline diff-only)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--annotations", "output diagnostics as GitHub Actions annotations").action(async (directory, flags) => {
3490
+ const validateModeFlags = (flags) => {
3491
+ const coercedDiff = coerceDiffValue(flags.diff);
3492
+ const exclusiveModes = [flags.staged ? "--staged" : null, coercedDiff !== void 0 && coercedDiff !== false ? "--diff" : null].filter((modeName) => modeName !== null);
3493
+ if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
3494
+ if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
3495
+ if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
3496
+ if (flags.annotations && (flags.json || flags.score)) throw new Error("--annotations cannot be combined with --json or --score.");
3497
+ };
3498
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "error").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").action(async (directory, flags) => {
2636
3499
  const isScoreOnly = flags.score;
3500
+ const isJsonMode = flags.json;
3501
+ const isQuiet = isScoreOnly || isJsonMode;
3502
+ const resolvedDirectory = path.resolve(directory);
3503
+ const jsonStartTime = performance.now();
3504
+ isJsonModeActive = isJsonMode;
3505
+ isCompactJsonOutput = Boolean(flags.jsonCompact);
3506
+ resolvedDirectoryForCancel = resolvedDirectory;
3507
+ cancelStartTime = jsonStartTime;
3508
+ if (isJsonMode) setLoggerSilent(true);
2637
3509
  try {
2638
- const resolvedDirectory = path.resolve(directory);
3510
+ validateModeFlags(flags);
2639
3511
  const userConfig = loadConfig(resolvedDirectory);
2640
- if (!isScoreOnly) {
3512
+ if (!isQuiet) {
2641
3513
  logger.log(`react-doctor v${VERSION}`);
2642
3514
  logger.break();
2643
3515
  }
2644
3516
  const scanOptions = resolveCliScanOptions(flags, userConfig, program);
2645
- const shouldSkipPrompts = flags.yes || flags.no || isAutomatedEnvironment() || !process.stdin.isTTY;
3517
+ const shouldSkipPrompts = flags.yes || flags.full || isJsonMode || isNonInteractiveEnvironment() || !process.stdin.isTTY;
3518
+ if (!flags.offline && isCiEnvironment() && !isQuiet) {
3519
+ logger.dim("CI detected — scoring locally.");
3520
+ logger.break();
3521
+ }
2646
3522
  if (flags.staged) {
3523
+ currentReportMode = "staged";
2647
3524
  const stagedFiles = getStagedSourceFiles(resolvedDirectory);
2648
3525
  if (stagedFiles.length === 0) {
2649
- if (!isScoreOnly) logger.dim("No staged source files found.");
3526
+ if (isJsonMode) writeJsonReport(buildJsonReport({
3527
+ version: VERSION,
3528
+ directory: resolvedDirectory,
3529
+ mode: "staged",
3530
+ diff: null,
3531
+ scans: [],
3532
+ totalElapsedMilliseconds: performance.now() - jsonStartTime
3533
+ }));
3534
+ else if (!isScoreOnly) logger.dim("No staged source files found.");
2650
3535
  return;
2651
3536
  }
2652
- if (!isScoreOnly) {
3537
+ if (!isQuiet) {
2653
3538
  logger.log(`Scanning ${highlighter.info(`${stagedFiles.length}`)} staged files...`);
2654
3539
  logger.break();
2655
3540
  }
2656
- const snapshot = materializeStagedFiles(resolvedDirectory, stagedFiles, mkdtempSync(path.join(tmpdir(), "react-doctor-staged-")));
3541
+ let tempDirectory = null;
3542
+ let cleanupSnapshot = null;
2657
3543
  try {
2658
- const remappedDiagnostics = (await scan(snapshot.tempDirectory, {
3544
+ tempDirectory = mkdtempSync(path.join(tmpdir(), "react-doctor-staged-"));
3545
+ const snapshot = materializeStagedFiles(resolvedDirectory, stagedFiles, tempDirectory);
3546
+ cleanupSnapshot = snapshot.cleanup;
3547
+ const scanResult = await scan(snapshot.tempDirectory, {
2659
3548
  ...scanOptions,
2660
3549
  includePaths: snapshot.stagedFiles,
2661
3550
  configOverride: userConfig
2662
- })).diagnostics.map((diagnostic) => ({
3551
+ });
3552
+ const remappedDiagnostics = scanResult.diagnostics.map((diagnostic) => ({
2663
3553
  ...diagnostic,
2664
- filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replace(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
3554
+ filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
3555
+ }));
3556
+ if (isJsonMode) writeJsonReport(buildJsonReport({
3557
+ version: VERSION,
3558
+ directory: resolvedDirectory,
3559
+ mode: "staged",
3560
+ diff: null,
3561
+ scans: [{
3562
+ directory: resolvedDirectory,
3563
+ result: {
3564
+ ...scanResult,
3565
+ diagnostics: remappedDiagnostics,
3566
+ project: {
3567
+ ...scanResult.project,
3568
+ rootDirectory: resolvedDirectory
3569
+ }
3570
+ }
3571
+ }],
3572
+ totalElapsedMilliseconds: performance.now() - jsonStartTime
2665
3573
  }));
2666
- if (flags.annotations) printAnnotations(remappedDiagnostics);
3574
+ if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
2667
3575
  if (shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
2668
3576
  } finally {
2669
- snapshot.cleanup();
3577
+ cleanupSnapshot?.();
2670
3578
  }
2671
3579
  return;
2672
3580
  }
2673
3581
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
2674
- const effectiveDiff = program.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff;
3582
+ const effectiveDiff = resolveEffectiveDiff(flags, userConfig, program);
2675
3583
  const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
2676
- const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
2677
- const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
2678
- if (isDiffMode && diffInfo && !isScoreOnly) {
3584
+ const diffInfo = effectiveDiff !== void 0 && effectiveDiff !== false || !shouldSkipPrompts && !isQuiet ? getDiffInfo(resolvedDirectory, explicitBaseBranch) : null;
3585
+ const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isQuiet);
3586
+ currentReportMode = isDiffMode ? "diff" : "full";
3587
+ if (isDiffMode && diffInfo && !isQuiet) {
2679
3588
  if (diffInfo.isCurrentChanges) logger.log("Scanning uncommitted changes");
2680
3589
  else logger.log(`Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`);
2681
3590
  logger.break();
2682
3591
  }
2683
3592
  const allDiagnostics = [];
3593
+ const completedScans = [];
2684
3594
  for (const projectDirectory of projectDirectories) {
2685
3595
  let includePaths;
2686
3596
  if (isDiffMode) {
2687
- const projectDiffInfo = getDiffInfo(projectDirectory, explicitBaseBranch);
3597
+ const projectDiffInfo = projectDirectory === resolvedDirectory ? diffInfo : getDiffInfo(projectDirectory, explicitBaseBranch);
2688
3598
  if (projectDiffInfo) {
2689
3599
  const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles);
2690
3600
  if (changedSourceFiles.length === 0) {
2691
- if (!isScoreOnly) {
3601
+ if (!isQuiet) {
2692
3602
  logger.dim(`No changed source files in ${projectDirectory}, skipping.`);
2693
3603
  logger.break();
2694
3604
  }
2695
3605
  continue;
2696
3606
  }
2697
3607
  includePaths = changedSourceFiles;
3608
+ } else if (!isQuiet) {
3609
+ logger.dim(`Cannot detect diff for ${projectDirectory} (not a git repository?) — scanning all files.`);
3610
+ logger.break();
2698
3611
  }
2699
3612
  }
2700
- if (!isScoreOnly) {
3613
+ if (!isQuiet) {
2701
3614
  logger.dim(`Scanning ${projectDirectory}...`);
2702
3615
  logger.break();
2703
3616
  }
@@ -2706,28 +3619,80 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2706
3619
  includePaths
2707
3620
  });
2708
3621
  allDiagnostics.push(...scanResult.diagnostics);
2709
- if (!isScoreOnly) logger.break();
3622
+ completedScans.push({
3623
+ directory: projectDirectory,
3624
+ result: scanResult
3625
+ });
3626
+ if (!isQuiet) logger.break();
2710
3627
  }
2711
- if (flags.annotations) printAnnotations(allDiagnostics);
3628
+ const reportMode = isDiffMode ? "diff" : "full";
3629
+ if (isJsonMode) writeJsonReport(buildJsonReport({
3630
+ version: VERSION,
3631
+ directory: resolvedDirectory,
3632
+ mode: reportMode,
3633
+ diff: isDiffMode ? diffInfo : null,
3634
+ scans: completedScans,
3635
+ totalElapsedMilliseconds: performance.now() - jsonStartTime
3636
+ }));
3637
+ if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
2712
3638
  if (shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
2713
3639
  } catch (error) {
2714
- handleError(error);
3640
+ try {
3641
+ if (isJsonMode) {
3642
+ writeJsonReport(buildJsonReportError({
3643
+ version: VERSION,
3644
+ directory: resolvedDirectory,
3645
+ error,
3646
+ elapsedMilliseconds: performance.now() - jsonStartTime,
3647
+ mode: currentReportMode
3648
+ }));
3649
+ process.exitCode = 1;
3650
+ return;
3651
+ }
3652
+ handleError(error);
3653
+ } catch {
3654
+ if (isJsonMode) process.stdout.write("{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n");
3655
+ process.exitCode = 1;
3656
+ }
2715
3657
  }
2716
3658
  }).addHelpText("after", `
3659
+ ${highlighter.dim("Configuration:")}
3660
+ Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
3661
+ CLI flags always override config values. See the README for the full schema.
3662
+
2717
3663
  ${highlighter.dim("Learn more:")}
2718
- ${highlighter.info("https://github.com/millionco/react-doctor")}
3664
+ ${highlighter.info(CANONICAL_GITHUB_URL)}
2719
3665
  `);
2720
- program.command("install").description("Install the react-doctor skill into your coding agents").option("-y, --yes", "skip prompts, install for all detected agents").action(async (options) => {
3666
+ program.command("install").description("Install the react-doctor skill into your coding agents").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").action(async (options) => {
2721
3667
  try {
2722
- await runInstallSkill({ yes: options.yes });
3668
+ await runInstallSkill({
3669
+ yes: options.yes,
3670
+ dryRun: options.dryRun
3671
+ });
2723
3672
  } catch (error) {
2724
3673
  handleError(error);
2725
3674
  }
2726
3675
  });
2727
- const main$1 = async () => {
2728
- await program.parseAsync();
2729
- };
2730
- main$1();
3676
+ process.stdout.on("error", (error) => {
3677
+ if (error.code === "EPIPE") process.exit(0);
3678
+ });
3679
+ program.parseAsync().catch((error) => {
3680
+ if (isJsonModeActive) {
3681
+ try {
3682
+ writeJsonReport(buildJsonReportError({
3683
+ version: VERSION,
3684
+ directory: resolvedDirectoryForCancel ?? process.cwd(),
3685
+ error,
3686
+ elapsedMilliseconds: performance.now() - cancelStartTime,
3687
+ mode: currentReportMode
3688
+ }));
3689
+ } catch {
3690
+ process.stdout.write("{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n");
3691
+ }
3692
+ process.exit(1);
3693
+ }
3694
+ handleError(error);
3695
+ });
2731
3696
  //#endregion
2732
3697
  export {};
2733
3698