react-doctor 0.2.14-dev.3ceb748 → 0.2.14-dev.4bc8a73

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
@@ -59,6 +59,7 @@ var Diagnostic = class extends Schema.Class("Diagnostic")({
59
59
  plugin: Schema.String,
60
60
  rule: Schema.String,
61
61
  severity: Severity,
62
+ title: Schema.optional(Schema.String),
62
63
  message: Schema.String,
63
64
  help: Schema.String,
64
65
  url: Schema.optional(Schema.String),
@@ -2094,6 +2095,8 @@ const isFile = (filePath) => {
2094
2095
  }
2095
2096
  };
2096
2097
  const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
2098
+ const GENERATED_BUNDLE_FILE_PATTERN = /\.(iife|umd|global|min)\.js$/i;
2099
+ const MINIFIED_SNIFF_BYTES = 65536;
2097
2100
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
2098
2101
  const IGNORED_DIRECTORIES = new Set([
2099
2102
  ".git",
@@ -2109,6 +2112,34 @@ const IGNORED_DIRECTORIES = new Set([
2109
2112
  "out",
2110
2113
  "storybook-static"
2111
2114
  ]);
2115
+ const isLintableSourceFile = (filePath) => SOURCE_FILE_PATTERN.test(filePath) && !GENERATED_BUNDLE_FILE_PATTERN.test(filePath);
2116
+ const isMinifiedSource = (absolutePath) => {
2117
+ let fileDescriptor;
2118
+ try {
2119
+ fileDescriptor = fs.openSync(absolutePath, "r");
2120
+ const buffer = Buffer.alloc(MINIFIED_SNIFF_BYTES);
2121
+ const bytesRead = fs.readSync(fileDescriptor, buffer, 0, MINIFIED_SNIFF_BYTES, 0);
2122
+ const prefix = buffer.toString("utf8", 0, bytesRead);
2123
+ const lines = prefix.split("\n");
2124
+ const longestLineLength = lines.reduce((longest, line) => Math.max(longest, line.length), 0);
2125
+ const averageLineLength = prefix.length / lines.length;
2126
+ return longestLineLength > 1e3 && averageLineLength > 500;
2127
+ } catch {
2128
+ return false;
2129
+ } finally {
2130
+ if (fileDescriptor !== void 0) fs.closeSync(fileDescriptor);
2131
+ }
2132
+ };
2133
+ const isLargeMinifiedFile = (absolutePath) => {
2134
+ let sizeBytes;
2135
+ try {
2136
+ sizeBytes = fs.statSync(absolutePath).size;
2137
+ } catch {
2138
+ return false;
2139
+ }
2140
+ if (sizeBytes < 2e4) return false;
2141
+ return isMinifiedSource(absolutePath);
2142
+ };
2112
2143
  const IGNORABLE_READDIR_ERROR_CODES = new Set([
2113
2144
  "EACCES",
2114
2145
  "EPERM",
@@ -2139,7 +2170,7 @@ const countSourceFilesViaFilesystem = (rootDirectory) => {
2139
2170
  if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
2140
2171
  continue;
2141
2172
  }
2142
- if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
2173
+ if (entry.isFile() && isLintableSourceFile(entry.name) && !isLargeMinifiedFile(path.join(currentDirectory, entry.name))) count++;
2143
2174
  }
2144
2175
  }
2145
2176
  return count;
@@ -2157,7 +2188,7 @@ const countSourceFilesViaGit = (rootDirectory) => {
2157
2188
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
2158
2189
  });
2159
2190
  if (result.error || result.status !== 0) return null;
2160
- return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
2191
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath) && !isLargeMinifiedFile(path.resolve(rootDirectory, filePath))).length;
2161
2192
  };
2162
2193
  const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
2163
2194
  const cachedPackageJsons = /* @__PURE__ */ new Map();
@@ -2843,29 +2874,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
2843
2874
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
2844
2875
  };
2845
2876
  };
2846
- const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
2847
- if (predicate(rootPackageJson)) return true;
2877
+ const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
2878
+ const rootValue = select(rootPackageJson);
2879
+ if (rootValue !== null) return rootValue;
2848
2880
  const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
2849
- if (patterns.length === 0) return false;
2881
+ if (patterns.length === 0) return null;
2850
2882
  const visitedDirectories = /* @__PURE__ */ new Set();
2851
2883
  for (const pattern of patterns) {
2852
- const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
2884
+ const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
2853
2885
  for (const workspaceDirectory of directories) {
2854
2886
  if (visitedDirectories.has(workspaceDirectory)) continue;
2855
2887
  visitedDirectories.add(workspaceDirectory);
2856
- if (predicate(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
2888
+ const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
2889
+ if (value !== null) return value;
2857
2890
  }
2858
2891
  }
2859
- return false;
2892
+ return null;
2860
2893
  };
2894
+ const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
2861
2895
  const NAMES = new Set([
2862
2896
  "react-native",
2863
2897
  "react-native-tvos",
2864
- "expo",
2865
- "expo-router",
2866
- "@expo/cli",
2867
- "@expo/metro-config",
2868
- "@expo/metro-runtime",
2898
+ ...new Set([
2899
+ "expo",
2900
+ "expo-router",
2901
+ "@expo/cli",
2902
+ "@expo/metro-config",
2903
+ "@expo/metro-runtime"
2904
+ ]),
2869
2905
  "react-native-windows",
2870
2906
  "react-native-macos"
2871
2907
  ]);
@@ -2889,6 +2925,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
2889
2925
  return false;
2890
2926
  };
2891
2927
  const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
