react-doctor 0.2.14-dev.9fc6f62 → 0.2.14-dev.b3c3aa9

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
@@ -14,6 +14,7 @@ import * as Redacted from "effect/Redacted";
14
14
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
15
15
  import * as Otlp from "effect/unstable/observability/Otlp";
16
16
  import * as Context from "effect/Context";
17
+ import os from "node:os";
17
18
  import * as Console from "effect/Console";
18
19
  import * as Fiber from "effect/Fiber";
19
20
  import * as Filter from "effect/Filter";
@@ -26,7 +27,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
26
27
  import * as NodePath from "@effect/platform-node-shared/NodePath";
27
28
  import * as ChildProcess from "effect/unstable/process/ChildProcess";
28
29
  import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
29
- import os from "node:os";
30
30
  import * as ts from "typescript";
31
31
  import { gzipSync } from "node:zlib";
32
32
  //#region \0rolldown/runtime.js
@@ -2874,29 +2874,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
2874
2874
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
2875
2875
  };
2876
2876
  };
2877
- const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
2878
- if (predicate(rootPackageJson)) return true;
2877
+ const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
2878
+ const rootValue = select(rootPackageJson);
2879
+ if (rootValue !== null) return rootValue;
2879
2880
  const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
2880
- if (patterns.length === 0) return false;
2881
+ if (patterns.length === 0) return null;
2881
2882
  const visitedDirectories = /* @__PURE__ */ new Set();
2882
2883
  for (const pattern of patterns) {
2883
- const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
2884
+ const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
2884
2885
  for (const workspaceDirectory of directories) {
2885
2886
  if (visitedDirectories.has(workspaceDirectory)) continue;
2886
2887
  visitedDirectories.add(workspaceDirectory);
2887
- 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;
2888
2890
  }
2889
2891
  }
2890
- return false;
2892
+ return null;
2891
2893
  };
2894
+ const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
2892
2895
  const NAMES = new Set([
2893
2896
  "react-native",
2894
2897
  "react-native-tvos",
2895
- "expo",
2896
- "expo-router",
2897
- "@expo/cli",
2898
- "@expo/metro-config",
2899
- "@expo/metro-runtime",
2898
+ ...new Set([
2899
+ "expo",
2900
+ "expo-router",
2901
+ "@expo/cli",
2902
+ "@expo/metro-config",
2903
+ "@expo/metro-runtime"
2904
+ ]),
2900
2905
  "react-native-windows",
2901
2906
  "react-native-macos"
2902
2907
  ]);
@@ -2920,6 +2925,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
2920
2925
  return false;
2921
2926
  };
