react-doctor 0.2.14-dev.6e59f10 → 0.2.14-dev.75c1f99

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
@@ -1,3 +1,5 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="fce73b02-d297-5132-af08-817f37e1467c")}catch(e){}}();
1
3
  import { createRequire } from "node:module";
2
4
  import * as Schema from "effect/Schema";
3
5
  import * as fs$1 from "node:fs";
@@ -14,7 +16,10 @@ import * as Redacted from "effect/Redacted";
14
16
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
15
17
  import * as Otlp from "effect/unstable/observability/Otlp";
16
18
  import * as Context from "effect/Context";
19
+ import os from "node:os";
17
20
  import * as Console from "effect/Console";
21
+ import { parseJSON5 } from "confbox";
22
+ import { createJiti } from "jiti";
18
23
  import * as Fiber from "effect/Fiber";
19
24
  import * as Filter from "effect/Filter";
20
25
  import * as Option from "effect/Option";
@@ -26,7 +31,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
26
31
  import * as NodePath from "@effect/platform-node-shared/NodePath";
27
32
  import * as ChildProcess from "effect/unstable/process/ChildProcess";
28
33
  import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
29
- import os from "node:os";
30
34
  import * as ts from "typescript";
31
35
  import { gzipSync } from "node:zlib";
32
36
  //#region \0rolldown/runtime.js
@@ -2874,29 +2878,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
2874
2878
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
2875
2879
  };
2876
2880
  };
2877
- const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
2878
- if (predicate(rootPackageJson)) return true;
2881
+ const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
2882
+ const rootValue = select(rootPackageJson);
2883
+ if (rootValue !== null) return rootValue;
2879
2884
  const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
2880
- if (patterns.length === 0) return false;
2885
+ if (patterns.length === 0) return null;
2881
2886
  const visitedDirectories = /* @__PURE__ */ new Set();
2882
2887
  for (const pattern of patterns) {
2883
- const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
2888
+ const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
2884
2889
  for (const workspaceDirectory of directories) {
2885
2890
  if (visitedDirectories.has(workspaceDirectory)) continue;
2886
2891
  visitedDirectories.add(workspaceDirectory);
2887
- if (predicate(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
2892
+ const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
2893
+ if (value !== null) return value;
2888
2894
  }
2889
2895
  }
2890
- return false;
2896
+ return null;
2891
2897
  };
2898
+ const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
2892
2899
  const NAMES = new Set([
2893
2900
  "react-native",
2894
2901
  "react-native-tvos",
2895
- "expo",
2896
- "expo-router",
2897
- "@expo/cli",
2898
- "@expo/metro-config",
2899
- "@expo/metro-runtime",
2902
+ ...new Set([
2903
+ "expo",
2904
+ "expo-router",
2905
+ "@expo/cli",
2906
+ "@expo/metro-config",
2907
+ "@expo/metro-runtime"
2908
+ ]),
2900
2909
  "react-native-windows",
2901
2910
  "react-native-macos"
2902
2911
  ]);
@@ -2920,6 +2929,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
2920
2929
  return false;
2921
2930
  };
2922
2931
  const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