2928
+ const getExpoDependencySpec = (packageJson) => {
2929
+ const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
2930
+ return typeof spec === "string" ? spec : null;
2931
+ };
2932
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
2892
2933
  const getPreactVersion = (packageJson) => {
2893
2934
  return {
2894
2935
  ...packageJson.peerDependencies,
@@ -3128,6 +3169,19 @@ const discoverProject = (directory) => {
3128
3169
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
3129
3170
  const sourceFileCount = countSourceFiles(directory);
3130
3171
  const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
3172
+ let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
3173
+ if (expoVersion !== null && isCatalogReference(expoVersion)) {
3174
+ const catalogName = extractCatalogName(expoVersion);
3175
+ let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
3176
+ if (!resolvedExpoVersion) {
3177
+ const monorepoRoot = findMonorepoRoot(directory);
3178
+ if (monorepoRoot) {
3179
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
3180
+ if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
3181
+ }
3182
+ }
3183
+ expoVersion = resolvedExpoVersion ?? expoVersion;
3184
+ }
3131
3185
  const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
3132
3186
  const preactVersion = getPreactVersion(packageJson);
3133
3187
  const projectInfo = {
@@ -3145,6 +3199,7 @@ const discoverProject = (directory) => {
3145
3199
  preactVersion,
3146
3200
  preactMajorVersion: parseReactMajor(preactVersion),
3147
3201
  hasReactNativeWorkspace,
3202
+ expoVersion,
3148
3203
  hasReanimated,
3149
3204
  sourceFileCount
3150
3205
  };
@@ -3240,7 +3295,18 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
3240
3295
  ];
3241
3296
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
3242
3297
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
3298
+ const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192;
3243
3299
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
3300
+ const DIAGNOSTIC_CATEGORY_BUCKETS = [
3301
+ "Security",
3302
+ "Bugs",
3303
+ "Performance",
3304
+ "Accessibility",
3305
+ "Maintainability"
3306
+ ];
3307
+ const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
3308
+ const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
3309
+ const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
3244
3310
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
3245
3311
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
3246
3312
  var InvalidGlobPatternError = class extends Error {
@@ -3360,10 +3426,11 @@ const restampSeverity = (diagnostic, override) => {
3360
3426
  */
3361
3427
  const buildRuleSeverityControls = (config) => {
3362
3428
  if (!config) return void 0;
3363
- if (config.rules === void 0 && config.categories === void 0) return void 0;
3429
+ if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
3364
3430
  return {
3365
3431
  ...config.rules !== void 0 ? { rules: config.rules } : {},
3366
- ...config.categories !== void 0 ? { categories: config.categories } : {}
3432
+ ...config.categories !== void 0 ? { categories: config.categories } : {},
3433
+ ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
3367
3434
  };
3368
3435
  };
3369
3436
  const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
@@ -3727,6 +3794,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
3727
3794
  }
3728
3795
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
3729
3796
  };
3797
+ const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
3798
+ const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
3799
+ const findNearestPackageDirectory = (filename) => {
3800
+ if (!filename) return null;
3801
+ const fromCache = cachedPackageDirectoryByFilename.get(filename);
3802
+ if (fromCache !== void 0) return fromCache;
3803
+ let currentDirectory = path.dirname(filename);
3804
+ while (true) {
3805
+ const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
3806
+ let hasPackageJson = false;
3807
+ try {
3808
+ hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
3809
+ } catch {
3810
+ hasPackageJson = false;
3811
+ }
3812
+ if (hasPackageJson) {
3813
+ cachedPackageDirectoryByFilename.set(filename, currentDirectory);
3814
+ return currentDirectory;
3815
+ }
3816
+ const parentDirectory = path.dirname(currentDirectory);
3817
+ if (parentDirectory === currentDirectory) {
3818
+ cachedPackageDirectoryByFilename.set(filename, null);
3819
+ return null;
3820
+ }
3821
+ currentDirectory = parentDirectory;
3822
+ }
3823
+ };
3824
+ const readManifest = (packageJsonPath) => {
3825
+ try {
3826
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
3827
+ if (typeof parsed === "object" && parsed !== null) return parsed;
3828
+ return null;
3829
+ } catch {
3830
+ return null;
3831
+ }
3832
+ };
3833
+ const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
3834
+ const classifyByDirectoryCohort = (packageDirectory) => {
3835
+ let current = packageDirectory;
3836
+ while (true) {
3837
+ if (path.basename(current) === "apps") return "app";
3838
+ const parent = path.dirname(current);
3839
+ if (parent === current) return null;
3840
+ current = parent;
3841
+ }
3842
+ };
3843
+ const clearPackageRoleCache = () => {
3844
+ cachedRoleByPackageDirectory.clear();
3845
+ cachedPackageDirectoryByFilename.clear();
3846
+ };
3847
+ const classifyPackageRole = (filename) => {
3848
+ if (!filename) return "unknown";
3849
+ const packageDirectory = findNearestPackageDirectory(filename);
3850
+ if (!packageDirectory) return "unknown";
3851
+ const cached = cachedRoleByPackageDirectory.get(packageDirectory);
3852
+ if (cached !== void 0) return cached;
3853
+ const manifest = readManifest(path.join(packageDirectory, "package.json"));
3854
+ let result;
3855
+ if (manifest && hasPublishContract(manifest)) result = "library";
3856
+ else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
3857
+ cachedRoleByPackageDirectory.set(packageDirectory, result);
3858
+ return result;
3859
+ };
3730
3860
  /**
3731
3861
  * Resolves the absolute path to read for a diagnostic's `filePath`,
3732
3862
  * accounting for the various shapes oxlint emits:
@@ -3862,10 +3992,13 @@ const collectStringSet = (values) => {
3862
3992
  * wins over `test-noise`)
3863
3993
  * 2. severity overrides (top-level `rules` / `categories`, with
3864
3994
  * `"off"` dropping)
3865
- * 3. ignore filters (rules / file patterns / per-file overrides)
3866
- * 4. `rn-no-raw-text` suppression via configured `textComponents` and
3995
+ * 3. warning suppression (only when `showWarnings` is false: drops every
3996
+ * `"warning"`-severity diagnostic unless a severity override opts a
3997
+ * specific rule / category back in)
3998
+ * 4. ignore filters (rules / file patterns / per-file overrides)
3999
+ * 5. `rn-no-raw-text` suppression via configured `textComponents` and
3867
4000
  * `rawTextWrapperComponents` (config-driven JSX enclosure checks)
3868
- * 5. inline suppressions (`// react-doctor-disable-next-line ...`)
4001
+ * 6. inline suppressions (`// react-doctor-disable-next-line ...`)
3869
4002
  *
3870
4003
  * Returns `null` when the diagnostic is dropped, the (possibly
3871
4004
  * severity-restamped) diagnostic otherwise.
@@ -3875,7 +4008,7 @@ const collectStringSet = (values) => {
3875
4008
  * `mergeAndFilterDiagnostics` wrapper apply this closure per element.
3876
4009
  */
3877
4010
  const buildDiagnosticPipeline = (input) => {
3878
- const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables } = input;
4011
+ const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables, showWarnings } = input;
3879
4012
  const severityControls = buildRuleSeverityControls(userConfig);
3880
4013
  const ignoredRules = new Set(Array.isArray(userConfig?.ignore?.rules) ? userConfig.ignore.rules.filter((rule) => typeof rule === "string") : []);
3881
4014
  const ignoredFilePatterns = compileIgnoredFilePatterns(userConfig);
@@ -3886,6 +4019,15 @@ const buildDiagnosticPipeline = (input) => {
3886
4019
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
3887
4020
  const fileLinesCache = /* @__PURE__ */ new Map();
3888
4021
  const testFileCache = /* @__PURE__ */ new Map();
4022
+ const libraryFileCache = /* @__PURE__ */ new Map();
4023
+ const isLibraryFile = (filePath) => {
4024
+ let cached = libraryFileCache.get(filePath);
4025
+ if (cached === void 0) {
4026
+ cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
4027
+ libraryFileCache.set(filePath, cached);
4028
+ }
4029
+ return cached;
4030
+ };
3889
4031
  const getFileLines = (filePath) => {
3890
4032
  const cached = fileLinesCache.get(filePath);
3891
4033
  if (cached !== void 0) return cached;
@@ -3912,6 +4054,10 @@ const buildDiagnosticPipeline = (input) => {
3912
4054
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
3913
4055
  return false;
3914
4056
  };
4057
+ const isAppOnlyRule = (ruleIdentifier) => {
4058
+ for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
4059
+ return false;
4060
+ };
3915
4061
  const isRnRawTextSuppressedByConfig = (diagnostic) => {
3916
4062
  if (diagnostic.rule !== "rn-no-raw-text") return false;
3917
4063
  if (diagnostic.line <= 0) return false;
@@ -3925,15 +4071,22 @@ const buildDiagnosticPipeline = (input) => {
3925
4071
  return { apply: (diagnostic) => {
3926
4072
  if (shouldAutoSuppress(diagnostic)) return null;
3927
4073
  let current = diagnostic;
4074
+ let explicitSeverityOverride;
4075
+ let explicitRuleOverride;
3928
4076
  if (severityControls) {
3929
4077
  const { ruleKey, category } = getDiagnosticRuleIdentity(current);
3930
- const override = resolveRuleSeverityOverride({
4078
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
4079
+ explicitSeverityOverride = resolveRuleSeverityOverride({
3931
4080
  ruleKey,
3932
4081
  category
3933
4082
  }, severityControls);
3934
- if (override === "off") return null;
3935
- if (override !== void 0) current = restampSeverity(current, override);
4083
+ if (explicitSeverityOverride === "off") return null;
4084
+ if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
3936
4085
  }
4086
+ if (explicitRuleOverride === void 0) {
4087
+ if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
4088
+ }
4089
+ if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
3937
4090
  if (userConfig) {
3938
4091
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
3939
4092
  if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) return null;
@@ -4165,10 +4318,18 @@ const VALID_RULE_SEVERITIES = [
4165
4318
  "warn",
4166
4319
  "off"
4167
4320
  ];
4321
+ const KNOWN_CATEGORY_LABEL = DIAGNOSTIC_CATEGORY_BUCKETS.join(", ");
4322
+ const isDiagnosticCategoryBucket = (value) => DIAGNOSTIC_CATEGORY_BUCKETS.includes(value);
4323
+ const filterKnownCategories = (fieldName, categories) => categories.filter((category) => {
4324
+ if (isDiagnosticCategoryBucket(category)) return true;
4325
+ warnConfigIssue(`config field "${fieldName}" lists "${category}", which is not a known category (expected one of: ${KNOWN_CATEGORY_LABEL}); ignoring the entry.`);
4326
+ return false;
4327
+ });
4168
4328
  const BOOLEAN_FIELD_NAMES = [
4169
4329
  "lint",
4170
4330
  "deadCode",
4171
4331
  "verbose",
4332
+ "warnings",
4172
4333
  "customRulesOnly",
4173
4334
  "share",
4174
4335
  "noScore",
@@ -4217,13 +4378,15 @@ const validateSurfaceControls = (surface, rawControls) => {
4217
4378
  warnConfigIssue(`config field "surfaces.${surface}" must be an object (got ${typeof rawControls}); ignoring this surface.`);
4218
4379
  return;
4219
4380
  }
4220
- const validated = {};
4381
+ const validatedSurfaceControls = {};
4221
4382
  for (const fieldName of SURFACE_CONTROL_FIELD_NAMES) {
4222
4383
  if (rawControls[fieldName] === void 0) continue;
4223
- const result = validateStringArrayField(`surfaces.${surface}.${fieldName}`, rawControls[fieldName]);
4224
- if (result !== void 0) validated[fieldName] = result;
4384
+ const qualifiedName = `surfaces.${surface}.${fieldName}`;
4385
+ const result = validateStringArrayField(qualifiedName, rawControls[fieldName]);
4386
+ if (result === void 0) continue;
4387
+ validatedSurfaceControls[fieldName] = fieldName === "includeCategories" || fieldName === "excludeCategories" ? filterKnownCategories(qualifiedName, result) : result;
4225
4388
  }
4226
- return validated;
4389
+ return validatedSurfaceControls;
4227
4390
  };
4228
4391
  const validateSurfacesField = (rawSurfaces) => {
4229
4392
  if (!isPlainObject$1(rawSurfaces)) {
@@ -4241,7 +4404,7 @@ const validateSurfacesField = (rawSurfaces) => {
4241
4404
  }
4242
4405
  return validated;
4243
4406
  };
4244
- const validateSeverityMap = (fieldName, rawMap) => {
4407
+ const validateSeverityMap = (fieldName, rawMap, keysAreCategories = false) => {
4245
4408
  if (!isPlainObject$1(rawMap)) {
4246
4409
  warnConfigIssue(`config field "${fieldName}" must be an object (got ${typeof rawMap}); ignoring this field.`);
4247
4410
  return;
@@ -4252,6 +4415,10 @@ const validateSeverityMap = (fieldName, rawMap) => {
4252
4415
  warnConfigIssue(`config field "${fieldName}" has an empty key; ignoring the entry.`);
4253
4416
  continue;
4254
4417
  }
4418
+ if (keysAreCategories && !isDiagnosticCategoryBucket(key)) {
4419
+ warnConfigIssue(`config field "${fieldName}.${key}" is not a known category (expected one of: ${KNOWN_CATEGORY_LABEL}); ignoring the entry.`);
4420
+ continue;
4421
+ }
4255
4422
  if (!isRuleSeverity(value)) {
4256
4423
  warnConfigIssue(`config field "${fieldName}.${key}" must be one of: ${VALID_RULE_SEVERITIES.join(", ")} (got ${formatType(value)}); ignoring the entry.`);
4257
4424
  continue;
@@ -4272,7 +4439,7 @@ const validateConfigTypes = (config) => {
4272
4439
  for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
4273
4440
  for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
4274
4441
  applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
4275
- for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value));
4442
+ for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
4276
4443
  applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
4277
4444
  return validated;
4278
4445
  };
@@ -4356,11 +4523,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
4356
4523
  }
4357
4524
  return resolvedRootDir;
4358
4525
  };
4359
- const resolveDiagnoseTarget = (directory) => {
4526
+ const resolveDiagnoseTarget = (directory, options = {}) => {
4360
4527
  if (isFile(path.join(directory, "package.json"))) return directory;
4361
4528
  const reactSubprojects = discoverReactSubprojects(directory);
4362
4529
  if (reactSubprojects.length === 0) return null;
4363
4530
  if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
4531
+ if (options.allowAmbiguous === true) return null;
4364
4532
  throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
4365
4533
  };
4366
4534
  /**
@@ -4374,7 +4542,8 @@ const resolveDiagnoseTarget = (directory) => {
4374
4542
  * project root, if configured.
4375
4543
  * 4. Walk into a nested React subproject when the requested
4376
4544
  * directory has no `package.json` of its own (raises
4377
- * `AmbiguousProjectError` when multiple candidates exist).
4545
+ * `AmbiguousProjectError` when multiple candidates exist unless
4546
+ * the caller opts into keeping the wrapper directory).
4378
4547
  *
4379
4548
  * Throws `ProjectNotFoundError` when neither the requested directory
4380
4549
  * nor any discoverable nested project has a `package.json`.
@@ -4386,14 +4555,14 @@ const resolveDiagnoseTarget = (directory) => {
4386
4555
  * via its own cache). Routing through `resolveScanTarget` keeps every
4387
4556
  * shell in agreement on what "the scan directory" means.
4388
4557
  */
4389
- const resolveScanTarget = (requestedDirectory) => {
4558
+ const resolveScanTarget = (requestedDirectory, options = {}) => {
4390
4559
  const absoluteRequested = path.resolve(requestedDirectory);
4391
4560
  const loadedConfig = loadConfigWithSource(absoluteRequested);
4392
4561
  const userConfig = loadedConfig?.config ?? null;
4393
4562
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4394
4563
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
4395
4564
  const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
4396
- const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
4565
+ const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
4397
4566
  if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
4398
4567
  return {
4399
4568
  resolvedDirectory,
@@ -4403,6 +4572,359 @@ const resolveScanTarget = (requestedDirectory) => {
4403
4572
  didRedirectViaRootDir: redirectedDirectory !== null
4404
4573
  };
4405
4574
  };
4575
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
4576
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
4577
+ const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
4578
+ return {
4579
+ rootDirectory,
4580
+ packageJson,
4581
+ directDependencyNames: getDirectDependencyNames(packageJson),
4582
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
4583
+ };
4584
+ };
4585
+ const buildExpoDiagnostic = (input) => ({
4586
+ filePath: input.filePath ?? "package.json",
4587
+ plugin: "react-doctor",
4588
+ rule: input.rule,
4589
+ severity: input.severity ?? "warning",
4590
+ message: input.message,
4591
+ help: input.help,
4592
+ line: input.line ?? 0,
4593
+ column: input.column ?? 0,
4594
+ category: input.category ?? "Correctness"
4595
+ });
4596
+ const CRITICAL_OVERRIDE_NAMES = new Set([
4597
+ "@expo/cli",
4598
+ "@expo/config",
4599
+ "@expo/metro-config",
4600
+ "@expo/metro-runtime",
4601
+ "@expo/metro",
4602
+ "metro"
4603
+ ]);
4604
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
4605
+ const collectOverrideNames = (packageJson) => new Set([
4606
+ ...Object.keys(packageJson.overrides ?? {}),
4607
+ ...Object.keys(packageJson.resolutions ?? {}),
4608
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
4609
+ ]);
4610
+ const checkExpoDependencyOverrides = (context) => {
4611
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
4612
+ if (overriddenCriticalNames.length === 0) return [];
4613
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
4614
+ return [buildExpoDiagnostic({
4615
+ rule: "expo-no-conflicting-dependency-override",
4616
+ message: `package.json pins SDK-critical ${overriddenCriticalNames.length === 1 ? "package" : "packages"} via overrides/resolutions (${quotedNames}) — these versions are tied to the Expo SDK release and overriding them is unsupported and may break Metro or native builds`,
4617
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
4618
+ })];
4619
+ };
4620
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
4621
+ const result = spawnSync("git", [
4622
+ "check-ignore",
4623
+ "-q",
4624
+ absolutePath
4625
+ ], {
4626
+ cwd: rootDirectory,
4627
+ stdio: [
4628
+ "ignore",
4629
+ "ignore",
4630
+ "ignore"
4631
+ ]
4632
+ });
4633
+ if (result.error) return null;
4634
+ if (result.status === 0) return true;
4635
+ if (result.status === 1) return false;
4636
+ return null;
4637
+ };
4638
+ const LOCAL_ENV_FILE_NAMES = [
4639
+ ".env.local",
4640
+ ".env.development.local",
4641
+ ".env.production.local",
4642
+ ".env.test.local"
4643
+ ];
4644
+ const checkExpoEnvLocalFiles = (context) => {
4645
+ const { rootDirectory } = context;
4646
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
4647
+ const filePath = path.join(rootDirectory, fileName);
4648
+ if (!isFile(filePath)) return false;
4649
+ return isPathGitIgnored(rootDirectory, filePath) === false;
4650
+ });
4651
+ if (committedEnvFiles.length === 0) return [];
4652
+ return [buildExpoDiagnostic({
4653
+ rule: "expo-env-local-not-gitignored",
4654
+ category: "Security",
4655
+ message: `Local environment ${committedEnvFiles.length === 1 ? "file" : "files"} (${committedEnvFiles.join(", ")}) ${committedEnvFiles.length === 1 ? "is" : "are"} not ignored by Git — committing \`.env*.local\` risks leaking secrets and overriding committed defaults for everyone who clones the project`,
4656
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
4657
+ })];
4658
+ };
4659
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
4660
+ const UNIMODULES_HELP = "Remove every `@unimodules/*` and `react-native-unimodules` package — their functionality now lives in `expo-modules-core`. See https://expo.fyi/r/sdk-44-remove-unimodules";
4661
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
4662
+ const unimodulesEntry = (packageName) => ({
4663
+ packageName,
4664
+ rule: "expo-no-unimodules-packages",
4665
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
4666
+ help: UNIMODULES_HELP
4667
+ });
4668
+ const FLAGGED_DEPENDENCIES = [
4669
+ unimodulesEntry("@unimodules/core"),
4670
+ unimodulesEntry("@unimodules/react-native-adapter"),
4671
+ unimodulesEntry("react-native-unimodules"),
4672
+ {
4673
+ packageName: "expo-cli",
4674
+ rule: "expo-no-cli-dependencies",
4675
+ message: "`expo-cli` (the legacy global CLI) is a project dependency — the CLI now ships inside the `expo` package, and keeping `expo-cli` causes failures such as `unknown option --fix` when running `npx expo install --fix`",
4676
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
4677
+ },
4678
+ {
4679
+ packageName: "eas-cli",
4680
+ rule: "expo-no-cli-dependencies",
4681
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
4682
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
4683
+ },
4684
+ {
4685
+ packageName: "expo-modules-autolinking",
4686
+ rule: "expo-no-redundant-dependency",
4687
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
4688
+ help: "Remove `expo-modules-autolinking` from your package.json"
4689
+ },
4690
+ {
4691
+ packageName: "expo-dev-launcher",
4692
+ rule: "expo-no-redundant-dependency",
4693
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4694
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
4695
+ },
4696
+ {
4697
+ packageName: "expo-dev-menu",
4698
+ rule: "expo-no-redundant-dependency",
4699
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4700
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
4701
+ },
4702
+ {
4703
+ packageName: "expo-modules-core",
4704
+ rule: "expo-no-redundant-dependency",
4705
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
4706
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
4707
+ },
4708
+ {
4709
+ packageName: "@expo/metro-config",
4710
+ rule: "expo-no-redundant-dependency",
4711
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
4712
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
4713
+ },
4714
+ {
4715
+ packageName: "@types/react-native",
4716
+ rule: "expo-no-redundant-dependency",
4717
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
4718
+ help: "Remove `@types/react-native` from your package.json",
4719
+ minSdkMajor: 48
4720
+ },
4721
+ {
4722
+ packageName: "@expo/config-plugins",
4723
+ rule: "expo-no-redundant-dependency",
4724
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
4725
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
4726
+ minSdkMajor: 48
4727
+ },
4728
+ {
4729
+ packageName: "@expo/prebuild-config",
4730
+ rule: "expo-no-redundant-dependency",
4731
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
4732
+ help: "Remove `@expo/prebuild-config` from your package.json",
4733
+ minSdkMajor: 53
4734
+ },
4735
+ {
4736
+ packageName: "expo-permissions",
4737
+ rule: "expo-no-redundant-dependency",
4738
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
4739
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
4740
+ minSdkMajor: 50
4741
+ },
4742
+ {
4743
+ packageName: "expo-app-loading",
4744
+ rule: "expo-no-redundant-dependency",
4745
+ message: "\"expo-app-loading\" was removed in SDK 49",
4746
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
4747
+ minSdkMajor: 49
4748
+ },
4749
+ {
4750
+ packageName: "expo-firebase-analytics",
4751
+ rule: "expo-no-redundant-dependency",
4752
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
4753
+ help: FIREBASE_HELP,
4754
+ minSdkMajor: 48
4755
+ },
4756
+ {
4757
+ packageName: "expo-firebase-recaptcha",
4758
+ rule: "expo-no-redundant-dependency",
4759
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
4760
+ help: FIREBASE_HELP,
4761
+ minSdkMajor: 48
4762
+ },
4763
+ {
4764
+ packageName: "expo-firebase-core",
4765
+ rule: "expo-no-redundant-dependency",
4766
+ message: "\"expo-firebase-core\" was removed in SDK 48",
4767
+ help: FIREBASE_HELP,
4768
+ minSdkMajor: 48
4769
+ }
4770
+ ];
4771
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
4772
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
4773
+ if (flaggedDependency.minSdkMajor === void 0) return true;
4774
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
4775
+ }).map((flaggedDependency) => buildExpoDiagnostic({
4776
+ rule: flaggedDependency.rule,
4777
+ message: flaggedDependency.message,
4778
+ help: flaggedDependency.help
4779
+ }));
4780
+ const findLocalModuleNativeFiles = (rootDirectory) => {
4781
+ const modulesDirectory = path.join(rootDirectory, "modules");
4782
+ if (!isDirectory(modulesDirectory)) return [];
4783
+ const nativeFilePaths = [];
4784
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
4785
+ if (!moduleEntry.isDirectory()) continue;
4786
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
4787
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
4788
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
4789
+ const iosDirectory = path.join(moduleDirectory, "ios");
4790
+ if (isDirectory(iosDirectory)) {
4791
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
4792
+ }
4793
+ }
4794
+ return nativeFilePaths;
4795
+ };
4796
+ const checkExpoGitignore = (context) => {
4797
+ const { rootDirectory } = context;
4798
+ const diagnostics = [];
4799
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
4800
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
4801
+ rule: "expo-gitignore",
4802
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
4803
+ help: "Add `.expo/` to your .gitignore"
4804
+ }));
4805
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
4806
+ rule: "expo-gitignore",
4807
+ message: "The native `ios`/`android` directories of a local Expo module under `modules/` are gitignored — usually caused by an overly broad `ios`/`android` ignore rule",
4808
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
4809
+ }));
4810
+ return diagnostics;
4811
+ };
4812
+ const LOCKFILE_NAMES = [
4813
+ "pnpm-lock.yaml",
4814
+ "yarn.lock",
4815
+ "package-lock.json",
4816
+ "bun.lockb",
4817
+ "bun.lock"
4818
+ ];
4819
+ const checkExpoLockfile = (context) => {
4820
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
4821
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
4822
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
4823
+ rule: "expo-lockfile",
4824
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
4825
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
4826
+ })];
4827
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
4828
+ rule: "expo-lockfile",
4829
+ message: `Multiple lock files detected (${presentLockfiles.join(", ")}) — CI environments such as EAS Build infer the package manager from the lock file, so this is ambiguous`,
4830
+ help: "Delete the lock files for the package managers you are not using and keep only one"
4831
+ })];
4832
+ return [];
4833
+ };
4834
+ const METRO_CONFIG_FILE_NAMES = [
4835
+ "metro.config.js",
4836
+ "metro.config.cjs",
4837
+ "metro.config.mjs",
4838
+ "metro.config.ts"
4839
+ ];
4840
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
4841
+ "expo/metro-config",
4842
+ "@sentry/react-native/metro",
4843
+ "getSentryExpoConfig"
4844
+ ];
4845
+ const checkExpoMetroConfig = (context) => {
4846
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
4847
+ if (metroConfigPath === void 0) return [];
4848
+ let contents;
4849
+ try {
4850
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
4851
+ } catch {
4852
+ return [];
4853
+ }
4854
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
4855
+ return [buildExpoDiagnostic({
4856
+ rule: "expo-metro-config",
4857
+ filePath: path.basename(metroConfigPath),
4858
+ message: "Your metro.config does not extend `expo/metro-config` — a custom Metro config that doesn't extend Expo's leads to unexpected, hard-to-debug bundling issues",
4859
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
4860
+ })];
4861
+ };
4862
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
4863
+ const checkExpoPackageJsonConflicts = (context) => {
4864
+ const { packageJson } = context;
4865
+ const diagnostics = [];
4866
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
4867
+ if (conflictingScriptNames.length > 0) {
4868
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
4869
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
4870
+ diagnostics.push(buildExpoDiagnostic({
4871
+ rule: "expo-package-json-conflict",
4872
+ message: `package.json defines ${quotedNames} ${conflictingScriptNames.length === 1 ? "as a script that conflicts" : "as scripts that conflict"} with binaries in node_modules/.bin${shadowsExpoCli ? " — a `expo` script shadows the Expo CLI and will likely cause build failures" : ""}`,
4873
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
4874
+ }));
4875
+ }
4876
+ const packageName = packageJson.name;
4877
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
4878
+ rule: "expo-package-json-conflict",
4879
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
4880
+ help: "Rename your package so it no longer matches one of its dependencies"
4881
+ }));
4882
+ return diagnostics;
4883
+ };
4884
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
4885
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
4886
+ const checkExpoRouterReactNavigation = (context) => {
4887
+ const { expoSdkMajor } = context;
4888
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
4889
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
4890
+ if (!context.directDependencyNames.has("expo-router")) return [];
4891
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
4892
+ if (reactNavigationNames.length === 0) return [];
4893
+ return [buildExpoDiagnostic({
4894
+ rule: "expo-router-no-react-navigation",
4895
+ message: `As of SDK 56, expo-router is no longer compatible with react-navigation, but ${reactNavigationNames.map((name) => `"${name}"`).join(", ")} ${reactNavigationNames.length === 1 ? "is" : "are"} installed as direct ${reactNavigationNames.length === 1 ? "dependency" : "dependencies"}`,
4896
+ help: "Remove these `@react-navigation/*` packages and replace direct imports with their expo-router equivalents. See https://docs.expo.dev/router/migrate/sdk-55-to-56/"
4897
+ })];
4898
+ };
4899
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
4900
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
4901
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
4902
+ const checkExpoVectorIcons = (context) => {
4903
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
4904
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
4905
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
4906
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
4907
+ return [buildExpoDiagnostic({
4908
+ rule: "expo-vector-icons-conflict",
4909
+ message: "This project installs both the scoped `@react-native-vector-icons/*` packages and `@expo/vector-icons` (or the deprecated `react-native-vector-icons`) — mixing them causes icon-rendering conflicts",
4910
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
4911
+ })];
4912
+ };
4913
+ const checkExpoProject = (rootDirectory, project) => {
4914
+ if (project.expoVersion === null) return [];
4915
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
4916
+ return [
4917
+ ...checkExpoFlaggedDependencies(context),
4918
+ ...checkExpoDependencyOverrides(context),
4919
+ ...checkExpoRouterReactNavigation(context),
4920
+ ...checkExpoVectorIcons(context),
4921
+ ...checkExpoPackageJsonConflicts(context),
4922
+ ...checkExpoLockfile(context),
4923
+ ...checkExpoGitignore(context),
4924
+ ...checkExpoEnvLocalFiles(context),
4925
+ ...checkExpoMetroConfig(context)
4926
+ ];
4927
+ };
4406
4928
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4407
4929
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
4408
4930
  const PACKAGE_JSON_FILE = "package.json";
