react-doctor 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@ import { createRequire } from "node:module";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
- import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, BUILTIN_A11Y_RULES, BUILTIN_REACT_RULES, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, YOU_MIGHT_NOT_NEED_EFFECT_RULES } from "oxlint-plugin-react-doctor";
5
+ import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, REACT_DOCTOR_RULES } from "oxlint-plugin-react-doctor";
6
+ import { gzipSync } from "node:zlib";
6
7
  import os from "node:os";
7
8
  import * as ts from "typescript";
8
9
  //#region \0rolldown/runtime.js
@@ -680,19 +681,6 @@ const findReactInWorkspaces = (rootDirectory, packageJson) => {
680
681
  }
681
682
  return result;
682
683
  };
683
- const REACT_DEPENDENCY_NAMES = new Set([
684
- "react",
685
- "react-native",
686
- "next"
687
- ]);
688
- const hasReactDependency = (packageJson) => {
689
- const allDependencies = {
690
- ...packageJson.peerDependencies,
691
- ...packageJson.dependencies,
692
- ...packageJson.devDependencies
693
- };
694
- return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
695
- };
696
684
  const findDependencyInfoFromMonorepoRoot = (directory) => {
697
685
  const monorepoRoot = findMonorepoRoot(directory);
698
686
  if (!monorepoRoot) return EMPTY_DEPENDENCY_INFO;
@@ -720,13 +708,13 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
720
708
  "peerDependencies"
721
709
  ]
722
710
  }) : null;
723
- const shouldUseReactFallback = leafPackageJson ? hasReactDependency(leafPackageJson) : true;
711
+ const shouldUseReactFallback = !leafReactDeclaration?.hasDeclaration;
724
712
  const shouldUseTailwindFallback = leafTailwindDeclaration?.hasDeclaration ?? true;
725
713
  const reactCatalogVersion = shouldUseReactFallback ? resolveCatalogVersion(rootPackageJson, "react", monorepoRoot, leafReactDeclaration?.catalogReference) : null;
726
714
  const tailwindCatalogVersion = shouldUseTailwindFallback ? resolveCatalogVersion(rootPackageJson, "tailwindcss", monorepoRoot, leafTailwindDeclaration?.catalogReference) : null;
727
715
  const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
728
716
  return {
729
- reactVersion: shouldUseReactFallback ? reactCatalogVersion ?? rootInfo.reactVersion ?? workspaceInfo.reactVersion : null,
717
+ reactVersion: shouldUseReactFallback ? reactCatalogVersion ?? rootInfo.reactVersion ?? workspaceInfo.reactVersion : rootInfo.reactVersion ?? workspaceInfo.reactVersion,
730
718
  tailwindVersion: shouldUseTailwindFallback ? tailwindCatalogVersion ?? rootInfo.tailwindVersion ?? workspaceInfo.tailwindVersion : null,
731
719
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
732
720
  };
@@ -793,6 +781,19 @@ const resolveEffectiveReactMajor = (reactVersion, packageJson) => {
793
781
  if (peerFloor === null) return hasUpperBoundOnlyPeerRange(peerReactRange) ? null : installedReactMajor;
794
782
  return installedReactMajor !== null ? Math.min(installedReactMajor, peerFloor) : peerFloor;
795
783
  };