2932
+ const getExpoDependencySpec = (packageJson) => {
2933
+ const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
2934
+ return typeof spec === "string" ? spec : null;
2935
+ };
2936
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
2923
2937
  const getPreactVersion = (packageJson) => {
2924
2938
  return {
2925
2939
  ...packageJson.peerDependencies,
@@ -3159,6 +3173,19 @@ const discoverProject = (directory) => {
3159
3173
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
3160
3174
  const sourceFileCount = countSourceFiles(directory);
3161
3175
  const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
3176
+ let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
3177
+ if (expoVersion !== null && isCatalogReference(expoVersion)) {
3178
+ const catalogName = extractCatalogName(expoVersion);
3179
+ let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
3180
+ if (!resolvedExpoVersion) {
3181
+ const monorepoRoot = findMonorepoRoot(directory);
3182
+ if (monorepoRoot) {
3183
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
3184
+ if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
3185
+ }
3186
+ }
3187
+ expoVersion = resolvedExpoVersion ?? expoVersion;
3188
+ }
3162
3189
  const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
3163
3190
  const preactVersion = getPreactVersion(packageJson);
3164
3191
  const projectInfo = {
@@ -3176,6 +3203,7 @@ const discoverProject = (directory) => {
3176
3203
  preactVersion,
3177
3204
  preactMajorVersion: parseReactMajor(preactVersion),
3178
3205
  hasReactNativeWorkspace,
3206
+ expoVersion,
3179
3207
  hasReanimated,
3180
3208
  sourceFileCount
3181
3209
  };
@@ -3265,7 +3293,14 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
3265
3293
  "tsconfig.json",
3266
3294
  "tsconfig.base.json",
3267
3295
  "package.json",
3268
- "react-doctor.config.json",
3296
+ "doctor.config.ts",
3297
+ "doctor.config.mts",
3298
+ "doctor.config.cts",
3299
+ "doctor.config.js",
3300
+ "doctor.config.mjs",
3301
+ "doctor.config.cjs",
3302
+ "doctor.config.json",
3303
+ "doctor.config.jsonc",
3269
3304
  "oxlint.json",
3270
3305
  ".oxlintrc.json"
3271
3306
  ];
@@ -3280,6 +3315,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
3280
3315
  "Accessibility",
3281
3316
  "Maintainability"
3282
3317
  ];
3318
+ const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
3319
+ const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
3320
+ const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
3283
3321
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
3284
3322
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
3285
3323
  var InvalidGlobPatternError = class extends Error {
@@ -3399,10 +3437,11 @@ const restampSeverity = (diagnostic, override) => {
3399
3437
  */
3400
3438
  const buildRuleSeverityControls = (config) => {
3401
3439
  if (!config) return void 0;
3402
- if (config.rules === void 0 && config.categories === void 0) return void 0;
3440
+ if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
3403
3441
  return {
3404
3442
  ...config.rules !== void 0 ? { rules: config.rules } : {},
3405
- ...config.categories !== void 0 ? { categories: config.categories } : {}
3443
+ ...config.categories !== void 0 ? { categories: config.categories } : {},
3444
+ ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
3406
3445
  };
3407
3446
  };
3408
3447
  const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
@@ -3766,6 +3805,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
3766
3805
  }
3767
3806
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
3768
3807
  };
3808
+ const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
3809
+ const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
3810
+ const findNearestPackageDirectory = (filename) => {
3811
+ if (!filename) return null;
3812
+ const fromCache = cachedPackageDirectoryByFilename.get(filename);
3813
+ if (fromCache !== void 0) return fromCache;
3814
+ let currentDirectory = path.dirname(filename);
3815
+ while (true) {
3816
+ const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
3817
+ let hasPackageJson = false;
3818
+ try {
3819
+ hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
3820
+ } catch {
3821
+ hasPackageJson = false;
3822
+ }
3823
+ if (hasPackageJson) {
3824
+ cachedPackageDirectoryByFilename.set(filename, currentDirectory);
3825
+ return currentDirectory;
3826
+ }
3827
+ const parentDirectory = path.dirname(currentDirectory);
3828
+ if (parentDirectory === currentDirectory) {
3829
+ cachedPackageDirectoryByFilename.set(filename, null);
3830
+ return null;
3831
+ }
3832
+ currentDirectory = parentDirectory;
3833
+ }
3834
+ };
3835
+ const readManifest = (packageJsonPath) => {
3836
+ try {
3837
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
3838
+ if (typeof parsed === "object" && parsed !== null) return parsed;
3839
+ return null;
3840
+ } catch {
3841
+ return null;
3842
+ }
3843
+ };
3844
+ const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
3845
+ const classifyByDirectoryCohort = (packageDirectory) => {
3846
+ let current = packageDirectory;
3847
+ while (true) {
3848
+ if (path.basename(current) === "apps") return "app";
3849
+ const parent = path.dirname(current);
3850
+ if (parent === current) return null;
3851
+ current = parent;
3852
+ }
3853
+ };
3854
+ const clearPackageRoleCache = () => {
3855
+ cachedRoleByPackageDirectory.clear();
3856
+ cachedPackageDirectoryByFilename.clear();
3857
+ };
3858
+ const classifyPackageRole = (filename) => {
3859
+ if (!filename) return "unknown";
3860
+ const packageDirectory = findNearestPackageDirectory(filename);
3861
+ if (!packageDirectory) return "unknown";
3862
+ const cached = cachedRoleByPackageDirectory.get(packageDirectory);
3863
+ if (cached !== void 0) return cached;
3864
+ const manifest = readManifest(path.join(packageDirectory, "package.json"));
3865
+ let result;
3866
+ if (manifest && hasPublishContract(manifest)) result = "library";
3867
+ else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
3868
+ cachedRoleByPackageDirectory.set(packageDirectory, result);
3869
+ return result;
3870
+ };
3769
3871
  /**
3770
3872
  * Resolves the absolute path to read for a diagnostic's `filePath`,
3771
3873
  * accounting for the various shapes oxlint emits:
@@ -3928,6 +4030,15 @@ const buildDiagnosticPipeline = (input) => {
3928
4030
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
3929
4031
  const fileLinesCache = /* @__PURE__ */ new Map();
3930
4032
  const testFileCache = /* @__PURE__ */ new Map();
4033
+ const libraryFileCache = /* @__PURE__ */ new Map();
4034
+ const isLibraryFile = (filePath) => {
4035
+ let cached = libraryFileCache.get(filePath);
4036
+ if (cached === void 0) {
4037
+ cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
4038
+ libraryFileCache.set(filePath, cached);
4039
+ }
4040
+ return cached;
4041
+ };
3931
4042
  const getFileLines = (filePath) => {
3932
4043
  const cached = fileLinesCache.get(filePath);
3933
4044
  if (cached !== void 0) return cached;
@@ -3954,6 +4065,10 @@ const buildDiagnosticPipeline = (input) => {
3954
4065
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
3955
4066
  return false;
3956
4067
  };
4068
+ const isAppOnlyRule = (ruleIdentifier) => {
4069
+ for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
4070
+ return false;
4071
+ };
3957
4072
  const isRnRawTextSuppressedByConfig = (diagnostic) => {
3958
4073
  if (diagnostic.rule !== "rn-no-raw-text") return false;
3959
4074
  if (diagnostic.line <= 0) return false;
@@ -3968,8 +4083,10 @@ const buildDiagnosticPipeline = (input) => {
3968
4083
  if (shouldAutoSuppress(diagnostic)) return null;
3969
4084
  let current = diagnostic;
3970
4085
  let explicitSeverityOverride;
4086
+ let explicitRuleOverride;
3971
4087
  if (severityControls) {
3972
4088
  const { ruleKey, category } = getDiagnosticRuleIdentity(current);
4089
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
3973
4090
  explicitSeverityOverride = resolveRuleSeverityOverride({
3974
4091
  ruleKey,
3975
4092
  category
@@ -3977,6 +4094,9 @@ const buildDiagnosticPipeline = (input) => {
3977
4094
  if (explicitSeverityOverride === "off") return null;
3978
4095
  if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
3979
4096
  }
4097
+ if (explicitRuleOverride === void 0) {
4098
+ if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
4099
+ }
3980
4100
  if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
3981
4101
  if (userConfig) {
3982
4102
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
@@ -4162,6 +4282,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
4162
4282
  }).pipe(Layer.provide(FetchHttpClient.layer));
4163
4283
  }).pipe(Effect.orDie));
4164
4284
  /**
4285
+ * Resolves a requested lint worker count to a clamped integer within
4286
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
4287
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
4288
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
4289
+ */
4290
+ const resolveScanConcurrency = (requested) => {
4291
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
4292
+ if (!Number.isFinite(desired) || desired < 1) return 1;
4293
+ return Math.max(1, Math.min(Math.floor(desired), 16));
4294
+ };
4295
+ /**
4165
4296
  * Per-batch oxlint wall-clock budget. Reads from the env var on
4166
4297
  * startup so the eval harness can raise the budget under sandbox
4167
4298
  * microVMs without recompiling react-doctor. Tests override via
@@ -4181,6 +4312,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
4181
4312
  * tests that exercise the cap behavior.
4182
4313
  */
4183
4314
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
4315
+ /**
4316
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
4317
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
4318
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
4319
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
4320
+ * CI callers that never touch the flag:
4321
+ *
4322
+ * - unset / `0` / `false` / `off` → `1` (serial)
4323
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
4324
+ * - a positive integer → that many workers (clamped)
4325
+ *
4326
+ * The resolved value is always within
4327
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
4328
+ */
4329
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
4330
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
4331
+ if (raw === void 0) return 1;
4332
+ const normalized = raw.trim().toLowerCase();
4333
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
4334
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
4335
+ const parsed = Number.parseInt(normalized, 10);
4336
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
4337
+ return resolveScanConcurrency(parsed);
4338
+ } }) {};
4184
4339
  const DIAGNOSTIC_SURFACES = [
4185
4340
  "cli",
4186
4341
  "prComment",
@@ -4337,69 +4492,109 @@ const validateConfigTypes = (config) => {
4337
4492
  const warn = (message) => {
4338
4493
  Effect.runSync(Console.warn(message));
4339
4494
  };
4340
- const CONFIG_FILENAME = "react-doctor.config.json";
4495
+ const CONFIG_BASENAME = "doctor.config";
4496
+ const CONFIG_EXTENSIONS = [
4497
+ "ts",
4498
+ "mts",
4499
+ "cts",
4500
+ "js",
4501
+ "mjs",
4502
+ "cjs",
4503
+ "json",
4504
+ "jsonc"
4505
+ ];
4506
+ const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
4507
+ const PACKAGE_JSON_FILENAME = "package.json";
4341
4508
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
4342
- const loadConfigFromDirectory = (directory) => {
4343
- 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)}`);
4354
- }
4355
- const packageJsonPath = path.join(directory, "package.json");
4356
- if (isFile(packageJsonPath)) try {
4357
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
4358
- const packageJson = JSON.parse(fileContent);
4509
+ const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
4510
+ const jiti = createJiti(import.meta.url);
4511
+ const formatError = (error) => error instanceof Error ? error.message : String(error);
4512
+ const loadModuleConfig = async (filePath) => {
4513
+ const imported = await jiti.import(filePath);
4514
+ return imported?.default ?? imported;
4515
+ };
4516
+ const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
4517
+ const readEmbeddedPackageJsonConfig = (directory) => {
4518
+ const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
4519
+ if (!isFile(packageJsonPath)) return null;
4520
+ try {
4521
+ const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
4359
4522
  if (isPlainObject(packageJson)) {
4360
4523
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4361
- if (isPlainObject(embeddedConfig)) return {
4362
- config: validateConfigTypes(embeddedConfig),
4363
- sourceDirectory: directory
4524
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
4525
+ }
4526
+ } catch {}
4527
+ return null;
4528
+ };
4529
+ const loadPackageJsonConfig = (directory) => {
4530
+ const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
4531
+ if (!embeddedConfig) return null;
4532
+ return {
4533
+ config: validateConfigTypes(embeddedConfig),
4534
+ sourceDirectory: directory,
4535
+ configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
4536
+ format: "package-json"
4537
+ };
4538
+ };
4539
+ const loadConfigFromDirectory = async (directory) => {
4540
+ let sawBrokenConfigFile = false;
4541
+ for (const extension of CONFIG_EXTENSIONS) {
4542
+ const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
4543
+ if (!isFile(filePath)) continue;
4544
+ const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
4545
+ try {
4546
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
4547
+ if (isPlainObject(parsed)) return {
4548
+ status: "found",
4549
+ loaded: {
4550
+ config: validateConfigTypes(parsed),
4551
+ sourceDirectory: directory,
4552
+ configFilePath: filePath,
4553
+ format: isDataFile ? "json" : "module"
4554
+ }
4364
4555
  };
4556
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
4557
+ sawBrokenConfigFile = true;
4558
+ } catch (error) {
4559
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
4560
+ sawBrokenConfigFile = true;
4365
4561
  }
4366
- } catch {
4367
- return null;
4368
4562
  }
4369
- return null;
4563
+ const packageJsonConfig = loadPackageJsonConfig(directory);
4564
+ if (packageJsonConfig) return {
4565
+ status: "found",
4566
+ loaded: packageJsonConfig
4567
+ };
4568
+ if (isFile(path.join(directory, LEGACY_CONFIG_FILENAME))) warn(`${LEGACY_CONFIG_FILENAME} is no longer read — rename it to ${CONFIG_BASENAME}.json (or author a ${CONFIG_BASENAME}.ts).`);
4569
+ return {
4570
+ status: sawBrokenConfigFile ? "invalid" : "absent",
4571
+ loaded: null
4572
+ };
4370
4573
  };
4371
4574
  const cachedConfigs = /* @__PURE__ */ new Map();
4372
4575
  const clearConfigCache = () => {
4373
4576
  cachedConfigs.clear();
4374
4577
  };
4375
- const loadConfigWithSource = (rootDirectory) => {
4376
- const cached = cachedConfigs.get(rootDirectory);
4377
- if (cached !== void 0) return cached;
4378
- const localConfig = loadConfigFromDirectory(rootDirectory);
4379
- if (localConfig) {
4380
- cachedConfigs.set(rootDirectory, localConfig);
4381
- return localConfig;
4382
- }
4383
- if (isProjectBoundary(rootDirectory)) {
4384
- cachedConfigs.set(rootDirectory, null);
4385
- return null;
4386
- }
4578
+ const loadConfigWalkingUp = async (rootDirectory) => {
4579
+ const localResult = await loadConfigFromDirectory(rootDirectory);
4580
+ if (localResult.status === "found") return localResult.loaded;
4581
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
4387
4582
  let ancestorDirectory = path.dirname(rootDirectory);
4388
4583
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4389
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4390
- if (ancestorConfig) {
4391
- cachedConfigs.set(rootDirectory, ancestorConfig);
4392
- return ancestorConfig;
4393
- }
4394
- if (isProjectBoundary(ancestorDirectory)) {
4395
- cachedConfigs.set(rootDirectory, null);
4396
- return null;
4397
- }
4584
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
4585
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
4586
+ if (isProjectBoundary(ancestorDirectory)) return null;
4398
4587
  ancestorDirectory = path.dirname(ancestorDirectory);
4399
4588
  }
4400
- cachedConfigs.set(rootDirectory, null);
4401
4589
  return null;
4402
4590
  };
4591
+ const loadConfigWithSource = (rootDirectory) => {
4592
+ const cached = cachedConfigs.get(rootDirectory);
4593
+ if (cached !== void 0) return cached;
4594
+ const loadPromise = loadConfigWalkingUp(rootDirectory);
4595
+ cachedConfigs.set(rootDirectory, loadPromise);
4596
+ return loadPromise;
4597
+ };
4403
4598
  const resolveConfigRootDir = (config, configSourceDirectory) => {
4404
4599
  if (!config || !configSourceDirectory) return null;
4405
4600
  const rawRootDir = config.rootDir;
@@ -4414,11 +4609,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
4414
4609
  }
4415
4610
  return resolvedRootDir;
4416
4611
  };
4417
- const resolveDiagnoseTarget = (directory) => {
4612
+ const resolveDiagnoseTarget = (directory, options = {}) => {
4418
4613
  if (isFile(path.join(directory, "package.json"))) return directory;
4419
4614
  const reactSubprojects = discoverReactSubprojects(directory);
4420
4615
  if (reactSubprojects.length === 0) return null;
4421
4616
  if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
4617
+ if (options.allowAmbiguous === true) return null;
4422
4618
  throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
4423
4619
  };
4424
4620
  /**
@@ -4426,13 +4622,13 @@ const resolveDiagnoseTarget = (directory) => {
4426
4622
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
4427
4623
  *
4428
4624
  * 1. Resolve the requested directory to absolute.
4429
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
4430
- * if present.
4625
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
4431
4626
  * 3. Honor `config.rootDir` to redirect the scan to a nested
4432
4627
  * project root, if configured.
4433
4628
  * 4. Walk into a nested React subproject when the requested
4434
4629
  * directory has no `package.json` of its own (raises
4435
- * `AmbiguousProjectError` when multiple candidates exist).
4630
+ * `AmbiguousProjectError` when multiple candidates exist unless
4631
+ * the caller opts into keeping the wrapper directory).
4436
4632
  *
4437
4633
  * Throws `ProjectNotFoundError` when neither the requested directory
4438
4634
  * nor any discoverable nested project has a `package.json`.
@@ -4444,14 +4640,14 @@ const resolveDiagnoseTarget = (directory) => {
4444
4640
  * via its own cache). Routing through `resolveScanTarget` keeps every
4445
4641
  * shell in agreement on what "the scan directory" means.
4446
4642
  */
4447
- const resolveScanTarget = (requestedDirectory) => {
4643
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
4448
4644
  const absoluteRequested = path.resolve(requestedDirectory);
4449
- const loadedConfig = loadConfigWithSource(absoluteRequested);
4645
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
4450
4646
  const userConfig = loadedConfig?.config ?? null;
4451
4647
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4452
4648
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
4453
4649
  const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
4454
- const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
4650
+ const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
4455
4651
  if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
4456
4652
  return {
4457
4653
  resolvedDirectory,
@@ -4461,6 +4657,359 @@ const resolveScanTarget = (requestedDirectory) => {
4461
4657
  didRedirectViaRootDir: redirectedDirectory !== null
4462
4658
  };
4463
4659
  };
4660
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
4661
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
4662
+ const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
4663
+ return {
4664
+ rootDirectory,
4665
+ packageJson,
4666
+ directDependencyNames: getDirectDependencyNames(packageJson),
4667
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
4668
+ };
4669
+ };
4670
+ const buildExpoDiagnostic = (input) => ({
4671
+ filePath: input.filePath ?? "package.json",
4672
+ plugin: "react-doctor",
4673
+ rule: input.rule,
4674
+ severity: input.severity ?? "warning",
4675
+ message: input.message,
4676
+ help: input.help,
4677
+ line: input.line ?? 0,
4678
+ column: input.column ?? 0,
4679
+ category: input.category ?? "Correctness"
4680
+ });
4681
+ const CRITICAL_OVERRIDE_NAMES = new Set([
4682
+ "@expo/cli",
4683
+ "@expo/config",
4684
+ "@expo/metro-config",
4685
+ "@expo/metro-runtime",
4686
+ "@expo/metro",
4687
+ "metro"
4688
+ ]);
4689
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
4690
+ const collectOverrideNames = (packageJson) => new Set([
4691
+ ...Object.keys(packageJson.overrides ?? {}),
4692
+ ...Object.keys(packageJson.resolutions ?? {}),
4693
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
4694
+ ]);
4695
+ const checkExpoDependencyOverrides = (context) => {
4696
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
4697
+ if (overriddenCriticalNames.length === 0) return [];
4698
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
4699
+ return [buildExpoDiagnostic({
4700
+ rule: "expo-no-conflicting-dependency-override",
4701
+ 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`,
4702
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
4703
+ })];
4704
+ };
4705
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
4706
+ const result = spawnSync("git", [
4707
+ "check-ignore",
4708
+ "-q",
4709
+ absolutePath
4710
+ ], {
4711
+ cwd: rootDirectory,
4712
+ stdio: [
4713
+ "ignore",
4714
+ "ignore",
4715
+ "ignore"
4716
+ ]
4717
+ });
4718
+ if (result.error) return null;
4719
+ if (result.status === 0) return true;
4720
+ if (result.status === 1) return false;
4721
+ return null;
4722
+ };
4723
+ const LOCAL_ENV_FILE_NAMES = [
4724
+ ".env.local",
4725
+ ".env.development.local",
4726
+ ".env.production.local",
4727
+ ".env.test.local"
4728
+ ];
4729
+ const checkExpoEnvLocalFiles = (context) => {
4730
+ const { rootDirectory } = context;
4731
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
4732
+ const filePath = path.join(rootDirectory, fileName);
4733
+ if (!isFile(filePath)) return false;
4734
+ return isPathGitIgnored(rootDirectory, filePath) === false;
4735
+ });
4736
+ if (committedEnvFiles.length === 0) return [];
4737
+ return [buildExpoDiagnostic({
4738
+ rule: "expo-env-local-not-gitignored",
4739
+ category: "Security",
4740
+ 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`,
4741
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
4742
+ })];
4743
+ };
4744
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
4745
+ 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";
4746
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
4747
+ const unimodulesEntry = (packageName) => ({
4748
+ packageName,
4749
+ rule: "expo-no-unimodules-packages",
4750
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
4751
+ help: UNIMODULES_HELP
4752
+ });
4753
+ const FLAGGED_DEPENDENCIES = [
4754
+ unimodulesEntry("@unimodules/core"),
4755
+ unimodulesEntry("@unimodules/react-native-adapter"),
4756
+ unimodulesEntry("react-native-unimodules"),
4757
+ {
4758
+ packageName: "expo-cli",
4759
+ rule: "expo-no-cli-dependencies",
4760
+ 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`",
4761
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
4762
+ },
4763
+ {
4764
+ packageName: "eas-cli",
4765
+ rule: "expo-no-cli-dependencies",
4766
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
4767
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
4768
+ },
4769
+ {
4770
+ packageName: "expo-modules-autolinking",
4771
+ rule: "expo-no-redundant-dependency",
4772
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
4773
+ help: "Remove `expo-modules-autolinking` from your package.json"
4774
+ },
4775
+ {
4776
+ packageName: "expo-dev-launcher",
4777
+ rule: "expo-no-redundant-dependency",
4778
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4779
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
4780
+ },
4781
+ {
4782
+ packageName: "expo-dev-menu",
4783
+ rule: "expo-no-redundant-dependency",
4784
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4785
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
4786
+ },
4787
+ {
4788
+ packageName: "expo-modules-core",
4789
+ rule: "expo-no-redundant-dependency",
4790
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
4791
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
4792
+ },
4793
+ {
4794
+ packageName: "@expo/metro-config",
4795
+ rule: "expo-no-redundant-dependency",
4796
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
4797
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
4798
+ },
4799
+ {
4800
+ packageName: "@types/react-native",
4801
+ rule: "expo-no-redundant-dependency",
4802
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
4803
+ help: "Remove `@types/react-native` from your package.json",
4804
+ minSdkMajor: 48
4805
+ },
4806
+ {
4807
+ packageName: "@expo/config-plugins",
4808
+ rule: "expo-no-redundant-dependency",
4809
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
4810
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
4811
+ minSdkMajor: 48
4812
+ },
4813
+ {
4814
+ packageName: "@expo/prebuild-config",
4815
+ rule: "expo-no-redundant-dependency",
4816
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
4817
+ help: "Remove `@expo/prebuild-config` from your package.json",
4818
+ minSdkMajor: 53
4819
+ },
4820
+ {
4821
+ packageName: "expo-permissions",
4822
+ rule: "expo-no-redundant-dependency",
4823
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
4824
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
4825
+ minSdkMajor: 50
4826
+ },
4827
+ {
4828
+ packageName: "expo-app-loading",
4829
+ rule: "expo-no-redundant-dependency",
4830
+ message: "\"expo-app-loading\" was removed in SDK 49",
4831
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
4832
+ minSdkMajor: 49
4833
+ },
4834
+ {
4835
+ packageName: "expo-firebase-analytics",
4836
+ rule: "expo-no-redundant-dependency",
4837
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
4838
+ help: FIREBASE_HELP,
4839
+ minSdkMajor: 48
4840
+ },
4841
+ {
4842
+ packageName: "expo-firebase-recaptcha",
4843
+ rule: "expo-no-redundant-dependency",
4844
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
4845
+ help: FIREBASE_HELP,
4846
+ minSdkMajor: 48
4847
+ },
4848
+ {
4849
+ packageName: "expo-firebase-core",
4850
+ rule: "expo-no-redundant-dependency",
4851
+ message: "\"expo-firebase-core\" was removed in SDK 48",
4852
+ help: FIREBASE_HELP,
4853
+ minSdkMajor: 48
4854
+ }
4855
+ ];
4856
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
4857
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
4858
+ if (flaggedDependency.minSdkMajor === void 0) return true;
4859
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
4860
+ }).map((flaggedDependency) => buildExpoDiagnostic({
4861
+ rule: flaggedDependency.rule,
4862
+ message: flaggedDependency.message,
4863
+ help: flaggedDependency.help
4864
+ }));
4865
+ const findLocalModuleNativeFiles = (rootDirectory) => {
4866
+ const modulesDirectory = path.join(rootDirectory, "modules");
4867
+ if (!isDirectory(modulesDirectory)) return [];
4868
+ const nativeFilePaths = [];
4869
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
4870
+ if (!moduleEntry.isDirectory()) continue;
4871
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
4872
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
4873
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
4874
+ const iosDirectory = path.join(moduleDirectory, "ios");
4875
+ if (isDirectory(iosDirectory)) {
4876
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
4877
+ }
4878
+ }
4879
+ return nativeFilePaths;
4880
+ };
4881
+ const checkExpoGitignore = (context) => {
4882
+ const { rootDirectory } = context;
4883
+ const diagnostics = [];
4884
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
4885
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
4886
+ rule: "expo-gitignore",
4887
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
4888
+ help: "Add `.expo/` to your .gitignore"
4889
+ }));
4890
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
4891
+ rule: "expo-gitignore",
4892
+ 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",
4893
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
4894
+ }));
4895
+ return diagnostics;
4896
+ };
4897
+ const LOCKFILE_NAMES = [
4898
+ "pnpm-lock.yaml",
4899
+ "yarn.lock",
4900
+ "package-lock.json",
4901
+ "bun.lockb",
4902
+ "bun.lock"
4903
+ ];
4904
+ const checkExpoLockfile = (context) => {
4905
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
4906
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
4907
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
4908
+ rule: "expo-lockfile",
4909
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
4910
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
4911
+ })];
4912
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
4913
+ rule: "expo-lockfile",
4914
+ 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`,
4915
+ help: "Delete the lock files for the package managers you are not using and keep only one"
4916
+ })];
4917
+ return [];
4918
+ };
4919
+ const METRO_CONFIG_FILE_NAMES = [
4920
+ "metro.config.js",
4921
+ "metro.config.cjs",
4922
+ "metro.config.mjs",
4923
+ "metro.config.ts"
4924
+ ];
4925
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
4926
+ "expo/metro-config",
4927
+ "@sentry/react-native/metro",
4928
+ "getSentryExpoConfig"
4929
+ ];
4930
+ const checkExpoMetroConfig = (context) => {
4931
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
4932
+ if (metroConfigPath === void 0) return [];
4933
+ let contents;
4934
+ try {
4935
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
4936
+ } catch {
4937
+ return [];
4938
+ }
4939
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
4940
+ return [buildExpoDiagnostic({
4941
+ rule: "expo-metro-config",
4942
+ filePath: path.basename(metroConfigPath),
4943
+ 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",
4944
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
4945
+ })];
4946
+ };
4947
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
4948
+ const checkExpoPackageJsonConflicts = (context) => {
4949
+ const { packageJson } = context;
4950
+ const diagnostics = [];
4951
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
4952
+ if (conflictingScriptNames.length > 0) {
4953
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
4954
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
4955
+ diagnostics.push(buildExpoDiagnostic({
4956
+ rule: "expo-package-json-conflict",
4957
+ 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" : ""}`,
4958
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
4959
+ }));
4960
+ }
4961
+ const packageName = packageJson.name;
4962
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
4963
+ rule: "expo-package-json-conflict",
4964
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
4965
+ help: "Rename your package so it no longer matches one of its dependencies"
4966
+ }));
4967
+ return diagnostics;
4968
+ };
4969
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
4970
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
4971
+ const checkExpoRouterReactNavigation = (context) => {
4972
+ const { expoSdkMajor } = context;
4973
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
4974
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
4975
+ if (!context.directDependencyNames.has("expo-router")) return [];
4976
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
4977
+ if (reactNavigationNames.length === 0) return [];
4978
+ return [buildExpoDiagnostic({
4979
+ rule: "expo-router-no-react-navigation",
4980
+ 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"}`,
4981
+ 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/"
4982
+ })];
4983
+ };
4984
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
4985
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
4986
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
4987
+ const checkExpoVectorIcons = (context) => {
4988
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
4989
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
4990
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
4991
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
4992
+ return [buildExpoDiagnostic({
4993
+ rule: "expo-vector-icons-conflict",
4994
+ 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",
4995
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
4996
+ })];
4997
+ };
4998
+ const checkExpoProject = (rootDirectory, project) => {
4999
+ if (project.expoVersion === null) return [];
5000
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
5001
+ return [
5002
+ ...checkExpoFlaggedDependencies(context),
5003
+ ...checkExpoDependencyOverrides(context),
5004
+ ...checkExpoRouterReactNavigation(context),
5005
+ ...checkExpoVectorIcons(context),
5006
+ ...checkExpoPackageJsonConflicts(context),
5007
+ ...checkExpoLockfile(context),
5008
+ ...checkExpoGitignore(context),
5009
+ ...checkExpoEnvLocalFiles(context),
5010
+ ...checkExpoMetroConfig(context)
5011
+ ];
5012
+ };
4464
5013
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4465
5014
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
4466
5015
  const PACKAGE_JSON_FILE = "package.json";