2922
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);
2923
2933
  const getPreactVersion = (packageJson) => {
2924
2934
  return {
2925
2935
  ...packageJson.peerDependencies,
@@ -3159,6 +3169,19 @@ const discoverProject = (directory) => {
3159
3169
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
3160
3170
  const sourceFileCount = countSourceFiles(directory);
3161
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
+ }
3162
3185
  const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
3163
3186
  const preactVersion = getPreactVersion(packageJson);
3164
3187
  const projectInfo = {
@@ -3176,6 +3199,7 @@ const discoverProject = (directory) => {
3176
3199
  preactVersion,
3177
3200
  preactMajorVersion: parseReactMajor(preactVersion),
3178
3201
  hasReactNativeWorkspace,
3202
+ expoVersion,
3179
3203
  hasReanimated,
3180
3204
  sourceFileCount
3181
3205
  };
@@ -3280,6 +3304,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
3280
3304
  "Accessibility",
3281
3305
  "Maintainability"
3282
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"]);
3283
3310
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
3284
3311
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
3285
3312
  var InvalidGlobPatternError = class extends Error {
@@ -3399,10 +3426,11 @@ const restampSeverity = (diagnostic, override) => {
3399
3426
  */
3400
3427
  const buildRuleSeverityControls = (config) => {
3401
3428
  if (!config) return void 0;
3402
- 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;
3403
3430
  return {
3404
3431
  ...config.rules !== void 0 ? { rules: config.rules } : {},
3405
- ...config.categories !== void 0 ? { categories: config.categories } : {}
3432
+ ...config.categories !== void 0 ? { categories: config.categories } : {},
3433
+ ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
3406
3434
  };
3407
3435
  };
3408
3436
  const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
@@ -3766,6 +3794,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
3766
3794
  }
3767
3795
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
3768
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
+ };
3769
3860
  /**
3770
3861
  * Resolves the absolute path to read for a diagnostic's `filePath`,
3771
3862
  * accounting for the various shapes oxlint emits:
@@ -3928,6 +4019,15 @@ const buildDiagnosticPipeline = (input) => {
3928
4019
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
3929
4020
  const fileLinesCache = /* @__PURE__ */ new Map();
3930
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
+ };
3931
4031
  const getFileLines = (filePath) => {
3932
4032
  const cached = fileLinesCache.get(filePath);
3933
4033
  if (cached !== void 0) return cached;
@@ -3954,6 +4054,10 @@ const buildDiagnosticPipeline = (input) => {
3954
4054
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
3955
4055
  return false;
3956
4056
  };
4057
+ const isAppOnlyRule = (ruleIdentifier) => {
4058
+ for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
4059
+ return false;
4060
+ };
3957
4061
  const isRnRawTextSuppressedByConfig = (diagnostic) => {
3958
4062
  if (diagnostic.rule !== "rn-no-raw-text") return false;
3959
4063
  if (diagnostic.line <= 0) return false;
@@ -3968,8 +4072,10 @@ const buildDiagnosticPipeline = (input) => {
3968
4072
  if (shouldAutoSuppress(diagnostic)) return null;
3969
4073
  let current = diagnostic;
3970
4074
  let explicitSeverityOverride;
4075
+ let explicitRuleOverride;
3971
4076
  if (severityControls) {
3972
4077
  const { ruleKey, category } = getDiagnosticRuleIdentity(current);
4078
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
3973
4079
  explicitSeverityOverride = resolveRuleSeverityOverride({
3974
4080
  ruleKey,
3975
4081
  category
@@ -3977,6 +4083,9 @@ const buildDiagnosticPipeline = (input) => {
3977
4083
  if (explicitSeverityOverride === "off") return null;
3978
4084
  if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
3979
4085
  }
4086
+ if (explicitRuleOverride === void 0) {
4087
+ if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
4088
+ }
3980
4089
  if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
3981
4090
  if (userConfig) {
3982
4091
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
@@ -4162,6 +4271,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
4162
4271
  }).pipe(Layer.provide(FetchHttpClient.layer));
4163
4272
  }).pipe(Effect.orDie));
4164
4273
  /**
4274
+ * Resolves a requested lint worker count to a clamped integer within
4275
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
4276
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
4277
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
4278
+ */
4279
+ const resolveScanConcurrency = (requested) => {
4280
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
4281
+ if (!Number.isFinite(desired) || desired < 1) return 1;
4282
+ return Math.max(1, Math.min(Math.floor(desired), 16));
4283
+ };
4284
+ /**
4165
4285
  * Per-batch oxlint wall-clock budget. Reads from the env var on
4166
4286
  * startup so the eval harness can raise the budget under sandbox
4167
4287
  * microVMs without recompiling react-doctor. Tests override via
@@ -4181,6 +4301,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
4181
4301
  * tests that exercise the cap behavior.
4182
4302
  */
4183
4303
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
4304
+ /**
4305
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
4306
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
4307
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
4308
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
4309
+ * CI callers that never touch the flag:
4310
+ *
4311
+ * - unset / `0` / `false` / `off` → `1` (serial)
4312
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
4313
+ * - a positive integer → that many workers (clamped)
4314
+ *
4315
+ * The resolved value is always within
4316
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
4317
+ */
4318
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
4319
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
4320
+ if (raw === void 0) return 1;
4321
+ const normalized = raw.trim().toLowerCase();
4322
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
4323
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
4324
+ const parsed = Number.parseInt(normalized, 10);
4325
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
4326
+ return resolveScanConcurrency(parsed);
4327
+ } }) {};
4184
4328
  const DIAGNOSTIC_SURFACES = [
4185
4329
  "cli",
4186
4330
  "prComment",
@@ -4341,16 +4485,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
4341
4485
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
4342
4486
  const loadConfigFromDirectory = (directory) => {
4343
4487
  const configFilePath = path.join(directory, CONFIG_FILENAME);
4344
- if (isFile(configFilePath)) try {
4345
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
4346
- const parsed = JSON.parse(fileContent);
4347
- if (isPlainObject(parsed)) return {
4348
- config: validateConfigTypes(parsed),
4349
- sourceDirectory: directory
4350
- };
4351
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4352
- } catch (error) {
4353
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
4488
+ let sawBrokenConfigFile = false;
4489
+ if (isFile(configFilePath)) {
4490
+ try {
4491
+ const fileContent = fs.readFileSync(configFilePath, "utf-8");
4492
+ const parsed = JSON.parse(fileContent);
4493
+ if (isPlainObject(parsed)) return {
4494
+ status: "found",
4495
+ loaded: {
4496
+ config: validateConfigTypes(parsed),
4497
+ sourceDirectory: directory
4498
+ }
4499
+ };
4500
+ warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4501
+ } catch (error) {
4502
+ warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
4503
+ }
4504
+ sawBrokenConfigFile = true;
4354
4505
  }
4355
4506
  const packageJsonPath = path.join(directory, "package.json");
4356
4507
  if (isFile(packageJsonPath)) try {
@@ -4359,14 +4510,18 @@ const loadConfigFromDirectory = (directory) => {
4359
4510
  if (isPlainObject(packageJson)) {
4360
4511
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4361
4512
  if (isPlainObject(embeddedConfig)) return {
4362
- config: validateConfigTypes(embeddedConfig),
4363
- sourceDirectory: directory
4513
+ status: "found",
4514
+ loaded: {
4515
+ config: validateConfigTypes(embeddedConfig),
4516
+ sourceDirectory: directory
4517
+ }
4364
4518
  };
4365
4519
  }
4366
- } catch {
4367
- return null;
4368
- }
4369
- return null;
4520
+ } catch {}
4521
+ return {
4522
+ status: sawBrokenConfigFile ? "invalid" : "absent",
4523
+ loaded: null
4524
+ };
4370
4525
  };
4371
4526
  const cachedConfigs = /* @__PURE__ */ new Map();
4372
4527
  const clearConfigCache = () => {
@@ -4375,21 +4530,21 @@ const clearConfigCache = () => {
4375
4530
  const loadConfigWithSource = (rootDirectory) => {
4376
4531
  const cached = cachedConfigs.get(rootDirectory);
4377
4532
  if (cached !== void 0) return cached;
4378
- const localConfig = loadConfigFromDirectory(rootDirectory);
4379
- if (localConfig) {
4380
- cachedConfigs.set(rootDirectory, localConfig);
4381
- return localConfig;
4533
+ const localResult = loadConfigFromDirectory(rootDirectory);
4534
+ if (localResult.status === "found") {
4535
+ cachedConfigs.set(rootDirectory, localResult.loaded);
4536
+ return localResult.loaded;
4382
4537
  }
4383
- if (isProjectBoundary(rootDirectory)) {
4538
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
4384
4539
  cachedConfigs.set(rootDirectory, null);
4385
4540
  return null;
4386
4541
  }
4387
4542
  let ancestorDirectory = path.dirname(rootDirectory);
4388
4543
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4389
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4390
- if (ancestorConfig) {
4391
- cachedConfigs.set(rootDirectory, ancestorConfig);
4392
- return ancestorConfig;
4544
+ const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
4545
+ if (ancestorResult.status === "found") {
4546
+ cachedConfigs.set(rootDirectory, ancestorResult.loaded);
4547
+ return ancestorResult.loaded;
4393
4548
  }
4394
4549
  if (isProjectBoundary(ancestorDirectory)) {
4395
4550
  cachedConfigs.set(rootDirectory, null);
@@ -4414,11 +4569,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
4414
4569
  }
4415
4570
  return resolvedRootDir;
4416
4571
  };
4417
- const resolveDiagnoseTarget = (directory) => {
4572
+ const resolveDiagnoseTarget = (directory, options = {}) => {
4418
4573
  if (isFile(path.join(directory, "package.json"))) return directory;
4419
4574
  const reactSubprojects = discoverReactSubprojects(directory);
4420
4575
  if (reactSubprojects.length === 0) return null;
4421
4576
  if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
4577
+ if (options.allowAmbiguous === true) return null;
4422
4578
  throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
4423
4579
  };
4424
4580
  /**
@@ -4432,7 +4588,8 @@ const resolveDiagnoseTarget = (directory) => {
4432
4588
  * project root, if configured.
4433
4589
  * 4. Walk into a nested React subproject when the requested
4434
4590
  * directory has no `package.json` of its own (raises
4435
- * `AmbiguousProjectError` when multiple candidates exist).
4591
+ * `AmbiguousProjectError` when multiple candidates exist unless
4592
+ * the caller opts into keeping the wrapper directory).
4436
4593
  *
4437
4594
  * Throws `ProjectNotFoundError` when neither the requested directory
4438
4595
  * nor any discoverable nested project has a `package.json`.
@@ -4444,14 +4601,14 @@ const resolveDiagnoseTarget = (directory) => {
4444
4601
  * via its own cache). Routing through `resolveScanTarget` keeps every
4445
4602
  * shell in agreement on what "the scan directory" means.
4446
4603
  */
4447
- const resolveScanTarget = (requestedDirectory) => {
4604
+ const resolveScanTarget = (requestedDirectory, options = {}) => {
4448
4605
  const absoluteRequested = path.resolve(requestedDirectory);
4449
4606
  const loadedConfig = loadConfigWithSource(absoluteRequested);
4450
4607
  const userConfig = loadedConfig?.config ?? null;
4451
4608
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4452
4609
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
4453
4610
  const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
4454
- const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
4611
+ const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
4455
4612
  if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
4456
4613
  return {
4457
4614
  resolvedDirectory,
@@ -4461,6 +4618,359 @@ const resolveScanTarget = (requestedDirectory) => {
4461
4618
  didRedirectViaRootDir: redirectedDirectory !== null
4462
4619
  };
4463
4620
  };
4621
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
4622
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
4623
+ const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
4624
+ return {
4625
+ rootDirectory,
4626
+ packageJson,
4627
+ directDependencyNames: getDirectDependencyNames(packageJson),
4628
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
4629
+ };
4630
+ };
4631
+ const buildExpoDiagnostic = (input) => ({
4632
+ filePath: input.filePath ?? "package.json",
4633
+ plugin: "react-doctor",
4634
+ rule: input.rule,
4635
+ severity: input.severity ?? "warning",
4636
+ message: input.message,
4637
+ help: input.help,
4638
+ line: input.line ?? 0,
4639
+ column: input.column ?? 0,
4640
+ category: input.category ?? "Correctness"
4641
+ });
4642
+ const CRITICAL_OVERRIDE_NAMES = new Set([
4643
+ "@expo/cli",
4644
+ "@expo/config",
4645
+ "@expo/metro-config",
4646
+ "@expo/metro-runtime",
4647
+ "@expo/metro",
4648
+ "metro"
4649
+ ]);
4650
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
4651
+ const collectOverrideNames = (packageJson) => new Set([
4652
+ ...Object.keys(packageJson.overrides ?? {}),
4653
+ ...Object.keys(packageJson.resolutions ?? {}),
4654
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
4655
+ ]);
4656
+ const checkExpoDependencyOverrides = (context) => {
4657
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
4658
+ if (overriddenCriticalNames.length === 0) return [];
4659
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
4660
+ return [buildExpoDiagnostic({
4661
+ rule: "expo-no-conflicting-dependency-override",
4662
+ 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`,
4663
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
4664
+ })];
4665
+ };
4666
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
4667
+ const result = spawnSync("git", [
4668
+ "check-ignore",
4669
+ "-q",
4670
+ absolutePath
4671
+ ], {
4672
+ cwd: rootDirectory,
4673
+ stdio: [
4674
+ "ignore",
4675
+ "ignore",
4676
+ "ignore"
4677
+ ]
4678
+ });
4679
+ if (result.error) return null;
4680
+ if (result.status === 0) return true;
4681
+ if (result.status === 1) return false;
4682
+ return null;
4683
+ };
4684
+ const LOCAL_ENV_FILE_NAMES = [
4685
+ ".env.local",
4686
+ ".env.development.local",
4687
+ ".env.production.local",
4688
+ ".env.test.local"
4689
+ ];
4690
+ const checkExpoEnvLocalFiles = (context) => {
4691
+ const { rootDirectory } = context;
4692
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
4693
+ const filePath = path.join(rootDirectory, fileName);
4694
+ if (!isFile(filePath)) return false;
4695
+ return isPathGitIgnored(rootDirectory, filePath) === false;
4696
+ });
4697
+ if (committedEnvFiles.length === 0) return [];
4698
+ return [buildExpoDiagnostic({
4699
+ rule: "expo-env-local-not-gitignored",
4700
+ category: "Security",
4701
+ 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`,
4702
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
4703
+ })];
4704
+ };
4705
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
4706
+ 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";
4707
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
4708
+ const unimodulesEntry = (packageName) => ({
4709
+ packageName,
4710
+ rule: "expo-no-unimodules-packages",
4711
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
4712
+ help: UNIMODULES_HELP
4713
+ });
4714
+ const FLAGGED_DEPENDENCIES = [
4715
+ unimodulesEntry("@unimodules/core"),
4716
+ unimodulesEntry("@unimodules/react-native-adapter"),
4717
+ unimodulesEntry("react-native-unimodules"),
4718
+ {
4719
+ packageName: "expo-cli",
4720
+ rule: "expo-no-cli-dependencies",
4721
+ 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`",
4722
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
4723
+ },
4724
+ {
4725
+ packageName: "eas-cli",
4726
+ rule: "expo-no-cli-dependencies",
4727
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
4728
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
4729
+ },
4730
+ {
4731
+ packageName: "expo-modules-autolinking",
4732
+ rule: "expo-no-redundant-dependency",
4733
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
4734
+ help: "Remove `expo-modules-autolinking` from your package.json"
4735
+ },
4736
+ {
4737
+ packageName: "expo-dev-launcher",
4738
+ rule: "expo-no-redundant-dependency",
4739
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4740
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
4741
+ },
4742
+ {
4743
+ packageName: "expo-dev-menu",
4744
+ rule: "expo-no-redundant-dependency",
4745
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4746
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
4747
+ },
4748
+ {
4749
+ packageName: "expo-modules-core",
4750
+ rule: "expo-no-redundant-dependency",
4751
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
4752
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
4753
+ },
4754
+ {
4755
+ packageName: "@expo/metro-config",
4756
+ rule: "expo-no-redundant-dependency",
4757
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
4758
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
4759
+ },
4760
+ {
4761
+ packageName: "@types/react-native",
4762
+ rule: "expo-no-redundant-dependency",
4763
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
4764
+ help: "Remove `@types/react-native` from your package.json",
4765
+ minSdkMajor: 48
4766
+ },
4767
+ {
4768
+ packageName: "@expo/config-plugins",
4769
+ rule: "expo-no-redundant-dependency",
4770
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
4771
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
4772
+ minSdkMajor: 48
4773
+ },
4774
+ {
4775
+ packageName: "@expo/prebuild-config",
4776
+ rule: "expo-no-redundant-dependency",
4777
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
4778
+ help: "Remove `@expo/prebuild-config` from your package.json",
4779
+ minSdkMajor: 53
4780
+ },
4781
+ {
4782
+ packageName: "expo-permissions",
4783
+ rule: "expo-no-redundant-dependency",
4784
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
4785
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
4786
+ minSdkMajor: 50
4787
+ },
4788
+ {
4789
+ packageName: "expo-app-loading",
4790
+ rule: "expo-no-redundant-dependency",
4791
+ message: "\"expo-app-loading\" was removed in SDK 49",
4792
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
4793
+ minSdkMajor: 49
4794
+ },
4795
+ {
4796
+ packageName: "expo-firebase-analytics",
4797
+ rule: "expo-no-redundant-dependency",
4798
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
4799
+ help: FIREBASE_HELP,
4800
+ minSdkMajor: 48
4801
+ },
4802
+ {
4803
+ packageName: "expo-firebase-recaptcha",
4804
+ rule: "expo-no-redundant-dependency",
4805
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
4806
+ help: FIREBASE_HELP,
4807
+ minSdkMajor: 48
4808
+ },
4809
+ {
4810
+ packageName: "expo-firebase-core",
4811
+ rule: "expo-no-redundant-dependency",
4812
+ message: "\"expo-firebase-core\" was removed in SDK 48",
4813
+ help: FIREBASE_HELP,
4814
+ minSdkMajor: 48
4815
+ }
4816
+ ];
4817
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
4818
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
4819
+ if (flaggedDependency.minSdkMajor === void 0) return true;
4820
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
4821
+ }).map((flaggedDependency) => buildExpoDiagnostic({
4822
+ rule: flaggedDependency.rule,
4823
+ message: flaggedDependency.message,
4824
+ help: flaggedDependency.help
4825
+ }));
4826
+ const findLocalModuleNativeFiles = (rootDirectory) => {
4827
+ const modulesDirectory = path.join(rootDirectory, "modules");
4828
+ if (!isDirectory(modulesDirectory)) return [];
4829
+ const nativeFilePaths = [];
4830
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
4831
+ if (!moduleEntry.isDirectory()) continue;
4832
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
4833
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
4834
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
4835
+ const iosDirectory = path.join(moduleDirectory, "ios");
4836
+ if (isDirectory(iosDirectory)) {
4837
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
4838
+ }
4839
+ }
4840
+ return nativeFilePaths;
4841
+ };
4842
+ const checkExpoGitignore = (context) => {
4843
+ const { rootDirectory } = context;
4844
+ const diagnostics = [];
4845
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
4846
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
4847
+ rule: "expo-gitignore",
4848
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
4849
+ help: "Add `.expo/` to your .gitignore"
4850
+ }));
4851
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
4852
+ rule: "expo-gitignore",
4853
+ 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",
4854
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
4855
+ }));
4856
+ return diagnostics;
4857
+ };
4858
+ const LOCKFILE_NAMES = [
4859
+ "pnpm-lock.yaml",
4860
+ "yarn.lock",
4861
+ "package-lock.json",
4862
+ "bun.lockb",
4863
+ "bun.lock"
4864
+ ];
4865
+ const checkExpoLockfile = (context) => {
4866
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
4867
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
4868
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
4869
+ rule: "expo-lockfile",
4870
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
4871
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
4872
+ })];
4873
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
4874
+ rule: "expo-lockfile",
4875
+ 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`,
4876
+ help: "Delete the lock files for the package managers you are not using and keep only one"
4877
+ })];
4878
+ return [];
4879
+ };
4880
+ const METRO_CONFIG_FILE_NAMES = [
4881
+ "metro.config.js",
4882
+ "metro.config.cjs",
4883
+ "metro.config.mjs",
4884
+ "metro.config.ts"
4885
+ ];
4886
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
4887
+ "expo/metro-config",
4888
+ "@sentry/react-native/metro",
4889
+ "getSentryExpoConfig"
4890
+ ];
4891
+ const checkExpoMetroConfig = (context) => {
4892
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
4893
+ if (metroConfigPath === void 0) return [];
4894
+ let contents;
4895
+ try {
4896
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
4897
+ } catch {
4898
+ return [];
4899
+ }
4900
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
4901
+ return [buildExpoDiagnostic({
4902
+ rule: "expo-metro-config",
4903
+ filePath: path.basename(metroConfigPath),
4904
+ 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",
4905
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
4906
+ })];
4907
+ };
4908
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
4909
+ const checkExpoPackageJsonConflicts = (context) => {
4910
+ const { packageJson } = context;
4911
+ const diagnostics = [];
4912
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
4913
+ if (conflictingScriptNames.length > 0) {
4914
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
4915
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
4916
+ diagnostics.push(buildExpoDiagnostic({
4917
+ rule: "expo-package-json-conflict",
4918
+ 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" : ""}`,
4919
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
4920
+ }));
4921
+ }
4922
+ const packageName = packageJson.name;
4923
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
4924
+ rule: "expo-package-json-conflict",
4925
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
4926
+ help: "Rename your package so it no longer matches one of its dependencies"
4927
+ }));
4928
+ return diagnostics;
4929
+ };
4930
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
4931
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
4932
+ const checkExpoRouterReactNavigation = (context) => {
4933
+ const { expoSdkMajor } = context;
4934
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
4935
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
4936
+ if (!context.directDependencyNames.has("expo-router")) return [];
4937
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
4938
+ if (reactNavigationNames.length === 0) return [];
4939
+ return [buildExpoDiagnostic({
4940
+ rule: "expo-router-no-react-navigation",
4941
+ 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"}`,
4942
+ 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/"
4943
+ })];
4944
+ };
4945
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
4946
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
4947
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
4948
+ const checkExpoVectorIcons = (context) => {
4949
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
4950
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
4951
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
4952
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
4953
+ return [buildExpoDiagnostic({
4954
+ rule: "expo-vector-icons-conflict",
4955
+ 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",
4956
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
4957
+ })];
4958
+ };
4959
+ const checkExpoProject = (rootDirectory, project) => {
4960
+ if (project.expoVersion === null) return [];
4961
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
4962
+ return [
4963
+ ...checkExpoFlaggedDependencies(context),
4964
+ ...checkExpoDependencyOverrides(context),
4965
+ ...checkExpoRouterReactNavigation(context),
4966
+ ...checkExpoVectorIcons(context),
4967
+ ...checkExpoPackageJsonConflicts(context),
4968
+ ...checkExpoLockfile(context),
4969
+ ...checkExpoGitignore(context),
4970
+ ...checkExpoEnvLocalFiles(context),
4971
+ ...checkExpoMetroConfig(context)
4972
+ ];
4973
+ };
4464
4974
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4465
4975
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
4466
4976
  const PACKAGE_JSON_FILE = "package.json";
@@ -4711,6 +5221,28 @@ const collectIgnorePatterns = (rootDirectory) => {
4711
5221
  cachedPatternsByRoot.set(rootDirectory, patterns);
4712
5222
  return patterns;
4713
5223
  };
5224
+ /**
5225
+ * Resolves a path to its canonical, symlink-free form, falling back to
5226
+ * the input when it cannot be realpath'd (broken symlink, permission
5227
+ * error) so a best-effort normalization never throws.
5228
+ *
5229
+ * deslop's dead-code module graph is collected with `fast-glob` (which
5230
+ * keeps the scan root's symlinks intact) while imports are resolved
5231
+ * through `oxc-resolver` (which returns realpath'd targets). When the
5232
+ * project root sits behind a symlink — e.g. macOS iCloud-synced
5233
+ * `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
5234
+ * spaces diverge: every resolved import misses the graph and the files
5235
+ * they point at (commonly every `@/…` alias target) are mis-reported as
5236
+ * unreachable. Canonicalizing the root before the scan keeps both path
5237
+ * spaces in agreement.
5238
+ */
5239
+ const toCanonicalPath = (filePath) => {
5240
+ try {
5241
+ return fs.realpathSync(filePath);
5242
+ } catch {
5243
+ return filePath;
5244
+ }
5245
+ };
4714
5246
  const DEAD_CODE_PLUGIN = "deslop";
4715
5247
  const DEAD_CODE_CATEGORY = "Maintainability";
4716
5248
  const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
@@ -4967,7 +5499,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
4967
5499
  });
4968
5500
  });