@@ -4572,99 +5094,6 @@ const checkReducedMotion = (rootDirectory) => {
4572
5094
  return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
4573
5095
  };
4574
5096
  const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
4575
- const toStringSet = (values) => {
4576
- if (!values || values.length === 0) return /* @__PURE__ */ new Set();
4577
- return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
4578
- };
4579
- const buildResolvedControls = (surface, userControls) => {
4580
- const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
4581
- const includeTags = toStringSet(userControls?.includeTags);
4582
- for (const tag of includeTags) excludeTags.delete(tag);
4583
- for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
4584
- return {
4585
- includeTags,
4586
- excludeTags,
4587
- includeCategories: toStringSet(userControls?.includeCategories),
4588
- excludeCategories: toStringSet(userControls?.excludeCategories),
4589
- includeRuleKeys: toStringSet(userControls?.includeRules),
4590
- excludeRuleKeys: toStringSet(userControls?.excludeRules)
4591
- };
4592
- };
4593
- const intersects = (values, candidates) => values.some((value) => candidates.has(value));
4594
- const isDiagnosticOnSurface = (diagnostic, surface, config) => {
4595
- const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
4596
- const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
4597
- if (resolved.includeRuleKeys.has(ruleKey)) return true;
4598
- if (resolved.includeCategories.has(category)) return true;
4599
- if (intersects(tags, resolved.includeTags)) return true;
4600
- if (resolved.excludeRuleKeys.has(ruleKey)) return false;
4601
- if (resolved.excludeCategories.has(category)) return false;
4602
- if (intersects(tags, resolved.excludeTags)) return false;
4603
- return true;
4604
- };
4605
- const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
4606
- const listSourceFilesViaGit = (rootDirectory) => {
4607
- const result = spawnSync("git", [
4608
- "ls-files",
4609
- "-z",
4610
- "--cached",
4611
- "--others",
4612
- "--exclude-standard"
4613
- ], {
4614
- cwd: rootDirectory,
4615
- encoding: "utf-8",
4616
- maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
4617
- });
4618
- if (result.error || result.status !== 0) return null;
4619
- return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
4620
- };
4621
- const listSourceFilesViaFilesystem = (rootDirectory) => {
4622
- const filePaths = [];
4623
- const stack = [rootDirectory];
4624
- while (stack.length > 0) {
4625
- const currentDirectory = stack.pop();
4626
- const entries = readDirectoryEntries(currentDirectory);
4627
- for (const entry of entries) {
4628
- const absolutePath = path.join(currentDirectory, entry.name);
4629
- if (entry.isDirectory()) {
4630
- if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
4631
- continue;
4632
- }
4633
- if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
4634
- }
4635
- }
4636
- return filePaths;
4637
- };
4638
- const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
4639
- const resolveLintIncludePaths = (rootDirectory, userConfig) => {
4640
- if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
4641
- const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
4642
- return listSourceFiles(rootDirectory).filter((filePath) => {
4643
- if (!JSX_FILE_PATTERN.test(filePath)) return false;
4644
- return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
4645
- });
4646
- };
4647
- var Config = class Config extends Context.Service()("react-doctor/Config") {
4648
- static layerNode = Layer.effect(Config, Effect.gen(function* () {
4649
- const cache = yield* Cache.make({
4650
- capacity: 16,
4651
- timeToLive: CONFIG_CACHE_TTL_MS,
4652
- lookup: (directory) => Effect.sync(() => {
4653
- const loaded = loadConfigWithSource(directory);
4654
- const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
4655
- return {
4656
- config: loaded?.config ?? null,
4657
- resolvedDirectory: redirected ?? directory,
4658
- configSourceDirectory: loaded?.sourceDirectory ?? null
4659
- };
4660
- })
4661
- });
4662
- return Config.of({ resolve: Effect.fn("Config.resolve")(function* (directory) {
4663
- return yield* Cache.get(cache, directory);
4664
- }) });
4665
- }));
4666
- static layerOf = (resolved) => Layer.succeed(Config, Config.of({ resolve: () => Effect.succeed(resolved) }));
4667
- };
4668
5097
  const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