784
+ const REACT_DEPENDENCY_NAMES = new Set([
785
+ "react",
786
+ "react-native",
787
+ "next"
788
+ ]);
789
+ const hasReactDependency = (packageJson) => {
790
+ const allDependencies = {
791
+ ...packageJson.peerDependencies,
792
+ ...packageJson.dependencies,
793
+ ...packageJson.devDependencies
794
+ };
795
+ return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
796
+ };
796
797
  const listWorkspacePackages = (rootDirectory) => {
797
798
  const packageJsonPath = path.join(rootDirectory, "package.json");
798
799
  if (!isFile(packageJsonPath)) return [];
@@ -3012,6 +3013,136 @@ const getDiagnosticRuleIdentity = (diagnostic) => ({
3012
3013
  category: diagnostic.category,
3013
3014
  tags: diagnostic.plugin === "react-doctor" ? reactDoctorPlugin.rules[diagnostic.rule]?.tags ?? [] : []
3014
3015
  });
3016
+ const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
3017
+ "effect/no-adjust-state-on-prop-change": "react-doctor/no-adjust-state-on-prop-change",
3018
+ "effect/no-chain-state-updates": "react-doctor/no-chain-state-updates",
3019
+ "effect/no-derived-state": "react-doctor/no-derived-state",
3020
+ "effect/no-event-handler": "react-doctor/no-event-handler",
3021
+ "effect/no-initialize-state": "react-doctor/no-initialize-state",
3022
+ "effect/no-pass-data-to-parent": "react-doctor/no-pass-data-to-parent",
3023
+ "effect/no-pass-live-state-to-parent": "react-doctor/no-pass-live-state-to-parent",
3024
+ "effect/no-reset-all-state-on-prop-change": "react-doctor/no-reset-all-state-on-prop-change",
3025
+ "jsx-a11y/alt-text": "react-doctor/alt-text",
3026
+ "jsx-a11y/anchor-ambiguous-text": "react-doctor/anchor-ambiguous-text",
3027
+ "jsx-a11y/anchor-has-content": "react-doctor/anchor-has-content",
3028
+ "jsx-a11y/anchor-is-valid": "react-doctor/anchor-is-valid",
3029
+ "jsx-a11y/aria-activedescendant-has-tabindex": "react-doctor/aria-activedescendant-has-tabindex",
3030
+ "jsx-a11y/aria-props": "react-doctor/aria-props",
3031
+ "jsx-a11y/aria-proptypes": "react-doctor/aria-proptypes",
3032
+ "jsx-a11y/aria-role": "react-doctor/aria-role",
3033
+ "jsx-a11y/aria-unsupported-elements": "react-doctor/aria-unsupported-elements",
3034
+ "jsx-a11y/autocomplete-valid": "react-doctor/autocomplete-valid",
3035
+ "jsx-a11y/click-events-have-key-events": "react-doctor/click-events-have-key-events",
3036
+ "jsx-a11y/control-has-associated-label": "react-doctor/control-has-associated-label",
3037
+ "jsx-a11y/heading-has-content": "react-doctor/heading-has-content",
3038
+ "jsx-a11y/html-has-lang": "react-doctor/html-has-lang",
3039
+ "jsx-a11y/iframe-has-title": "react-doctor/iframe-has-title",
3040
+ "jsx-a11y/img-redundant-alt": "react-doctor/img-redundant-alt",
3041
+ "jsx-a11y/interactive-supports-focus": "react-doctor/interactive-supports-focus",
3042
+ "jsx-a11y/label-has-associated-control": "react-doctor/label-has-associated-control",
3043
+ "jsx-a11y/lang": "react-doctor/lang",
3044
+ "jsx-a11y/media-has-caption": "react-doctor/media-has-caption",
3045
+ "jsx-a11y/mouse-events-have-key-events": "react-doctor/mouse-events-have-key-events",
3046
+ "jsx-a11y/no-access-key": "react-doctor/no-access-key",
3047
+ "jsx-a11y/no-aria-hidden-on-focusable": "react-doctor/no-aria-hidden-on-focusable",
3048
+ "jsx-a11y/no-autofocus": "react-doctor/no-autofocus",
3049
+ "jsx-a11y/no-distracting-elements": "react-doctor/no-distracting-elements",
3050
+ "jsx-a11y/no-interactive-element-to-noninteractive-role": "react-doctor/no-interactive-element-to-noninteractive-role",
3051
+ "jsx-a11y/no-noninteractive-element-interactions": "react-doctor/no-noninteractive-element-interactions",
3052
+ "jsx-a11y/no-noninteractive-element-to-interactive-role": "react-doctor/no-noninteractive-element-to-interactive-role",
3053
+ "jsx-a11y/no-noninteractive-tabindex": "react-doctor/no-noninteractive-tabindex",
3054
+ "jsx-a11y/no-redundant-roles": "react-doctor/no-redundant-roles",
3055
+ "jsx-a11y/no-static-element-interactions": "react-doctor/no-static-element-interactions",
3056
+ "jsx-a11y/prefer-tag-over-role": "react-doctor/prefer-tag-over-role",
3057
+ "jsx-a11y/role-has-required-aria-props": "react-doctor/role-has-required-aria-props",
3058
+ "jsx-a11y/role-supports-aria-props": "react-doctor/role-supports-aria-props",
3059
+ "jsx-a11y/scope": "react-doctor/scope",
3060
+ "jsx-a11y/tabindex-no-positive": "react-doctor/tabindex-no-positive",
3061
+ "react-hooks/exhaustive-deps": "react-doctor/exhaustive-deps",
3062
+ "react-hooks/rules-of-hooks": "react-doctor/rules-of-hooks",
3063
+ "react-perf/jsx-no-jsx-as-prop": "react-doctor/jsx-no-jsx-as-prop",
3064
+ "react-perf/jsx-no-new-array-as-prop": "react-doctor/jsx-no-new-array-as-prop",
3065
+ "react-perf/jsx-no-new-function-as-prop": "react-doctor/jsx-no-new-function-as-prop",
3066
+ "react-perf/jsx-no-new-object-as-prop": "react-doctor/jsx-no-new-object-as-prop",
3067
+ "react-refresh/only-export-components": "react-doctor/only-export-components",
3068
+ "react/button-has-type": "react-doctor/button-has-type",
3069
+ "react/checked-requires-onchange-or-readonly": "react-doctor/checked-requires-onchange-or-readonly",
3070
+ "react/display-name": "react-doctor/display-name",
3071
+ "react/exhaustive-deps": "react-doctor/exhaustive-deps",
3072
+ "react/forbid-component-props": "react-doctor/forbid-component-props",
3073
+ "react/forbid-dom-props": "react-doctor/forbid-dom-props",
3074
+ "react/forbid-elements": "react-doctor/forbid-elements",
3075
+ "react/forward-ref-uses-ref": "react-doctor/forward-ref-uses-ref",
3076
+ "react/hook-use-state": "react-doctor/hook-use-state",
3077
+ "react/iframe-missing-sandbox": "react-doctor/iframe-missing-sandbox",
3078
+ "react/jsx-boolean-value": "react-doctor/jsx-boolean-value",
3079
+ "react/jsx-curly-brace-presence": "react-doctor/jsx-curly-brace-presence",
3080
+ "react/jsx-filename-extension": "react-doctor/jsx-filename-extension",
3081
+ "react/jsx-fragments": "react-doctor/jsx-fragments",
3082
+ "react/jsx-handler-names": "react-doctor/jsx-handler-names",
3083
+ "react/jsx-key": "react-doctor/jsx-key",
3084
+ "react/jsx-max-depth": "react-doctor/jsx-max-depth",
3085
+ "react/jsx-no-comment-textnodes": "react-doctor/jsx-no-comment-textnodes",
3086
+ "react/jsx-no-constructed-context-values": "react-doctor/jsx-no-constructed-context-values",
3087
+ "react/jsx-no-duplicate-props": "react-doctor/jsx-no-duplicate-props",
3088
+ "react/jsx-no-jsx-as-prop": "react-doctor/jsx-no-jsx-as-prop",
3089
+ "react/jsx-no-new-array-as-prop": "react-doctor/jsx-no-new-array-as-prop",
3090
+ "react/jsx-no-new-function-as-prop": "react-doctor/jsx-no-new-function-as-prop",
3091
+ "react/jsx-no-new-object-as-prop": "react-doctor/jsx-no-new-object-as-prop",
3092
+ "react/jsx-no-script-url": "react-doctor/jsx-no-script-url",
3093
+ "react/jsx-no-target-blank": "react-doctor/jsx-no-target-blank",
3094
+ "react/jsx-no-undef": "react-doctor/jsx-no-undef",
3095
+ "react/jsx-no-useless-fragment": "react-doctor/jsx-no-useless-fragment",
3096
+ "react/jsx-pascal-case": "react-doctor/jsx-pascal-case",
3097
+ "react/jsx-props-no-spread-multi": "react-doctor/jsx-props-no-spread-multi",
3098
+ "react/jsx-props-no-spreading": "react-doctor/jsx-props-no-spreading",
3099
+ "react/no-array-index-key": "react-doctor/no-array-index-key",
3100
+ "react/no-children-prop": "react-doctor/no-children-prop",
3101
+ "react/no-clone-element": "react-doctor/no-clone-element",
3102
+ "react/no-danger": "react-doctor/no-danger",
3103
+ "react/no-danger-with-children": "react-doctor/no-danger-with-children",
3104
+ "react/no-did-mount-set-state": "react-doctor/no-did-mount-set-state",
3105
+ "react/no-did-update-set-state": "react-doctor/no-did-update-set-state",
3106
+ "react/no-direct-mutation-state": "react-doctor/no-direct-mutation-state",
3107
+ "react/no-find-dom-node": "react-doctor/no-find-dom-node",
3108
+ "react/no-is-mounted": "react-doctor/no-is-mounted",
3109
+ "react/no-multi-comp": "react-doctor/no-multi-comp",
3110
+ "react/no-namespace": "react-doctor/no-namespace",
3111
+ "react/no-react-children": "react-doctor/no-react-children",
3112
+ "react/no-redundant-should-component-update": "react-doctor/no-redundant-should-component-update",
3113
+ "react/no-render-return-value": "react-doctor/no-render-return-value",
3114
+ "react/no-set-state": "react-doctor/no-set-state",
3115
+ "react/no-string-refs": "react-doctor/no-string-refs",
3116
+ "react/no-this-in-sfc": "react-doctor/no-this-in-sfc",
3117
+ "react/no-unescaped-entities": "react-doctor/no-unescaped-entities",
3118
+ "react/no-unknown-property": "react-doctor/no-unknown-property",
3119
+ "react/no-unsafe": "react-doctor/no-unsafe",
3120
+ "react/no-unstable-nested-components": "react-doctor/no-unstable-nested-components",
3121
+ "react/no-will-update-set-state": "react-doctor/no-will-update-set-state",
3122
+ "react/only-export-components": "react-doctor/only-export-components",
3123
+ "react/prefer-es6-class": "react-doctor/prefer-es6-class",
3124
+ "react/prefer-function-component": "react-doctor/prefer-function-component",
3125
+ "react/react-in-jsx-scope": "react-doctor/react-in-jsx-scope",
3126
+ "react/require-render-return": "react-doctor/require-render-return",
3127
+ "react/rules-of-hooks": "react-doctor/rules-of-hooks",
3128
+ "react/self-closing-comp": "react-doctor/self-closing-comp",
3129
+ "react/state-in-constructor": "react-doctor/state-in-constructor",
3130
+ "react/style-prop-object": "react-doctor/style-prop-object",
3131
+ "react/void-dom-elements-no-children": "react-doctor/void-dom-elements-no-children"
3132
+ };
3133
+ const NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS = /* @__PURE__ */ new Map();
3134
+ for (const [legacyRuleKey, nativeRuleKey] of Object.entries(LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY)) {
3135
+ const aliases = NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.get(nativeRuleKey) ?? [];
3136
+ aliases.push(legacyRuleKey);
3137
+ NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.set(nativeRuleKey, aliases);
3138
+ }
3139
+ const getLegacyRuleKeysForNative = (ruleKey) => NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.get(ruleKey) ?? [];
3140
+ const canonicalizeRuleKey = (ruleKey) => LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey] ?? ruleKey;
3141
+ const isSameRuleKey = (candidateRuleKey, targetRuleKey) => canonicalizeRuleKey(candidateRuleKey) === canonicalizeRuleKey(targetRuleKey);
3142
+ const getEquivalentRuleKeys = (ruleKey) => {
3143
+ const nativeRuleKey = canonicalizeRuleKey(ruleKey);
3144
+ return [nativeRuleKey, ...getLegacyRuleKeysForNative(nativeRuleKey)];
3145
+ };
3015
3146
  /**
3016
3147
  * Resolves the user-configured severity override for a rule.
3017
3148
  * Per-rule overrides win over per-category overrides. Returns
@@ -3020,7 +3151,14 @@ const getDiagnosticRuleIdentity = (diagnostic) => ({
3020
3151
  */