4969
5501
  const checkDeadCode = async (options) => {
4970
- const { rootDirectory, userConfig } = options;
5502
+ const { userConfig } = options;
5503
+ const rootDirectory = toCanonicalPath(options.rootDirectory);
4971
5504
  if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
4972
5505
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
4973
5506
  const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
@@ -5718,6 +6251,7 @@ const buildCapabilities = (project) => {
5718
6251
  const capabilities = /* @__PURE__ */ new Set();
5719
6252
  capabilities.add(project.framework);
5720
6253
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
6254
+ if (project.expoVersion !== null) capabilities.add("expo");
5721
6255
  const reactMajor = project.reactMajorVersion;
5722
6256
  if (reactMajor !== null) {
5723
6257
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -5889,10 +6423,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
5889
6423
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
5890
6424
  return fs.realpathSync(rootDirectory);
5891
6425
  };
6426
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
6427
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
6428
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
6429
+ };
5892
6430
  const applyRuleSeverityControls = (rules, severityControls) => {
5893
6431
  const enabledRules = {};
5894
6432
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
5895
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
6433
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
5896
6434
  if (severity === "off") continue;
5897
6435
  enabledRules[ruleKey] = severity;
5898
6436
  }
@@ -5934,7 +6472,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
5934
6472
  category: rule.category
5935
6473
  }, severityControls);