4669
5098
  const FALSY_VALUES = new Set([
4670
5099
  "false",
@@ -4746,6 +5175,30 @@ const collectIgnorePatterns = (rootDirectory) => {
4746
5175
  cachedPatternsByRoot.set(rootDirectory, patterns);
4747
5176
  return patterns;
4748
5177
  };
5178
+ /**
5179
+ * Resolves a path to its canonical, symlink-free form, falling back to
5180
+ * the input when it cannot be realpath'd (broken symlink, permission
5181
+ * error) so a best-effort normalization never throws.
5182
+ *
5183
+ * deslop's dead-code module graph is collected with `fast-glob` (which
5184
+ * keeps the scan root's symlinks intact) while imports are resolved
5185
+ * through `oxc-resolver` (which returns realpath'd targets). When the
5186
+ * project root sits behind a symlink — e.g. macOS iCloud-synced
5187
+ * `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
5188
+ * spaces diverge: every resolved import misses the graph and the files
5189
+ * they point at (commonly every `@/…` alias target) are mis-reported as
5190
+ * unreachable. Canonicalizing the root before the scan keeps both path
5191
+ * spaces in agreement.
5192
+ */
5193
+ const toCanonicalPath = (filePath) => {
5194
+ try {
5195
+ return fs.realpathSync(filePath);
5196
+ } catch {
5197
+ return filePath;
5198
+ }
5199
+ };
5200
+ const DEAD_CODE_PLUGIN = "deslop";
5201
+ const DEAD_CODE_CATEGORY = "Maintainability";
4749
5202
  const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
4750
5203
  const DEAD_CODE_WORKER_SCRIPT = `
4751
5204
  const inputChunks = [];
@@ -4921,7 +5374,11 @@ const buildDeadCodeWorkerError = (workerError) => {
4921
5374
  return error;
4922
5375
  };
4923
5376
  const createDeadCodeWorker = (input) => {
4924
- const child = spawn(process.execPath, ["-e", DEAD_CODE_WORKER_SCRIPT], {
5377
+ const child = spawn(process.execPath, [
5378
+ `--max-old-space-size=${DEAD_CODE_WORKER_MAX_OLD_SPACE_MB}`,
5379
+ "-e",
5380
+ DEAD_CODE_WORKER_SCRIPT
5381
+ ], {
4925
5382
  stdio: [
4926
5383
  "pipe",
4927
5384
  "pipe",
@@ -4996,7 +5453,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
4996
5453
  });
4997
5454
  });
4998
5455
  const checkDeadCode = async (options) => {
4999
- const { rootDirectory, userConfig } = options;
5456
+ const { userConfig } = options;
5457
+ const rootDirectory = toCanonicalPath(options.rootDirectory);
5000
5458
  if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
5001
5459
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
5002
5460
  const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
@@ -5009,59 +5467,162 @@ const checkDeadCode = async (options) => {
5009
5467
  const diagnostics = [];
5010
5468
  for (const unusedFile of result.unusedFiles) diagnostics.push({
5011
5469
  filePath: toRelative(unusedFile.path),
5012
- plugin: "deslop",
5470
+ plugin: DEAD_CODE_PLUGIN,
5013
5471
  rule: "unused-file",
5014
5472
  severity: "warning",
5015
5473
  message: "Unused file — not reachable from any entry point",
5016
5474
  help: "Delete the file if it is truly unreachable, or import it from an entry point.",
5017
5475
  line: 0,
5018
5476
  column: 0,
5019
- category: "Dead Code"
5477
+ category: DEAD_CODE_CATEGORY
5020
5478
  });
5021
5479
  for (const unusedExport of result.unusedExports) {
5022
5480
  const label = unusedExport.isTypeOnly ? "type export" : "export";
5023
5481
  diagnostics.push({
5024
5482
  filePath: toRelative(unusedExport.path),
5025
- plugin: "deslop",
5483
+ plugin: DEAD_CODE_PLUGIN,
5026
5484
  rule: unusedExport.isTypeOnly ? "unused-type" : "unused-export",
5027
5485
  severity: "warning",
5028
5486
  message: `Unused ${label}: \`${unusedExport.name}\``,
5029
5487
  help: "Drop the `export` keyword (or remove the declaration) if no other module uses this symbol.",
5030
5488
  line: unusedExport.line,
5031
5489
  column: unusedExport.column,
5032
- category: "Dead Code"
5490
+ category: DEAD_CODE_CATEGORY
5033
5491
  });