3021
3152
  const resolveRuleSeverityOverride = (input, controls) => {
3022
3153
  if (!controls) return void 0;
3023
- return controls.rules?.[input.ruleKey] ?? (input.category !== void 0 ? controls.categories?.[input.category] : void 0);
3154
+ const exactRuleOverride = controls.rules?.[input.ruleKey];
3155
+ if (exactRuleOverride !== void 0) return exactRuleOverride;
3156
+ for (const equivalentRuleKey of getEquivalentRuleKeys(input.ruleKey)) {
3157
+ if (equivalentRuleKey === input.ruleKey) continue;
3158
+ const equivalentRuleOverride = controls.rules?.[equivalentRuleKey];
3159
+ if (equivalentRuleOverride !== void 0) return equivalentRuleOverride;
3160
+ }
3161
+ return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
3024
3162
  };
3025
3163
  const SEVERITY_FOR_OVERRIDE = {
3026
3164
  error: "error",
@@ -3229,14 +3367,19 @@ const describeFailure = (error) => {
3229
3367
  if (error instanceof Error && error.message) return error.message;
3230
3368
  return String(error);
3231
3369
  };
3232
- const calculateScore = async (diagnostics) => {
3370
+ const calculateScore = async (diagnostics, options = {}) => {
3233
3371
  const controller = new AbortController();
3234
3372
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
3373
+ const requestUrl = options.isCi ? `${SCORE_API_URL}?ci=1` : SCORE_API_URL;
3235
3374
  try {
3236
- const response = await fetch(SCORE_API_URL, {
3375
+ const compressedBody = gzipSync(JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }));
3376
+ const response = await fetch(requestUrl, {
3237
3377
  method: "POST",
3238
- headers: { "Content-Type": "application/json" },
3239
- body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
3378
+ headers: {
3379
+ "Content-Type": "application/json",
3380
+ "Content-Encoding": "gzip"
3381
+ },
3382
+ body: compressedBody,
3240
3383
  signal: controller.signal
3241
3384
  });
3242
3385
  if (!response.ok) {
@@ -3323,60 +3466,6 @@ const canOxlintExtendConfig = (configPath) => {
3323
3466
  if (extendsEntries.length === 0) return true;
3324
3467
  return extendsEntries.some((entry) => typeof entry === "string" && isLocalPathExtend(entry));
3325
3468
  };
3326
- const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
3327
- const REDUCED_MOTION_FILE_GLOBS = [
3328
- "*.ts",
3329
- "*.tsx",
3330
- "*.js",
3331
- "*.jsx",
3332
- "*.css",
3333
- "*.scss"
3334
- ];
3335
- const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
3336
- filePath: "package.json",
3337
- plugin: "react-doctor",
3338
- rule: "require-reduced-motion",
3339
- severity: "error",
3340
- message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
3341
- help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
3342
- line: 0,
3343
- column: 0,
3344
- category: "Accessibility"
3345
- };
3346
- const checkReducedMotion = (rootDirectory) => {
3347
- const packageJsonPath = path.join(rootDirectory, "package.json");
3348
- if (!isFile(packageJsonPath)) return [];
3349
- let hasMotionLibrary = false;
3350
- try {
3351
- const packageJson = readPackageJson(packageJsonPath);
3352
- const allDependencies = {
3353
- ...packageJson.dependencies,
3354
- ...packageJson.devDependencies
3355
- };
3356
- hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
3357
- } catch {
3358
- return [];
3359
- }
3360
- if (!hasMotionLibrary) return [];
3361
- const result = spawnSync("git", [
3362
- "grep",
3363
- "-ql",
3364
- "-E",
3365
- REDUCED_MOTION_GREP_PATTERN,
3366
- "--",
3367
- ...REDUCED_MOTION_FILE_GLOBS
3368
- ], {
3369
- cwd: rootDirectory,
3370
- stdio: [
3371
- "ignore",
3372
- "pipe",
3373
- "pipe"
3374
- ]
3375
- });
3376
- if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
3377
- if (result.status === 0) return [];
3378
- return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
3379
- };
3380
3469
  const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
3381
3470
  const FALSY_VALUES = new Set([
3382
3471
  "false",
@@ -3556,6 +3645,148 @@ const collectIgnorePatterns = (rootDirectory) => {
3556
3645
  cachedPatternsByRoot.set(rootDirectory, patterns);
3557
3646
  return patterns;
3558
3647
  };
3648
+ const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
3649
+ const resolveTsConfigPath = (rootDirectory) => {
3650
+ for (const filename of TSCONFIG_FILENAMES$1) {
3651
+ const candidate = path.join(rootDirectory, filename);
3652
+ if (fs.existsSync(candidate)) return candidate;
3653
+ }
3654
+ };
3655
+ const collectDeadCodeIgnorePatterns = (rootDirectory, userConfig) => {
3656
+ const seen = /* @__PURE__ */ new Set();
3657
+ const sources = [
3658
+ readIgnoreFile(path.join(rootDirectory, ".gitignore")),
3659
+ collectIgnorePatterns(rootDirectory),
3660
+ userConfig?.ignore?.files ?? []
3661
+ ];
3662
+ for (const source of sources) for (const pattern of source) seen.add(pattern);
3663
+ return [...seen].filter((pattern) => pattern.length > 0);
3664
+ };
3665
+ const toRelativeFilePath = (rootDirectory, filePath) => {
3666
+ const relative = toRelativePath(filePath, rootDirectory);
3667
+ return relative.length > 0 ? relative : filePath.replace(/\\/g, "/");
3668
+ };
3669
+ const checkDeadCode = async (options) => {
3670
+ const { rootDirectory, userConfig } = options;
3671
+ if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
3672
+ const { analyze, defineConfig } = await import("deslop-js");
3673
+ const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
3674
+ const result = await analyze(defineConfig({
3675
+ rootDir: rootDirectory,
3676
+ tsConfigPath: resolveTsConfigPath(rootDirectory),
3677
+ ...ignorePatterns.length > 0 ? { ignorePatterns } : {}
3678
+ }));
3679
+ const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
3680
+ const diagnostics = [];
3681
+ for (const unusedFile of result.unusedFiles) diagnostics.push({
3682
+ filePath: toRelative(unusedFile.path),
3683
+ plugin: "deslop",
3684
+ rule: "unused-file",
3685
+ severity: "warning",
3686
+ message: "Unused file — not reachable from any entry point",
3687
+ help: "Delete the file if it is truly unreachable, or import it from an entry point.",
3688
+ line: 0,
3689
+ column: 0,
3690
+ category: "Dead Code"
3691
+ });
3692
+ for (const unusedExport of result.unusedExports) {
3693
+ const label = unusedExport.isTypeOnly ? "type export" : "export";
3694
+ diagnostics.push({
3695
+ filePath: toRelative(unusedExport.path),
3696
+ plugin: "deslop",
3697
+ rule: unusedExport.isTypeOnly ? "unused-type" : "unused-export",
3698
+ severity: "warning",
3699
+ message: `Unused ${label}: \`${unusedExport.name}\``,
3700
+ help: "Drop the `export` keyword (or remove the declaration) if no other module uses this symbol.",
3701
+ line: unusedExport.line,
3702
+ column: unusedExport.column,
3703
+ category: "Dead Code"
3704
+ });
3705
+ }
3706
+ for (const unusedDependency of result.unusedDependencies) {
3707
+ const label = unusedDependency.isDevDependency ? "devDependency" : "dependency";
3708
+ diagnostics.push({
3709
+ filePath: "package.json",
3710
+ plugin: "deslop",
3711
+ rule: unusedDependency.isDevDependency ? "unused-dev-dependency" : "unused-dependency",
3712
+ severity: "warning",
3713
+ message: `Unused ${label}: \`${unusedDependency.name}\``,
3714
+ help: "Remove the dependency from package.json if it is genuinely unused.",
3715
+ line: 0,
3716
+ column: 0,
3717
+ category: "Dead Code"
3718
+ });
3719
+ }
3720
+ for (const cycle of result.circularDependencies) {
3721
+ if (cycle.files.length === 0) continue;
3722
+ diagnostics.push({
3723
+ filePath: toRelative(cycle.files[0]),
3724
+ plugin: "deslop",
3725
+ rule: "circular-dependency",
3726
+ severity: "warning",
3727
+ message: `Circular import cycle: ${cycle.files.map(toRelative).join(" → ")}`,
3728
+ help: "Break the cycle by extracting the shared code into a third module that both files import.",
3729
+ line: 0,
3730
+ column: 0,
3731
+ category: "Dead Code"
3732
+ });
3733
+ }
3734
+ return diagnostics;
3735
+ };
3736
+ const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
3737
+ const REDUCED_MOTION_FILE_GLOBS = [
3738
+ "*.ts",
3739
+ "*.tsx",
3740
+ "*.js",
3741
+ "*.jsx",
3742
+ "*.css",
3743
+ "*.scss"
3744
+ ];
3745
+ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
3746
+ filePath: "package.json",
3747
+ plugin: "react-doctor",
3748
+ rule: "require-reduced-motion",
3749
+ severity: "error",
3750
+ message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
3751
+ help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
3752
+ line: 0,
3753
+ column: 0,
3754
+ category: "Accessibility"
3755
+ };
3756
+ const checkReducedMotion = (rootDirectory) => {
3757
+ const packageJsonPath = path.join(rootDirectory, "package.json");
3758
+ if (!isFile(packageJsonPath)) return [];
3759
+ let hasMotionLibrary = false;
3760
+ try {
3761
+ const packageJson = readPackageJson(packageJsonPath);
3762
+ const allDependencies = {
3763
+ ...packageJson.dependencies,
3764
+ ...packageJson.devDependencies
3765
+ };
3766
+ hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
3767
+ } catch {
3768
+ return [];
3769
+ }
3770
+ if (!hasMotionLibrary) return [];
3771
+ const result = spawnSync("git", [
3772
+ "grep",
3773
+ "-ql",
3774
+ "-E",
3775
+ REDUCED_MOTION_GREP_PATTERN,
3776
+ "--",
3777
+ ...REDUCED_MOTION_FILE_GLOBS
3778
+ ], {
3779
+ cwd: rootDirectory,
3780
+ stdio: [
3781
+ "ignore",
3782
+ "pipe",
3783
+ "pipe"
3784
+ ]
3785
+ });
3786
+ if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
3787
+ if (result.status === 0) return [];
3788
+ return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
3789
+ };
3559
3790
  const createNodeReadFileLinesSync = (rootDirectory) => {
3560
3791
  return (filePath) => {
3561
3792
  const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
@@ -3685,7 +3916,7 @@ const isRuleListedInComment = (ruleList, ruleId) => {
3685
3916
  if (!trimmed) return true;
3686
3917
  const ruleSection = stripDescriptionTail(trimmed).trim();
3687
3918
  if (!ruleSection) return true;
3688
- return ruleSection.split(/[,\s]+/).some((token) => token.trim() === ruleId);
3919
+ return ruleSection.split(/[,\s]+/).some((token) => isSameRuleKey(token.trim(), ruleId));
3689
3920
  };
3690
3921
  const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
3691
3922
  const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
@@ -3837,6 +4068,10 @@ const isInsideStringOnlyWrapper = (lines, diagnosticLine, diagnosticColumn, wrap
3837
4068
  }
3838
4069
  return false;
3839
4070
  };
4071
+ const isIgnoredRule = (ignoredRules, ruleIdentifier) => {
4072
+ for (const ignoredRule of ignoredRules) if (isSameRuleKey(ignoredRule, ruleIdentifier)) return true;
4073
+ return false;
4074
+ };
3840
4075
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
3841
4076
  const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
3842
4077
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
@@ -3847,8 +4082,7 @@ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLi
3847
4082
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
3848
4083
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
3849
4084
  return diagnostics.filter((diagnostic) => {
3850
- const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
3851
- if (ignoredRules.has(ruleIdentifier)) return false;
4085
+ if (isIgnoredRule(ignoredRules, `${diagnostic.plugin}/${diagnostic.rule}`)) return false;
3852
4086
  if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
3853
4087
  if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
3854
4088
  if ((hasTextComponents || hasRawTextWrappers) && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
@@ -3876,11 +4110,24 @@ const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync)
3876
4110
  }] : [diagnostic];
3877
4111
  });
3878
4112
  };
3879
- const TEST_FILE_PATH_PATTERN = /(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\/|\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/;
4113
+ const TEST_FILE_DIRECTORY_PATTERN = /(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\//;
4114
+ const TEST_FILE_SUFFIX_PATTERN = /\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/;
4115
+ const FIXTURE_PROJECT_PATTERN = /\/(?:fixtures|__fixtures__)\//;
4116
+ const SOURCE_ROOT_PATTERN = /\/(?:src|app|lib|components|pages|features|modules|packages|apps|frontend|client)\//g;
4117
+ const stripAboveSourceRoot = (relativePath) => {
4118
+ const fixtureMatch = FIXTURE_PROJECT_PATTERN.exec(relativePath);
4119
+ if (fixtureMatch === null) return relativePath;
4120
+ let lastIdx = -1;
4121
+ for (const match of relativePath.matchAll(SOURCE_ROOT_PATTERN)) if (match.index !== void 0 && match.index > lastIdx) lastIdx = match.index;
4122
+ if (lastIdx >= 0) return relativePath.slice(lastIdx);
4123
+ return relativePath.slice(fixtureMatch.index + fixtureMatch[0].length - 1);
4124
+ };
3880
4125
  const isTestFilePath = (relativePath) => {
3881
4126
  if (relativePath.length === 0) return false;
3882
4127
  const forwardSlashed = relativePath.replaceAll("\\", "/");
3883
- return TEST_FILE_PATH_PATTERN.test(forwardSlashed);
4128
+ if (TEST_FILE_SUFFIX_PATTERN.test(forwardSlashed)) return true;
4129
+ const scoped = stripAboveSourceRoot(forwardSlashed);
4130
+ return TEST_FILE_DIRECTORY_PATTERN.test(scoped);
3884
4131
  };
3885
4132
  const testFileResultCache = /* @__PURE__ */ new Map();
3886
4133
  const clearAutoSuppressionCaches = () => {
@@ -3888,7 +4135,9 @@ const clearAutoSuppressionCaches = () => {
3888
4135
  };
3889
4136
  const shouldAutoSuppress = (diagnostic) => {
3890
4137
  const filePath = diagnostic.filePath;
3891
- if ((diagnostic.plugin === "react-doctor" ? reactDoctorPlugin.rules[diagnostic.rule] : null)?.tags?.includes("test-noise")) {
4138
+ const rule = diagnostic.plugin === "react-doctor" ? reactDoctorPlugin.rules[diagnostic.rule] : null;
4139
+ if (rule?.tags?.includes("test-noise")) {
4140
+ if (rule.tags.includes("migration-hint")) return false;
3892
4141
  let isTest = testFileResultCache.get(filePath);
3893
4142
  if (isTest === void 0) {
3894
4143
  isTest = isTestFilePath(filePath);
@@ -3905,9 +4154,13 @@ const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, rea
3905
4154
  return filterInlineSuppressions(filtered, directory, readFileLinesSync);
3906
4155
  };
3907
4156
  const combineDiagnostics = (input) => {
3908
- const { lintDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true, respectInlineDisables } = input;
3909
- const extraDiagnostics = isDiffMode || !includeEnvironmentChecks ? [] : checkReducedMotion(directory);
3910
- return mergeAndFilterDiagnostics([...lintDiagnostics, ...extraDiagnostics], directory, userConfig, readFileLinesSync, { respectInlineDisables });
4157
+ const { lintDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true, respectInlineDisables, extraDiagnostics = [] } = input;
4158
+ const environmentDiagnostics = isDiffMode || !includeEnvironmentChecks ? [] : checkReducedMotion(directory);
4159
+ return mergeAndFilterDiagnostics([
4160
+ ...lintDiagnostics,
4161
+ ...environmentDiagnostics,
4162
+ ...extraDiagnostics
4163
+ ], directory, userConfig, readFileLinesSync, { respectInlineDisables });
3911
4164
  };
3912
4165
  const findFirstLintConfigInDirectory = (directory) => {
3913
4166
  for (const filename of ADOPTABLE_LINT_CONFIG_FILENAMES) {
@@ -4030,11 +4283,11 @@ const getUncommittedChangedFiles = (directory) => {
4030
4283
  const getDiffInfo = (directory, explicitBaseBranch) => {
4031
4284
  if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) throw new Error("Diff base branch cannot be empty.");
4032
4285
  const currentBranch = getCurrentBranch(directory);
4033
- if (!currentBranch) return null;
4286
+ if (!currentBranch && !explicitBaseBranch) return null;
4034
4287
  const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
4035
4288
  if (!baseBranch) return null;
4036
4289
  if (explicitBaseBranch && !branchExists(directory, explicitBaseBranch)) throw new Error(`Diff base branch "${explicitBaseBranch}" does not exist (run \`git fetch\` to update remote refs).`);
4037
- if (currentBranch === baseBranch) {
4290
+ if (currentBranch !== null && currentBranch === baseBranch) {
4038
4291
  const uncommittedFiles = getUncommittedChangedFiles(directory);
4039
4292
  if (uncommittedFiles.length === 0) return null;
4040
4293
  return {
@@ -4061,6 +4314,7 @@ const VALID_RULE_SEVERITIES = [
4061
4314
  ];
4062
4315
  const BOOLEAN_FIELD_NAMES = [
4063
4316
  "lint",
4317
+ "deadCode",
4064
4318
  "verbose",
4065
4319
  "customRulesOnly",
4066
4320
  "share",
@@ -4416,10 +4670,13 @@ const buildCapabilities = (project) => {
4416
4670
  if (project.hasTypeScript) capabilities.add("typescript");
4417
4671
  return capabilities;
4418
4672
  };
4419
- const shouldEnableRule = (requires, tags, capabilities, ignoredTags) => {
4673
+ const shouldEnableRule = (requires, tags, capabilities, ignoredTags, disabledBy) => {
4420
4674
  if (requires) {
4421
4675
  for (const capability of requires) if (!capabilities.has(capability)) return false;
4422
4676
  }
4677
+ if (disabledBy) {
4678
+ for (const capability of disabledBy) if (capabilities.has(capability)) return false;
4679
+ }
4423
4680
  if (tags) {
4424
4681
  for (const tag of tags) if (ignoredTags.has(tag)) return false;
4425
4682
  }
@@ -4452,23 +4709,6 @@ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
4452
4709
  availableRuleNames: readPluginRuleNames(pluginSpecifier)
4453
4710
  };
4454
4711
  };
4455
- const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect";
4456
- const resolveYouMightNotNeedEffectPlugin = (customRulesOnly) => {
4457
- if (customRulesOnly) return null;
4458
- let pluginSpecifier;
4459
- try {
4460
- pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-you-might-not-need-an-effect");
4461
- } catch {
4462
- return null;
4463
- }
4464
- return {
4465
- entry: {
4466
- name: YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
4467
- specifier: pluginSpecifier
4468
- },
4469
- availableRuleNames: readPluginRuleNames(pluginSpecifier)
4470
- };
4471
- };
4472
4712
  const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
4473
4713
  if (availableRuleNames.size === 0) return rules;
4474
4714
  const ruleKeyPrefix = `${pluginNamespace}/`;
@@ -4487,26 +4727,36 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
4487
4727
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
4488
4728
  return fs.realpathSync(rootDirectory);
4489
4729
  };
4730
+ const applyRuleSeverityControls = (rules, severityControls) => {
4731
+ const enabledRules = {};
4732
+ for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
4733
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
4734
+ if (severity === "off") continue;
4735
+ enabledRules[ruleKey] = severity;
4736
+ }
4737
+ return enabledRules;
4738
+ };
4490
4739
  const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls }) => {
4491
4740
  const reactHooksJsPlugin = resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
4492
- const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
4493
- const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
4494
- const youMightNotNeedEffectRules = youMightNotNeedEffectPlugin ? filterRulesToAvailable(YOU_MIGHT_NOT_NEED_EFFECT_RULES, YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, youMightNotNeedEffectPlugin.availableRuleNames) : {};
4741
+ const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
4495
4742
  const jsPlugins = [];
4496
4743
  if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
4497
- if (youMightNotNeedEffectPlugin) jsPlugins.push(youMightNotNeedEffectPlugin.entry);
4498
4744
  const capabilities = buildCapabilities(project);
4499
4745
  const enabledReactDoctorRules = {};
4500
- for (const [ruleId, rule] of Object.entries(reactDoctorPlugin.rules)) {
4501
- const fullKey = `react-doctor/${ruleId}`;
4746
+ for (const registryEntry of REACT_DOCTOR_RULES) {
4747
+ const rule = reactDoctorPlugin.rules[registryEntry.id];
4748
+ if (!rule) continue;
4749
+ if (customRulesOnly && registryEntry.originallyExternal) continue;
4502
4750
  if (rule.framework !== "global" && !rule.requires) continue;
4503
- if (!shouldEnableRule(rule.requires, rule.tags, capabilities, ignoredTags)) continue;
4504
- const severity = resolveRuleSeverityOverride({
4505
- ruleKey: fullKey,
4751
+ if (!shouldEnableRule(rule.requires, rule.tags, capabilities, ignoredTags, rule.disabledBy)) continue;
4752
+ const explicitSeverity = resolveRuleSeverityOverride({
4753
+ ruleKey: registryEntry.key,
4506
4754
  category: rule.category
4507
- }, severityControls) ?? rule.severity;
4755
+ }, severityControls);
4756
+ if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
4757
+ const severity = explicitSeverity ?? rule.severity;
4508
4758
  if (severity === "off") continue;
4509
- enabledReactDoctorRules[fullKey] = severity;
4759
+ enabledReactDoctorRules[registryEntry.key] = severity;
4510
4760
  }
4511
4761
  return {
4512
4762
  ...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
@@ -4519,7 +4769,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
4519
4769
  style: "off",
4520
4770
  nursery: "off"
4521
4771
  },
4522
- plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
4772
+ plugins: [],
4523
4773
  jsPlugins: [...jsPlugins, pluginPath],
4524
4774
  settings: { "react-doctor": {
4525
4775
  framework: project.framework,
@@ -4527,10 +4777,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
4527
4777
  ...serverAuthFunctionNames && serverAuthFunctionNames.length > 0 ? { serverAuthFunctionNames: [...serverAuthFunctionNames] } : {}
4528
4778
  } },
4529
4779
  rules: {
4530
- ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
4531
- ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
4532
4780
  ...reactCompilerRules,
4533
- ...youMightNotNeedEffectRules,
4534
4781
  ...enabledReactDoctorRules
4535
4782
  }
4536
4783
  };
@@ -4908,7 +5155,13 @@ const SANITIZED_ENV = (() => {
4908
5155
  }
4909
5156
  return sanitized;
4910
5157
  })();
4911
- const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
5158
+ const OXLINT_SPAWN_TIMEOUT_MS = (() => {
5159
+ const raw = process.env["REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS"];
5160
+ if (raw === void 0) return 6e4;
5161
+ const parsed = Number(raw);
5162
+ if (!Number.isFinite(parsed) || parsed <= 0) return 6e4;
5163
+ return parsed;
5164
+ })();
4912
5165
  const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
4913
5166
  const child = spawn(nodeBinaryPath, args, {
4914
5167
  cwd: rootDirectory,
@@ -5088,6 +5341,7 @@ const runOxlint = async (options) => {
5088
5341
  const spawnLintBatches = async () => {
5089
5342
  const allDiagnostics = [];
5090
5343
  const droppedFiles = [];
5344
+ let firstDropReason = null;
5091
5345
  const spawnLintBatch = async (batch) => {
5092
5346
  const batchArgs = [...baseArgs, ...batch];
5093
5347
  try {
@@ -5096,6 +5350,7 @@ const runOxlint = async (options) => {
5096
5350
  if (!isSplittableOxlintBatchError(error)) throw error;
5097
5351
  if (batch.length <= 1) {
5098
5352
  droppedFiles.push(...batch);
5353
+ if (firstDropReason === null && error instanceof Error) firstDropReason = error.message;
5099
5354
  return [];
5100
5355
  }
5101
5356
  const splitIndex = Math.ceil(batch.length / 2);
@@ -5107,7 +5362,8 @@ const runOxlint = async (options) => {
5107
5362
  const previewCount = 3;
5108
5363
  const previewFiles = droppedFiles.slice(0, previewCount).join(", ");
5109
5364
  const remainderHint = droppedFiles.length > previewCount ? `, +${droppedFiles.length - previewCount} more` : "";
5110
- onPartialFailure(`${droppedFiles.length} file(s) exceeded the ${OXLINT_SPAWN_TIMEOUT_MS / 1e3}s per-batch oxlint budget and were skipped (${previewFiles}${remainderHint})`);
5365
+ const reasonHint = firstDropReason ? ` first failure: ${firstDropReason}` : "";
5366
+ onPartialFailure(`${droppedFiles.length} file(s) failed to lint and were skipped (${previewFiles}${remainderHint})${reasonHint}`);
5111
5367
  }
5112
5368
  return dedupeDiagnostics(allDiagnostics);
5113
5369
  };
@@ -5154,7 +5410,8 @@ const toJsonReport = (result, options) => buildJsonReport({
5154
5410
  result: {
5155
5411
  diagnostics: result.diagnostics,
5156
5412
  score: result.score,
5157
- skippedChecks: [],
5413
+ skippedChecks: result.skippedChecks,
5414
+ ...result.skippedCheckReasons ? { skippedCheckReasons: result.skippedCheckReasons } : {},
5158
5415
  project: result.project,
5159
5416
  elapsedMilliseconds: result.elapsedMilliseconds
5160
5417
  }
@@ -5177,32 +5434,54 @@ const diagnose = async (directory, options = {}) => {
5177
5434
  const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
5178
5435
  const readFileLinesSync = createNodeReadFileLinesSync(resolvedDirectory);
5179
5436
  const effectiveLint = options.lint ?? userConfig?.lint ?? true;
5437
+ const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
5180
5438
  const effectiveRespectInlineDisables = options.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true;
5181
5439
  const ignoredTags = new Set(userConfig?.ignore?.tags ?? []);
5440
+ const lintPromise = effectiveLint ? runOxlint({
5441
+ rootDirectory: resolvedDirectory,
5442
+ project: projectInfo,
5443
+ includePaths: lintIncludePaths,
5444
+ customRulesOnly: userConfig?.customRulesOnly ?? false,
5445
+ respectInlineDisables: effectiveRespectInlineDisables,
5446
+ adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
5447
+ ignoredTags,
5448
+ userConfig
5449
+ }).catch((error) => {
5450
+ console.error("Lint failed:", error);
5451
+ return EMPTY_DIAGNOSTICS;
5452
+ }) : Promise.resolve(EMPTY_DIAGNOSTICS);
5453
+ const shouldRunDeadCode = effectiveDeadCode && !isDiffMode;
5454
+ let deadCodeFailureReason = null;
5455
+ const deadCodePromise = shouldRunDeadCode ? checkDeadCode({
5456
+ rootDirectory: resolvedDirectory,
5457
+ userConfig
5458
+ }).catch((error) => {
5459
+ deadCodeFailureReason = error instanceof Error ? error.message : String(error);
5460
+ return EMPTY_DIAGNOSTICS;
5461
+ }) : Promise.resolve(EMPTY_DIAGNOSTICS);
5462
+ const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
5182
5463
  const diagnostics = combineDiagnostics({
5183
- lintDiagnostics: effectiveLint ? await runOxlint({
5184
- rootDirectory: resolvedDirectory,
5185
- project: projectInfo,
5186
- includePaths: lintIncludePaths,
5187
- customRulesOnly: userConfig?.customRulesOnly ?? false,
5188
- respectInlineDisables: effectiveRespectInlineDisables,
5189
- adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
5190
- ignoredTags,
5191
- userConfig
5192
- }).catch((error) => {
5193
- console.error("Lint failed:", error);
5194
- return EMPTY_DIAGNOSTICS;
5195
- }) : EMPTY_DIAGNOSTICS,
5464
+ lintDiagnostics,
5196
5465
  directory: resolvedDirectory,
5197
5466
  isDiffMode,
5198
5467
  userConfig,
5199
5468
  readFileLinesSync,
5200
- respectInlineDisables: effectiveRespectInlineDisables
5469
+ respectInlineDisables: effectiveRespectInlineDisables,
5470
+ extraDiagnostics: deadCodeDiagnostics
5201
5471
  });
5202
5472
  const elapsedMilliseconds = globalThis.performance.now() - startTime;
5473
+ const score = await calculateScore(diagnostics);
5474
+ const skippedChecks = [];
5475
+ const skippedCheckReasons = {};
5476
+ if (deadCodeFailureReason !== null) {
5477
+ skippedChecks.push("dead-code");
5478
+ skippedCheckReasons["dead-code"] = deadCodeFailureReason;
5479
+ }
5203
5480
  return {
5204
5481
  diagnostics,
5205
- score: await calculateScore(diagnostics),
5482
+ score,
5483
+ skippedChecks,
5484
+ ...Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {},
5206
5485
  project: projectInfo,
5207
5486
  elapsedMilliseconds
5208
5487
  };