react-doctor 0.2.14-dev.8b313ba → 0.2.14-dev.938376

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";
@@ -5741,6 +6251,7 @@ const buildCapabilities = (project) => {
5741
6251
  const capabilities = /* @__PURE__ */ new Set();
5742
6252
  capabilities.add(project.framework);
5743
6253
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
6254
+ if (project.expoVersion !== null) capabilities.add("expo");
5744
6255
  const reactMajor = project.reactMajorVersion;
5745
6256
  if (reactMajor !== null) {
5746
6257
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -5912,10 +6423,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
5912
6423
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
5913
6424
  return fs.realpathSync(rootDirectory);
5914
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
+ };
5915
6430
  const applyRuleSeverityControls = (rules, severityControls) => {
5916
6431
  const enabledRules = {};
5917
6432
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
5918
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
6433
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
5919
6434
  if (severity === "off") continue;
5920
6435
  enabledRules[ruleKey] = severity;
5921
6436
  }
@@ -5957,7 +6472,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
5957
6472
  category: rule.category
5958
6473
  }, severityControls);
5959
6474
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
5960
- const severity = explicitSeverity ?? rule.severity;
6475
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
5961
6476
  if (severity === "off") continue;
5962
6477
  enabledReactDoctorRules[registryEntry.key] = severity;
5963
6478
  }
@@ -6014,6 +6529,44 @@ const dedupeDiagnostics = (diagnostics) => {
6014
6529
  }
6015
6530
  return uniqueDiagnostics;
6016
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
+ };
6017
6570
  const getPublicEnvPrefix = (framework) => {
6018
6571
  switch (framework) {
6019
6572
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -6696,6 +7249,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
6696
7249
  */
6697
7250
  const spawnLintBatches = async (input) => {
6698
7251
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
7252
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
6699
7253
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
6700
7254
  const allDiagnostics = [];
6701
7255
  const droppedFiles = [];
@@ -6715,23 +7269,31 @@ const spawnLintBatches = async (input) => {
6715
7269
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
6716
7270
  }
6717
7271
  };
7272
+ let startedFileCount = 0;
6718
7273
  let scannedFileCount = 0;
6719
- for (const batch of fileBatches) {
6720
- let batchFileIndex = 0;
6721
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
6722
- if (batchFileIndex < batch.length) {
6723
- batchFileIndex += 1;
6724
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
6725
- }
6726
- }, 50) : null;
6727
- 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;
6728
7286
  const batchDiagnostics = await spawnLintBatch(batch);
6729
- allDiagnostics.push(...batchDiagnostics);
6730
7287
  scannedFileCount += batch.length;
6731
- onFileProgress?.(scannedFileCount, totalFileCount);
6732
- } finally {
6733
- if (progressInterval !== null) clearInterval(progressInterval);
6734
- }
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);
6735
7297
  }
6736
7298
  if (droppedFiles.length > 0 && onPartialFailure) {
6737
7299
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6858,7 +7420,8 @@ const runOxlint = async (options) => {
6858
7420
  onPartialFailure,
6859
7421
  onFileProgress: options.onFileProgress,
6860
7422
  spawnTimeoutMs,
6861
- outputMaxBytes
7423
+ outputMaxBytes,
7424
+ concurrency: options.concurrency
6862
7425
  });
6863
7426
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
6864
7427
  try {
@@ -6926,6 +7489,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6926
7489
  const partialFailures = yield* LintPartialFailures;
6927
7490
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
6928
7491
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
7492
+ const concurrency = yield* OxlintConcurrency;
6929
7493
  const collectedFailures = [];
6930
7494
  const diagnostics = yield* Effect.tryPromise({
6931
7495
  try: () => runOxlint({
@@ -6944,7 +7508,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6944
7508
  },
6945
7509
  onFileProgress: input.onFileProgress,
6946
7510
  spawnTimeoutMs,
6947
- outputMaxBytes
7511
+ outputMaxBytes,
7512
+ concurrency
6948
7513
  }),
6949
7514
  catch: ensureReactDoctorError
6950
7515
  });
@@ -7268,7 +7833,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7268
7833
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
7269
7834
  yield* beforeLint(project, lintIncludePaths ?? void 0);
7270
7835
  const isDiffMode = input.includePaths.length > 0;