5034
5492
  }
5035
5493
  for (const unusedDependency of result.unusedDependencies) {
5036
5494
  const label = unusedDependency.isDevDependency ? "devDependency" : "dependency";
5037
5495
  diagnostics.push({
5038
5496
  filePath: "package.json",
5039
- plugin: "deslop",
5497
+ plugin: DEAD_CODE_PLUGIN,
5040
5498
  rule: unusedDependency.isDevDependency ? "unused-dev-dependency" : "unused-dependency",
5041
5499
  severity: "warning",
5042
5500
  message: `Unused ${label}: \`${unusedDependency.name}\``,
5043
5501
  help: "Remove the dependency from package.json if it is genuinely unused.",
5044
5502
  line: 0,
5045
5503
  column: 0,
5046
- category: "Dead Code"
5504
+ category: DEAD_CODE_CATEGORY
5047
5505
  });
5048
5506
  }
5049
5507
  for (const cycle of result.circularDependencies) {
5050
5508
  if (cycle.files.length === 0) continue;
5051
5509
  diagnostics.push({
5052
5510
  filePath: toRelative(cycle.files[0]),
5053
- plugin: "deslop",
5511
+ plugin: DEAD_CODE_PLUGIN,
5054
5512
  rule: "circular-dependency",
5055
5513
  severity: "warning",
5056
5514
  message: `Circular import cycle: ${cycle.files.map(toRelative).join(" → ")}`,
5057
5515
  help: "Break the cycle by extracting the shared code into a third module that both files import.",
5058
5516
  line: 0,
5059
5517
  column: 0,
5060
- category: "Dead Code"
5518
+ category: DEAD_CODE_CATEGORY
5061
5519
  });
5062
5520
  }