@@ -4711,6 +5260,28 @@ const collectIgnorePatterns = (rootDirectory) => {
4711
5260
  cachedPatternsByRoot.set(rootDirectory, patterns);
4712
5261
  return patterns;
4713
5262
  };
5263
+ /**
5264
+ * Resolves a path to its canonical, symlink-free form, falling back to
5265
+ * the input when it cannot be realpath'd (broken symlink, permission
5266
+ * error) so a best-effort normalization never throws.
5267
+ *
5268
+ * deslop's dead-code module graph is collected with `fast-glob` (which
5269
+ * keeps the scan root's symlinks intact) while imports are resolved
5270
+ * through `oxc-resolver` (which returns realpath'd targets). When the
5271
+ * project root sits behind a symlink — e.g. macOS iCloud-synced
5272
+ * `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
5273
+ * spaces diverge: every resolved import misses the graph and the files
5274
+ * they point at (commonly every `@/…` alias target) are mis-reported as
5275
+ * unreachable. Canonicalizing the root before the scan keeps both path
5276
+ * spaces in agreement.
5277
+ */
5278
+ const toCanonicalPath = (filePath) => {
5279
+ try {
5280
+ return fs.realpathSync(filePath);
5281
+ } catch {
5282
+ return filePath;
5283
+ }
5284
+ };
4714
5285
  const DEAD_CODE_PLUGIN = "deslop";