5936
6474
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
5937
- const severity = explicitSeverity ?? rule.severity;
6475
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
5938
6476
  if (severity === "off") continue;
5939
6477
  enabledReactDoctorRules[registryEntry.key] = severity;
5940
6478
  }
@@ -5991,6 +6529,44 @@ const dedupeDiagnostics = (diagnostics) => {
5991
6529
  }
5992
6530
  return uniqueDiagnostics;
5993
6531
  };
6532
+ /**
6533
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
6534
+ * once, returning results in input order. A pool of workers each pulls the
6535
+ * next not-yet-started index until the list drains — so a worker that
6536
+ * finishes a fast task immediately picks up the next one (greedy load
6537
+ * balancing), which matters when tasks have uneven durations (oxlint
6538
+ * batches do).
6539
+ *
6540
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
6541
+ * no further tasks are started, the already-in-flight tasks are awaited to
6542
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
6543
+ * rejects with that first error. This keeps the caller's fail-fast retry
6544
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
6545
+ * top of a still-running first one.
6546
+ */
6547
+ const mapWithConcurrency = async (items, concurrency, task) => {
6548
+ const results = new Array(items.length);
6549
+ if (items.length === 0) return results;
6550
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
6551
+ let nextIndex = 0;
6552
+ const errors = [];
6553
+ const runWorker = async () => {
6554
+ while (errors.length === 0) {
6555
+ const index = nextIndex;
6556
+ nextIndex += 1;
6557
+ if (index >= items.length) return;
6558
+ try {
6559
+ results[index] = await task(items[index], index);
6560
+ } catch (error) {
6561
+ errors.push(error);
6562
+ return;
6563
+ }
6564
+ }
6565
+ };
6566
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
6567
+ if (errors.length > 0) throw errors[0];
6568
+ return results;
6569
+ };
5994
6570
  const getPublicEnvPrefix = (framework) => {
5995
6571
  switch (framework) {
5996
6572
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -6673,6 +7249,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
6673
7249
  */
6674
7250
  const spawnLintBatches = async (input) => {
6675
7251
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
7252
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
6676
7253
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
6677
7254
  const allDiagnostics = [];
6678
7255
  const droppedFiles = [];
@@ -6692,23 +7269,31 @@ const spawnLintBatches = async (input) => {
6692
7269
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
6693
7270
  }
6694
7271
  };
7272
+ let startedFileCount = 0;
6695
7273
  let scannedFileCount = 0;
6696
- for (const batch of fileBatches) {
6697
- let batchFileIndex = 0;
6698
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
6699
- if (batchFileIndex < batch.length) {
6700
- batchFileIndex += 1;
6701
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
6702
- }
6703
- }, 50) : null;
6704
- try {
7274
+ let displayedFileCount = 0;
7275
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
7276
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
7277
+ if (displayedFileCount < ceiling) {
7278
+ displayedFileCount += 1;
7279
+ onFileProgress(displayedFileCount, totalFileCount);
7280
+ }
7281
+ }, 50) : null;
7282
+ progressTimer?.unref?.();
7283
+ try {
7284
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
7285
+ startedFileCount += batch.length;
6705
7286
  const batchDiagnostics = await spawnLintBatch(batch);
6706
- allDiagnostics.push(...batchDiagnostics);
6707
7287
  scannedFileCount += batch.length;
6708
- onFileProgress?.(scannedFileCount, totalFileCount);
6709
- } finally {
6710
- if (progressInterval !== null) clearInterval(progressInterval);
6711
- }
7288
+ if (onFileProgress) {
7289
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
7290
+ onFileProgress(displayedFileCount, totalFileCount);
7291
+ }
7292
+ return batchDiagnostics;
7293
+ });
7294
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
7295
+ } finally {
7296
+ if (progressTimer !== null) clearInterval(progressTimer);
6712
7297
  }