5063
5521
  return diagnostics;
5064
5522
  };
5523
+ const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
5524
+ const isSurfacingOverride = (override) => override === "warn" || override === "error";
5525
+ const deadCodeMaySurfaceWhenWarningsHidden = (userConfig) => {
5526
+ const severityControls = buildRuleSeverityControls(userConfig);
5527
+ if (!severityControls) return false;
5528
+ if (isSurfacingOverride(severityControls.categories?.["Maintainability"])) return true;
5529
+ for (const [ruleKey, override] of Object.entries(severityControls.rules ?? {})) if (ruleKey.startsWith(DEAD_CODE_RULE_KEY_PREFIX) && isSurfacingOverride(override)) return true;
5530
+ return false;
5531
+ };
5532
+ const toStringSet = (values) => {
5533
+ if (!values || values.length === 0) return /* @__PURE__ */ new Set();
5534
+ return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
5535
+ };
5536
+ const buildResolvedControls = (surface, userControls) => {
5537
+ const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
5538
+ const includeTags = toStringSet(userControls?.includeTags);
5539
+ for (const tag of includeTags) excludeTags.delete(tag);
5540
+ for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
5541
+ return {
5542
+ includeTags,
5543
+ excludeTags,
5544
+ includeCategories: toStringSet(userControls?.includeCategories),
5545
+ excludeCategories: toStringSet(userControls?.excludeCategories),
5546
+ includeRuleKeys: toStringSet(userControls?.includeRules),
5547
+ excludeRuleKeys: toStringSet(userControls?.excludeRules)
5548
+ };
5549
+ };
5550
+ const intersects = (values, candidates) => values.some((value) => candidates.has(value));
5551
+ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
5552
+ const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
5553
+ const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
5554
+ if (resolved.includeRuleKeys.has(ruleKey)) return true;
5555
+ if (resolved.includeCategories.has(category)) return true;
5556
+ if (intersects(tags, resolved.includeTags)) return true;
5557
+ if (resolved.excludeRuleKeys.has(ruleKey)) return false;
5558
+ if (resolved.excludeCategories.has(category)) return false;
5559
+ if (intersects(tags, resolved.excludeTags)) return false;
5560
+ return true;
5561
+ };
5562
+ const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
5563
+ const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(path.resolve(rootDirectory, relativePath)));
5564
+ const listSourceFilesViaGit = (rootDirectory) => {
5565
+ const result = spawnSync("git", [
5566
+ "ls-files",
5567
+ "-z",
5568
+ "--cached",
5569
+ "--others",
5570
+ "--exclude-standard"
5571
+ ], {
5572
+ cwd: rootDirectory,
5573
+ encoding: "utf-8",
5574
+ maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
5575
+ });
5576
+ if (result.error || result.status !== 0) return null;
5577
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
5578
+ };
5579
+ const listSourceFilesViaFilesystem = (rootDirectory) => {
5580
+ const filePaths = [];
5581
+ const stack = [rootDirectory];
5582
+ while (stack.length > 0) {
5583
+ const currentDirectory = stack.pop();
5584
+ const entries = readDirectoryEntries(currentDirectory);
5585
+ for (const entry of entries) {
5586
+ const absolutePath = path.join(currentDirectory, entry.name);
5587
+ if (entry.isDirectory()) {
5588
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
5589
+ continue;
5590
+ }
5591
+ if (entry.isFile() && isLintableSourceFile(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
5592
+ }
5593
+ }
5594
+ return filePaths;
5595
+ };
5596
+ const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
5597
+ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
5598
+ if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
5599
+ const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
5600
+ return listSourceFiles(rootDirectory).filter((filePath) => {
5601
+ if (!JSX_FILE_PATTERN.test(filePath)) return false;
5602
+ return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
5603
+ });
5604
+ };
5605
+ var Config = class Config extends Context.Service()("react-doctor/Config") {
5606
+ static layerNode = Layer.effect(Config, Effect.gen(function* () {
5607
+ const cache = yield* Cache.make({
5608
+ capacity: 16,
5609
+ timeToLive: CONFIG_CACHE_TTL_MS,
5610
+ lookup: (directory) => Effect.sync(() => {
5611
+ const loaded = loadConfigWithSource(directory);
5612
+ const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
5613
+ return {
5614
+ config: loaded?.config ?? null,
5615
+ resolvedDirectory: redirected ?? directory,
5616
+ configSourceDirectory: loaded?.sourceDirectory ?? null
5617
+ };
5618
+ })
5619
+ });
5620
+ return Config.of({ resolve: Effect.fn("Config.resolve")(function* (directory) {
5621
+ return yield* Cache.get(cache, directory);
5622
+ }) });
5623
+ }));
5624
+ static layerOf = (resolved) => Layer.succeed(Config, Config.of({ resolve: () => Effect.succeed(resolved) }));
5625
+ };
5065
5626
  /**
5066
5627
  * `DeadCode` runs whole-project reachability analysis and streams
5067
5628
  * diagnostics. Reachability is a whole-project property — the
@@ -5567,12 +6128,12 @@ const findFilesWithDisableDirectivesViaGit = async (rootDirectory, includePaths)
5567
6128
  return null;
5568
6129
  }
5569
6130
  if (grepResult === null) return null;
5570
- return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
6131
+ return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
5571
6132
  };
5572
6133
  const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
5573
6134
  const matches = [];
5574
6135
  const checkFile = (relativePath) => {
5575
- if (!SOURCE_FILE_PATTERN.test(relativePath)) return;
6136
+ if (!isLintableSourceFile(relativePath)) return;
5576
6137
  const absolutePath = path.join(rootDirectory, relativePath);
5577
6138
  let content;
5578
6139
  try {
@@ -5644,6 +6205,7 @@ const buildCapabilities = (project) => {
5644
6205
  const capabilities = /* @__PURE__ */ new Set();
5645
6206
  capabilities.add(project.framework);
5646
6207
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
6208
+ if (project.expoVersion !== null) capabilities.add("expo");
5647
6209
  const reactMajor = project.reactMajorVersion;
5648
6210
  if (reactMajor !== null) {
5649
6211
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -5815,10 +6377,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
5815
6377
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
5816
6378
  return fs.realpathSync(rootDirectory);
5817
6379
  };
6380
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
6381
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
6382
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
6383
+ };
5818
6384
  const applyRuleSeverityControls = (rules, severityControls) => {
5819
6385
  const enabledRules = {};
5820
6386
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
5821
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
6387
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
5822
6388
  if (severity === "off") continue;
5823
6389
  enabledRules[ruleKey] = severity;
5824
6390
  }