4715
5286
  const DEAD_CODE_CATEGORY = "Maintainability";
4716
5287
  const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
@@ -4967,7 +5538,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
4967
5538
  });
4968
5539
  });
4969
5540
  const checkDeadCode = async (options) => {
4970
- const { rootDirectory, userConfig } = options;
5541
+ const { userConfig } = options;
5542
+ const rootDirectory = toCanonicalPath(options.rootDirectory);
4971
5543
  if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
4972
5544
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
4973
5545
  const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
@@ -5120,8 +5692,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
5120
5692
  const cache = yield* Cache.make({
5121
5693
  capacity: 16,
5122
5694
  timeToLive: CONFIG_CACHE_TTL_MS,
5123
- lookup: (directory) => Effect.sync(() => {
5124
- const loaded = loadConfigWithSource(directory);
5695
+ lookup: (directory) => Effect.promise(async () => {
5696
+ const loaded = await loadConfigWithSource(directory);
5125
5697
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
5126
5698
  return {
5127
5699
  config: loaded?.config ?? null,
@@ -5718,6 +6290,7 @@ const buildCapabilities = (project) => {
5718
6290
  const capabilities = /* @__PURE__ */ new Set();
5719
6291
  capabilities.add(project.framework);
5720
6292
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
6293
+ if (project.expoVersion !== null) capabilities.add("expo");
5721
6294
  const reactMajor = project.reactMajorVersion;
5722
6295
  if (reactMajor !== null) {
5723
6296
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -5889,10 +6462,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
5889
6462
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
5890
6463
  return fs.realpathSync(rootDirectory);
5891
6464
  };
6465
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
6466
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
6467
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
6468
+ };
5892
6469
  const applyRuleSeverityControls = (rules, severityControls) => {
5893
6470
  const enabledRules = {};
5894
6471
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
5895
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
6472
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
5896
6473
  if (severity === "off") continue;
5897
6474
  enabledRules[ruleKey] = severity;
5898
6475
  }
@@ -5934,7 +6511,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
5934
6511
  category: rule.category
5935
6512
  }, severityControls);
5936
6513
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
5937
- const severity = explicitSeverity ?? rule.severity;
6514
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
5938
6515
  if (severity === "off") continue;
5939
6516
  enabledReactDoctorRules[registryEntry.key] = severity;
5940
6517
  }
@@ -5991,6 +6568,44 @@ const dedupeDiagnostics = (diagnostics) => {
5991
6568
  }
5992
6569
  return uniqueDiagnostics;
5993
6570
  };
6571
+ /**
6572
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
6573
+ * once, returning results in input order. A pool of workers each pulls the
6574
+ * next not-yet-started index until the list drains — so a worker that
6575
+ * finishes a fast task immediately picks up the next one (greedy load
6576
+ * balancing), which matters when tasks have uneven durations (oxlint
6577
+ * batches do).
6578
+ *
6579
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
6580
+ * no further tasks are started, the already-in-flight tasks are awaited to
6581
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
6582
+ * rejects with that first error. This keeps the caller's fail-fast retry
6583
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
6584
+ * top of a still-running first one.
6585
+ */
6586
+ const mapWithConcurrency = async (items, concurrency, task) => {
6587
+ const results = new Array(items.length);
6588
+ if (items.length === 0) return results;
6589
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
6590
+ let nextIndex = 0;
6591
+ const errors = [];
6592
+ const runWorker = async () => {
6593
+ while (errors.length === 0) {
6594
+ const index = nextIndex;
6595
+ nextIndex += 1;
6596
+ if (index >= items.length) return;
6597
+ try {
6598
+ results[index] = await task(items[index], index);
6599
+ } catch (error) {
6600
+ errors.push(error);
6601
+ return;
6602
+ }
6603
+ }
6604
+ };
6605
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
6606
+ if (errors.length > 0) throw errors[0];
6607
+ return results;
6608
+ };
5994
6609
  const getPublicEnvPrefix = (framework) => {
5995
6610
  switch (framework) {
5996
6611
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -6673,6 +7288,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
6673
7288
  */
6674
7289
  const spawnLintBatches = async (input) => {
6675
7290
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
7291
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
6676
7292
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
6677
7293
  const allDiagnostics = [];
6678
7294
  const droppedFiles = [];
@@ -6692,23 +7308,31 @@ const spawnLintBatches = async (input) => {
6692
7308
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
6693
7309
  }
6694
7310
  };
7311
+ let startedFileCount = 0;
6695
7312
  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 {
7313
+ let displayedFileCount = 0;
7314
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
7315
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
7316
+ if (displayedFileCount < ceiling) {
7317
+ displayedFileCount += 1;
7318
+ onFileProgress(displayedFileCount, totalFileCount);
7319
+ }
7320
+ }, 50) : null;
7321
+ progressTimer?.unref?.();
7322
+ try {
7323
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
7324
+ startedFileCount += batch.length;
6705
7325
  const batchDiagnostics = await spawnLintBatch(batch);
6706
- allDiagnostics.push(...batchDiagnostics);
6707
7326
  scannedFileCount += batch.length;
6708
- onFileProgress?.(scannedFileCount, totalFileCount);
6709
- } finally {
6710
- if (progressInterval !== null) clearInterval(progressInterval);
6711
- }
7327
+ if (onFileProgress) {
7328
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
7329
+ onFileProgress(displayedFileCount, totalFileCount);
7330
+ }
7331
+ return batchDiagnostics;
7332
+ });
7333
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
7334
+ } finally {
7335
+ if (progressTimer !== null) clearInterval(progressTimer);
6712
7336
  }
6713
7337
  if (droppedFiles.length > 0 && onPartialFailure) {
6714
7338
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6835,7 +7459,8 @@ const runOxlint = async (options) => {
6835
7459
  onPartialFailure,
6836
7460
  onFileProgress: options.onFileProgress,
6837
7461
  spawnTimeoutMs,
6838
- outputMaxBytes
7462
+ outputMaxBytes,
7463
+ concurrency: options.concurrency
6839
7464
  });
6840
7465
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
6841
7466
  try {
@@ -6903,6 +7528,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6903
7528
  const partialFailures = yield* LintPartialFailures;
6904
7529
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
6905
7530
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
7531
+ const concurrency = yield* OxlintConcurrency;
6906
7532
  const collectedFailures = [];
6907
7533
  const diagnostics = yield* Effect.tryPromise({
6908
7534
  try: () => runOxlint({
@@ -6921,7 +7547,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6921
7547
  },
6922
7548
  onFileProgress: input.onFileProgress,
6923
7549
  spawnTimeoutMs,
6924
- outputMaxBytes
7550
+ outputMaxBytes,
7551
+ concurrency
6925
7552
  }),
6926
7553
  catch: ensureReactDoctorError
6927
7554
  });
@@ -7245,7 +7872,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7245
7872
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
7246
7873
  yield* beforeLint(project, lintIncludePaths ?? void 0);
7247
7874
  const isDiffMode = input.includePaths.length > 0;
7248
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
7875
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
7249
7876
  const transform = buildDiagnosticPipeline({
7250
7877
  rootDirectory: scanDirectory,
7251
7878
  userConfig: resolvedConfig.config,
@@ -7254,7 +7881,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7254
7881
  showWarnings
7255
7882
  });
7256
7883
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
7257
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
7884
+ const environmentDiagnostics = isDiffMode ? [] : [
7885
+ ...checkReducedMotion(scanDirectory),
7886
+ ...checkPnpmHardening(scanDirectory),
7887
+ ...checkExpoProject(scanDirectory, project)
7888
+ ];
7258
7889
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7259
7890
  const lintFailure = yield* Ref.make({
7260
7891
  didFail: false,
@@ -7266,6 +7897,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7266
7897
  didFail: false,
7267
7898
  reason: null
7268
7899
  });
7900
+ const scanConcurrency = yield* OxlintConcurrency;
7901
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
7269
7902
  const scanProgress = yield* progressService.start("Scanning...");
7270
7903
  const scanStartTime = Date.now();
7271
7904
  let lastReportedTotalFileCount = 0;
@@ -7282,7 +7915,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7282
7915
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
7283
7916
  onFileProgress: (scannedFileCount, totalFileCount) => {
7284
7917
  lastReportedTotalFileCount = totalFileCount;
7285
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
7918
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
7286
7919
  }
7287
7920
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
7288
7921
  yield* Ref.set(lintFailure, {
@@ -7314,7 +7947,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7314
7947
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
7315
7948
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
7316
7949
  else if (input.suppressScanSummary) yield* scanProgress.stop();
7317
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
7950
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
7318
7951
  yield* reporterService.finalize;
7319
7952
  const finalDiagnostics = [
7320
7953
  ...envCollected,
@@ -7366,7 +7999,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7366
7999
  "inspect.isCi": input.isCi,
7367
8000
  "inspect.scoreSurface": input.scoreSurface ?? "score"
7368
8001
  } }));
7369
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7370
8002
  const parseNodeVersion = (versionString) => {
7371
8003
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
7372
8004
  return {
@@ -7665,6 +8297,26 @@ const buildJsonReport = (input) => {
7665
8297
  };
7666
8298
  };
7667
8299
  /**
8300
+ * Single source of truth for the skipped-check accounting shared by the
8301
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
8302
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
8303
+ * failed lint / dead-code pass instead of a false "all clear", so the
8304
+ * branch logic lives here once.
8305
+ */
8306
+ const buildSkippedChecks = (input) => {
8307
+ const skippedChecks = [];
8308
+ if (input.didLintFail) skippedChecks.push("lint");
8309
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
8310
+ const skippedCheckReasons = {};
8311
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
8312
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
8313
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
8314
+ return {
8315
+ skippedChecks,
8316
+ skippedCheckReasons
8317
+ };
8318
+ };
8319
+ /**
7668
8320
  * Programmatic façade over `Git.diffSelection`. Async because the
7669
8321
  * Git service runs through Effect's `ChildProcess` (true subprocess
7670
8322
  * spawn, not `spawnSync`).
@@ -7770,7 +8422,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
7770
8422
  const clearAutoSuppressionCaches = () => {};
7771
8423
  //#endregion
7772
8424
  //#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);
8425
+ 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
8426
  const buildInspectProgram = (scanTarget, options, configOverride) => {
7775
8427
  const effectiveConfig = configOverride ?? scanTarget.userConfig;
7776
8428
  const includePaths = options.includePaths ?? [];
@@ -7779,7 +8431,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7779
8431
  includePaths,
7780
8432
  customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
7781
8433
  respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
7782
- warnings: options.warnings ?? effectiveConfig?.warnings ?? false,
8434
+ warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
7783
8435
  adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
7784
8436
  ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
7785
8437
  runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
@@ -7789,13 +8441,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7789
8441
  };
7790
8442
  const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7791
8443
  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;
8444
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
7799
8445
  return {
7800
8446
  diagnostics: [...output.diagnostics],
7801
8447
  score: output.score,
@@ -7807,8 +8453,8 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7807
8453
  };
7808
8454
  const diagnose = async (directory, options = {}) => {
7809
8455
  const startTime = globalThis.performance.now();
7810
- 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);
8456
+ const program = buildInspectProgram(await resolveScanTarget(directory), options);
8457
+ return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
7812
8458
  };
7813
8459
  //#endregion
7814
8460
  //#region src/index.ts
@@ -7817,6 +8463,7 @@ const clearCaches = () => {
7817
8463
  clearConfigCache();
7818
8464
  clearPackageJsonCache();
7819
8465
  clearIgnorePatternsCache();
8466
+ clearPackageRoleCache();
7820
8467
  clearAutoSuppressionCaches();
7821
8468
  };
7822
8469
  const toJsonReport = (result, options) => buildJsonReport({
@@ -7840,4 +8487,5 @@ const toJsonReport = (result, options) => buildJsonReport({
7840
8487
  //#endregion
7841
8488
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
7842
8489
 
7843
- //# sourceMappingURL=index.js.map
8490
+ //# sourceMappingURL=index.js.map
8491
+ //# debugId=fce73b02-d297-5132-af08-817f37e1467c