6713
7298
  if (droppedFiles.length > 0 && onPartialFailure) {
6714
7299
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6835,7 +7420,8 @@ const runOxlint = async (options) => {
6835
7420
  onPartialFailure,
6836
7421
  onFileProgress: options.onFileProgress,
6837
7422
  spawnTimeoutMs,
6838
- outputMaxBytes
7423
+ outputMaxBytes,
7424
+ concurrency: options.concurrency
6839
7425
  });
6840
7426
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
6841
7427
  try {
@@ -6903,6 +7489,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6903
7489
  const partialFailures = yield* LintPartialFailures;
6904
7490
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
6905
7491
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
7492
+ const concurrency = yield* OxlintConcurrency;
6906
7493
  const collectedFailures = [];
6907
7494
  const diagnostics = yield* Effect.tryPromise({
6908
7495
  try: () => runOxlint({
@@ -6921,7 +7508,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6921
7508
  },
6922
7509
  onFileProgress: input.onFileProgress,
6923
7510
  spawnTimeoutMs,
6924
- outputMaxBytes
7511
+ outputMaxBytes,
7512
+ concurrency
6925
7513
  }),
6926
7514
  catch: ensureReactDoctorError
6927
7515
  });
@@ -7254,7 +7842,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7254
7842
  showWarnings