@@ -5860,7 +6426,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
5860
6426
  category: rule.category
5861
6427
  }, severityControls);
5862
6428
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
5863
- const severity = explicitSeverity ?? rule.severity;
6429
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
5864
6430
  if (severity === "off") continue;
5865
6431
  enabledReactDoctorRules[registryEntry.key] = severity;
5866
6432
  }
@@ -6032,7 +6598,7 @@ const KNOWN_SECRET_RULES = [
6032
6598
  }
6033
6599
  ];
6034
6600
  const CANDIDATE_TOKEN_PATTERN = /[A-Za-z0-9_][A-Za-z0-9_-]*/g;
6035
- const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6601
+ const HEX_DIGEST_PATTERN = /^(?:[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/;
6036
6602
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6037
6603
  const HAS_LETTER_PATTERN = /[A-Za-z]/;
6038
6604
  const HAS_DIGIT_PATTERN = /[0-9]/;
@@ -6049,7 +6615,7 @@ const shannonEntropyBits = (value) => {
6049
6615
  const looksLikeHighEntropySecret = (token) => {
6050
6616
  if (token.length < 32) return false;
6051
6617
  if (!HAS_LETTER_PATTERN.test(token) || !HAS_DIGIT_PATTERN.test(token)) return false;
6052
- if (GIT_OBJECT_ID_PATTERN.test(token) || UUID_PATTERN.test(token)) return false;
6618
+ if (HEX_DIGEST_PATTERN.test(token) || UUID_PATTERN.test(token)) return false;
6053
6619
  return shannonEntropyBits(token) >= 3;
6054
6620
  };
6055
6621
  const redactHighEntropyTokens = (text) => text.replace(CANDIDATE_TOKEN_PATTERN, (token) => looksLikeHighEntropySecret(token) ? REDACTED_PLACEHOLDER : token);
@@ -6376,25 +6942,26 @@ const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
6376
6942
  return bindingResolution !== null && !bindingResolution.isReactUseBinding;
6377
6943
  };
6378
6944
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
6379
- const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
6945
+ const REACT_COMPILER_TITLE = "React Compiler can't optimize this";
6946
+ const REACT_COMPILER_MESSAGE = "This component misses React Compiler's automatic memoization & re-renders more than it should. Rewrite the flagged code so the compiler can optimize it.";
6380
6947
  const PLUGIN_CATEGORY_MAP = {
6381
- react: "Correctness",
6382
- "react-hooks": "Correctness",
6383
- "react-hooks-js": "React Compiler",
6384
- "react-doctor": "Other",
6948
+ react: "Bugs",
6949
+ "react-hooks": "Bugs",
6950
+ "react-hooks-js": "Performance",
6951
+ "react-doctor": "Bugs",
6385
6952
  "jsx-a11y": "Accessibility",
6386
- effect: "State & Effects",
6387
- eslint: "Correctness",
6388
- oxc: "Correctness",
6389
- typescript: "Correctness",
6390
- unicorn: "Correctness",
6391
- import: "Bundle Size",
6392
- promise: "Correctness",
6393
- n: "Correctness",
6394
- node: "Correctness",
6395
- vitest: "Correctness",
6396
- jest: "Correctness",
6397
- nextjs: "Next.js"
6953
+ effect: "Bugs",
6954
+ eslint: "Bugs",
6955
+ oxc: "Bugs",
6956
+ typescript: "Bugs",
6957
+ unicorn: "Bugs",
6958
+ import: "Performance",
6959
+ promise: "Bugs",
6960
+ n: "Bugs",
6961
+ node: "Bugs",
6962
+ vitest: "Bugs",
6963
+ jest: "Bugs",
6964
+ nextjs: "Bugs"
6398
6965
  };
6399
6966
  const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
6400
6967
  const getRuleRecommendation = (ruleName, project) => {
@@ -6402,6 +6969,8 @@ const getRuleRecommendation = (ruleName, project) => {
6402
6969
  return reactDoctorPlugin.rules[ruleName]?.recommendation;
6403
6970
  };
6404
6971
  const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
6972
+ const getRuleTitle = (ruleName) => reactDoctorPlugin.rules[ruleName]?.title;
6973
+ const resolveDiagnosticTitle = (plugin, rule) => plugin === "react-hooks-js" ? REACT_COMPILER_TITLE : getRuleTitle(rule);
6405
6974
  const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
6406
6975
  const cleaned = resolveCleanedDiagnostic(typeof message === "string" ? message : "", typeof help === "string" ? help : "", plugin, rule, project);
6407
6976
  return {
@@ -6430,7 +6999,7 @@ const parseRuleCode = (code) => {
6430
6999
  rule: match[2]
6431
7000
  };
6432
7001
  };
6433
- const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
7002
+ const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Bugs";
6434
7003
  const isOxlintOutput = (value) => {
6435
7004
  if (typeof value !== "object" || value === null) return false;
6436
7005
  const candidate = value;
@@ -6454,7 +7023,16 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
6454
7023
  throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
6455
7024
  }
6456
7025
  if (!isOxlintOutput(parsed)) throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
6457
- return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
7026
+ const minifiedFileCache = /* @__PURE__ */ new Map();
7027
+ const isMinifiedDiagnosticFile = (filename) => {
7028
+ const absolutePath = path.isAbsolute(filename) ? filename : path.resolve(rootDirectory || ".", filename);
7029
+ const cached = minifiedFileCache.get(absolutePath);
7030
+ if (cached !== void 0) return cached;
7031
+ const minified = isMinifiedSource(absolutePath);
7032
+ minifiedFileCache.set(absolutePath, minified);
7033
+ return minified;
7034
+ };
7035
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && isLintableSourceFile(diagnostic.filename) && !isMinifiedDiagnosticFile(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
6458
7036
  const { plugin, rule } = parseRuleCode(diagnostic.code);
6459
7037
  const primaryLabel = diagnostic.labels[0];
6460
7038
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule, project);
@@ -6463,6 +7041,7 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
6463
7041
  plugin,
6464
7042
  rule,
6465
7043
  severity: diagnostic.severity,
7044
+ title: resolveDiagnosticTitle(plugin, rule),
6466
7045
  message: cleaned.message,
6467
7046
  help: cleaned.help,
6468
7047
  url: diagnostic.url,
@@ -6880,7 +7459,8 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
6880
7459
  static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
6881
7460
  update: () => Effect.void,
6882
7461
  succeed: () => Effect.void,
6883
- fail: () => Effect.void
7462
+ fail: () => Effect.void,
7463
+ stop: () => Effect.void
6884
7464
  }) }));