7271
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
7836
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
7272
7837
  const transform = buildDiagnosticPipeline({
7273
7838
  rootDirectory: scanDirectory,
7274
7839
  userConfig: resolvedConfig.config,
@@ -7277,7 +7842,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7277
7842
  showWarnings
7278
7843
  });
7279
7844
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
7280
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
7845
+ const environmentDiagnostics = isDiffMode ? [] : [
7846
+ ...checkReducedMotion(scanDirectory),
7847
+ ...checkPnpmHardening(scanDirectory),
7848
+ ...checkExpoProject(scanDirectory, project)
7849
+ ];
7281
7850
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7282
7851
  const lintFailure = yield* Ref.make({
7283
7852
  didFail: false,
@@ -7289,6 +7858,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7289
7858
  didFail: false,
7290
7859
  reason: null
7291
7860
  });
7861
+ const scanConcurrency = yield* OxlintConcurrency;
7862
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
7292
7863
  const scanProgress = yield* progressService.start("Scanning...");
7293
7864
  const scanStartTime = Date.now();
7294
7865
  let lastReportedTotalFileCount = 0;
@@ -7305,7 +7876,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7305
7876
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
7306
7877
  onFileProgress: (scannedFileCount, totalFileCount) => {
7307
7878
  lastReportedTotalFileCount = totalFileCount;
7308
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
7879
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
7309
7880
  }
7310
7881
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
7311
7882
  yield* Ref.set(lintFailure, {
@@ -7337,7 +7908,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7337
7908
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
7338
7909
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
7339
7910
  else if (input.suppressScanSummary) yield* scanProgress.stop();
7340
- 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}`);
7341
7912
  yield* reporterService.finalize;
7342
7913
  const finalDiagnostics = [
7343
7914
  ...envCollected,
@@ -7389,7 +7960,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7389
7960
  "inspect.isCi": input.isCi,
7390
7961
  "inspect.scoreSurface": input.scoreSurface ?? "score"
7391
7962
  } }));
7392
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7393
7963
  const parseNodeVersion = (versionString) => {
7394
7964
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
7395
7965
  return {
@@ -7688,6 +8258,26 @@ const buildJsonReport = (input) => {
7688
8258
  };
7689
8259
  };
7690
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
+ /**
7691
8281
  * Programmatic façade over `Git.diffSelection`. Async because the
7692
8282
  * Git service runs through Effect's `ChildProcess` (true subprocess
7693
8283
  * spawn, not `spawnSync`).
@@ -7793,7 +8383,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
7793
8383
  const clearAutoSuppressionCaches = () => {};
7794
8384
  //#endregion
7795
8385
  //#region ../api/dist/index.js
7796
- 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);
7797
8387
  const buildInspectProgram = (scanTarget, options, configOverride) => {
7798
8388
  const effectiveConfig = configOverride ?? scanTarget.userConfig;
7799
8389
  const includePaths = options.includePaths ?? [];
@@ -7802,7 +8392,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7802
8392
  includePaths,
7803
8393
  customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
7804
8394
  respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
7805
- warnings: options.warnings ?? effectiveConfig?.warnings ?? false,
8395
+ warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
7806
8396
  adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
7807
8397
  ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
7808
8398
  runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
@@ -7812,13 +8402,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7812
8402
  };
7813
8403
  const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7814
8404
  if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
7815
- const skippedChecks = [];
7816
- if (output.didLintFail) skippedChecks.push("lint");
7817
- if (output.didDeadCodeFail) skippedChecks.push("dead-code");
7818
- const skippedCheckReasons = {};
7819
- if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
7820
- else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
7821
- if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
8405
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
7822
8406
  return {
7823
8407
  diagnostics: [...output.diagnostics],
7824
8408
  score: output.score,
@@ -7831,7 +8415,7 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7831
8415
  const diagnose = async (directory, options = {}) => {
7832
8416
  const startTime = globalThis.performance.now();
7833
8417
  const program = buildInspectProgram(resolveScanTarget(directory), options);
7834
- 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);
7835
8419
  };
7836
8420
  //#endregion
7837
8421
  //#region src/index.ts
@@ -7840,6 +8424,7 @@ const clearCaches = () => {
7840
8424
  clearConfigCache();
7841
8425
  clearPackageJsonCache();
7842
8426
  clearIgnorePatternsCache();
8427
+ clearPackageRoleCache();
7843
8428
  clearAutoSuppressionCaches();
7844
8429
  };
7845
8430
  const toJsonReport = (result, options) => buildJsonReport({