7255
7843
  });
7256
7844
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
7257
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
7845
+ const environmentDiagnostics = isDiffMode ? [] : [
7846
+ ...checkReducedMotion(scanDirectory),
7847
+ ...checkPnpmHardening(scanDirectory),
7848
+ ...checkExpoProject(scanDirectory, project)
7849
+ ];
7258
7850
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7259
7851
  const lintFailure = yield* Ref.make({
7260
7852
  didFail: false,
@@ -7266,6 +7858,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7266
7858
  didFail: false,
7267
7859
  reason: null
7268
7860
  });
7861
+ const scanConcurrency = yield* OxlintConcurrency;
7862
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
7269
7863
  const scanProgress = yield* progressService.start("Scanning...");
7270
7864
  const scanStartTime = Date.now();
7271
7865
  let lastReportedTotalFileCount = 0;
@@ -7282,7 +7876,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7282
7876
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
7283
7877
  onFileProgress: (scannedFileCount, totalFileCount) => {
7284
7878
  lastReportedTotalFileCount = totalFileCount;
7285
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
7879
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
7286
7880
  }
7287
7881
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
7288
7882
  yield* Ref.set(lintFailure, {
@@ -7314,7 +7908,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7314
7908
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
7315
7909
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
7316
7910
  else if (input.suppressScanSummary) yield* scanProgress.stop();
7317
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
7911
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
7318
7912
  yield* reporterService.finalize;
7319
7913
  const finalDiagnostics = [
7320
7914
  ...envCollected,
@@ -7366,7 +7960,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7366
7960
  "inspect.isCi": input.isCi,
7367
7961
  "inspect.scoreSurface": input.scoreSurface ?? "score"
7368
7962
  } }));