6885
7465
  static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
6886
7466
  yield* Ref.update(events, (existing) => [...existing, {
@@ -6899,6 +7479,10 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
6899
7479
  fail: (displayText) => Ref.update(events, (existing) => [...existing, {
6900
7480
  _tag: "Failed",
6901
7481
  text: displayText
7482
+ }]),
7483
+ stop: () => Ref.update(events, (existing) => [...existing, {
7484
+ _tag: "Stopped",
7485
+ text
6902
7486
  }])
6903
7487
  };
6904
7488
  }) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
@@ -7148,18 +7732,25 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7148
7732
  repo
7149
7733
  }).pipe(Effect.orElseSucceed(() => null)) : Effect.succeed(null));
7150
7734
  const lintIncludePaths = computeJsxIncludePaths([...input.includePaths]) ?? resolveLintIncludePaths(scanDirectory, resolvedConfig.config);
7735
+ const scannedFilePaths = input.suppressScanSummary ? (lintIncludePaths ?? (yield* filesService.listSourceFiles(scanDirectory))).map((relativePath) => path.resolve(scanDirectory, relativePath)) : [];
7151
7736
  const beforeLint = hooks.beforeLint ?? NO_HOOKS.beforeLint;
7152
7737
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
7153
7738
  yield* beforeLint(project, lintIncludePaths ?? void 0);
7154
7739
  const isDiffMode = input.includePaths.length > 0;
7740
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
7155
7741
  const transform = buildDiagnosticPipeline({
7156
7742
  rootDirectory: scanDirectory,
7157
7743
  userConfig: resolvedConfig.config,
7158
7744
  readFileLinesSync: fileReader(filesService, scanDirectory),
7159
- respectInlineDisables: input.respectInlineDisables
7745
+ respectInlineDisables: input.respectInlineDisables,
7746
+ showWarnings
7160
7747
  });
7161
7748
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
7162
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
7749
+ const environmentDiagnostics = isDiffMode ? [] : [
7750
+ ...checkReducedMotion(scanDirectory),
7751
+ ...checkPnpmHardening(scanDirectory),
7752
+ ...checkExpoProject(scanDirectory, project)
7753
+ ];
7163
7754
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7164
7755
  const lintFailure = yield* Ref.make({
7165
7756
  didFail: false,
@@ -7202,7 +7793,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7202
7793
  const lintFailureState = yield* Ref.get(lintFailure);
7203
7794
  yield* afterLint(lintFailureState.didFail);
7204
7795
  if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
7205
- const shouldRunDeadCode = input.runDeadCode && !isDiffMode;
7796
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
7206
7797
  const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update("Analyzing dead code...").pipe(Effect.andThen(Stream.runCollect(applyPerElementPipeline(deadCodeService.run({
7207
7798
  rootDirectory: scanDirectory,
7208
7799
  userConfig: resolvedConfig.config
@@ -7214,9 +7805,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7214
7805
  return Stream.empty;
7215
7806
  }))))))));
7216
7807
  const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
7217
- const scanElapsedSeconds = ((Date.now() - scanStartTime) / 1e3).toFixed(1);
7808
+ const scanElapsedMilliseconds = Date.now() - scanStartTime;
7809
+ const scanElapsedSeconds = (scanElapsedMilliseconds / 1e3).toFixed(1);
7218
7810
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
7219
7811
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
7812
+ else if (input.suppressScanSummary) yield* scanProgress.stop();
7220
7813
  else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
7221
7814
  yield* reporterService.finalize;
7222
7815
  const finalDiagnostics = [
@@ -7257,7 +7850,10 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7257
7850
  lintFailureReasonKind: lintFailureState.reasonKind,
7258
7851
  lintPartialFailures,
7259
7852
  didDeadCodeFail: deadCodeFailureState.didFail,
7260
- deadCodeFailureReason: deadCodeFailureState.reason
7853
+ deadCodeFailureReason: deadCodeFailureState.reason,
7854
+ scannedFileCount: totalFileCount,
7855
+ scannedFilePaths,
7856
+ scanElapsedMilliseconds
7261
7857
  };
7262
7858
  }).pipe(Effect.withSpan("runInspect", { attributes: {
7263
7859
  "inspect.directory": input.directory,
@@ -7383,7 +7979,7 @@ const isPathInsideDirectory = (childAbsolutePath, parentAbsolutePath) => {
7383
7979
  static layerNode = Layer.effect(StagedFiles, Effect.gen(function* () {
7384
7980
  const git = yield* Git;
7385
7981
  return StagedFiles.of({
7386
- discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter((entry) => SOURCE_FILE_PATTERN.test(entry)))),
7982
+ discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter(isLintableSourceFile))),
7387
7983
  materialize: ({ directory, stagedFiles, tempDirectory }) => Effect.gen(function* () {
7388
7984
  const materializedFiles = [];
7389
7985
  const resolvedTempDirectory = path.resolve(tempDirectory);
@@ -7592,7 +8188,7 @@ const getDiffInfo = (directory, explicitBaseBranch) => Effect.runPromise(Effect.
7592
8188
  GitBaseBranchInvalid: (reason) => Effect.die(new Error(reason.detail)),
7593
8189
  GitBaseBranchMissing: (reason) => Effect.die(new Error(reason.message))
7594
8190
  })));
7595
- const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
8191
+ const filterSourceFiles = (filePaths) => filePaths.filter(isLintableSourceFile);
7596
8192
  var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
7597
8193
  let p = process || {}, argv = p.argv || [], env = p.env || {};
7598
8194
  let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
@@ -7679,6 +8275,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7679
8275
  includePaths,
7680
8276
  customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
7681
8277
  respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
8278
+ warnings: options.warnings ?? effectiveConfig?.warnings ?? false,
7682
8279
  adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
7683
8280
  ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
7684
8281
  runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
@@ -7716,6 +8313,7 @@ const clearCaches = () => {
7716
8313
  clearConfigCache();
7717
8314
  clearPackageJsonCache();
7718
8315
  clearIgnorePatternsCache();
8316
+ clearPackageRoleCache();
7719
8317
  clearAutoSuppressionCaches();
7720
8318
  };
7721
8319
  const toJsonReport = (result, options) => buildJsonReport({