7369
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7370
7963
  const parseNodeVersion = (versionString) => {
7371
7964
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
7372
7965
  return {
@@ -7665,6 +8258,26 @@ const buildJsonReport = (input) => {
7665
8258
  };
7666
8259
  };
7667
8260
  /**
8261
+ * Single source of truth for the skipped-check accounting shared by the
8262
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
8263
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
8264
+ * failed lint / dead-code pass instead of a false "all clear", so the
8265
+ * branch logic lives here once.
8266
+ */
8267
+ const buildSkippedChecks = (input) => {
8268
+ const skippedChecks = [];
8269
+ if (input.didLintFail) skippedChecks.push("lint");
8270
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
8271
+ const skippedCheckReasons = {};
8272
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
8273
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
8274
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
8275
+ return {
8276
+ skippedChecks,
8277
+ skippedCheckReasons
8278
+ };
8279
+ };
8280
+ /**
7668
8281
  * Programmatic façade over `Git.diffSelection`. Async because the
7669
8282
  * Git service runs through Effect's `ChildProcess` (true subprocess
7670
8283
  * spawn, not `spawnSync`).
@@ -7770,7 +8383,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
7770
8383
  const clearAutoSuppressionCaches = () => {};
7771
8384
  //#endregion
7772
8385
  //#region ../api/dist/index.js
7773
- const DEFAULT_LAYER = Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
8386
+ const buildDiagnoseLayer = (configLayer = Config.layerNode) => Layer.mergeAll(Project.layerNode, configLayer, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7774
8387
  const buildInspectProgram = (scanTarget, options, configOverride) => {
7775
8388
  const effectiveConfig = configOverride ?? scanTarget.userConfig;
7776
8389
  const includePaths = options.includePaths ?? [];
@@ -7789,13 +8402,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7789
8402
  };
7790
8403
  const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7791
8404
  if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
7792
- const skippedChecks = [];
7793
- if (output.didLintFail) skippedChecks.push("lint");
7794
- if (output.didDeadCodeFail) skippedChecks.push("dead-code");
7795
- const skippedCheckReasons = {};
7796
- if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
7797
- else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
7798
- if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
8405
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
7799
8406
  return {
7800
8407
  diagnostics: [...output.diagnostics],
7801
8408
  score: output.score,
@@ -7808,7 +8415,7 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7808
8415
  const diagnose = async (directory, options = {}) => {
7809
8416
  const startTime = globalThis.performance.now();
7810
8417
  const program = buildInspectProgram(resolveScanTarget(directory), options);
7811
- return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(DEFAULT_LAYER), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
8418
+ return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
7812
8419
  };
7813
8420
  //#endregion
7814
8421
  //#region src/index.ts
@@ -7817,6 +8424,7 @@ const clearCaches = () => {
7817
8424
  clearConfigCache();
7818
8425
  clearPackageJsonCache();
7819
8426
  clearIgnorePatternsCache();
8427
+ clearPackageRoleCache();
7820
8428
  clearAutoSuppressionCaches();
7821
8429
  };
7822
8430
  const toJsonReport = (result, options) => buildJsonReport({