react-doctor 0.2.14-dev.8b313ba → 0.2.14-dev.9777f1a

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,7 +14,10 @@ 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";
19
+ import { parseJSON5 } from "confbox";
20
+ import { createJiti } from "jiti";
18
21
  import * as Fiber from "effect/Fiber";
19
22
  import * as Filter from "effect/Filter";
20
23
  import * as Option from "effect/Option";
@@ -26,7 +29,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
26
29
  import * as NodePath from "@effect/platform-node-shared/NodePath";
27
30
  import * as ChildProcess from "effect/unstable/process/ChildProcess";
28
31
  import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
29
- import os from "node:os";
30
32
  import * as ts from "typescript";
31
33
  import { gzipSync } from "node:zlib";
32
34
  //#region \0rolldown/runtime.js
@@ -2874,29 +2876,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
2874
2876
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
2875
2877
  };
2876
2878
  };
2877
- const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
2878
- if (predicate(rootPackageJson)) return true;
2879
+ const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
2880
+ const rootValue = select(rootPackageJson);
2881
+ if (rootValue !== null) return rootValue;
2879
2882
  const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
2880
- if (patterns.length === 0) return false;
2883
+ if (patterns.length === 0) return null;
2881
2884
  const visitedDirectories = /* @__PURE__ */ new Set();
2882
2885
  for (const pattern of patterns) {
2883
- const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
2886
+ const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
2884
2887
  for (const workspaceDirectory of directories) {
2885
2888
  if (visitedDirectories.has(workspaceDirectory)) continue;
2886
2889
  visitedDirectories.add(workspaceDirectory);
2887
- if (predicate(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
2890
+ const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
2891
+ if (value !== null) return value;
2888
2892
  }
2889
2893
  }
2890
- return false;
2894
+ return null;
2891
2895
  };
2896
+ const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
2892
2897
  const NAMES = new Set([
2893
2898
  "react-native",
2894
2899
  "react-native-tvos",
2895
- "expo",
2896
- "expo-router",
2897
- "@expo/cli",
2898
- "@expo/metro-config",
2899
- "@expo/metro-runtime",
2900
+ ...new Set([
2901
+ "expo",
2902
+ "expo-router",
2903
+ "@expo/cli",
2904
+ "@expo/metro-config",
2905
+ "@expo/metro-runtime"
2906
+ ]),
2900
2907
  "react-native-windows",
2901
2908
  "react-native-macos"
2902
2909
  ]);
@@ -2920,6 +2927,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
2920
2927
  return false;
2921
2928
  };
2922
2929
  const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
2930
+ const getExpoDependencySpec = (packageJson) => {
2931
+ const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
2932
+ return typeof spec === "string" ? spec : null;
2933
+ };
2934
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
2923
2935
  const getPreactVersion = (packageJson) => {
2924
2936
  return {
2925
2937
  ...packageJson.peerDependencies,
@@ -3159,6 +3171,19 @@ const discoverProject = (directory) => {
3159
3171
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
3160
3172
  const sourceFileCount = countSourceFiles(directory);
3161
3173
  const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
3174
+ let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
3175
+ if (expoVersion !== null && isCatalogReference(expoVersion)) {
3176
+ const catalogName = extractCatalogName(expoVersion);
3177
+ let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
3178
+ if (!resolvedExpoVersion) {
3179
+ const monorepoRoot = findMonorepoRoot(directory);
3180
+ if (monorepoRoot) {
3181
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
3182
+ if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
3183
+ }
3184
+ }
3185
+ expoVersion = resolvedExpoVersion ?? expoVersion;
3186
+ }
3162
3187
  const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
3163
3188
  const preactVersion = getPreactVersion(packageJson);
3164
3189
  const projectInfo = {
@@ -3176,6 +3201,7 @@ const discoverProject = (directory) => {
3176
3201
  preactVersion,
3177
3202
  preactMajorVersion: parseReactMajor(preactVersion),
3178
3203
  hasReactNativeWorkspace,
3204
+ expoVersion,
3179
3205
  hasReanimated,
3180
3206
  sourceFileCount
3181
3207
  };
@@ -3265,7 +3291,14 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
3265
3291
  "tsconfig.json",
3266
3292
  "tsconfig.base.json",
3267
3293
  "package.json",
3268
- "react-doctor.config.json",
3294
+ "doctor.config.ts",
3295
+ "doctor.config.mts",
3296
+ "doctor.config.cts",
3297
+ "doctor.config.js",
3298
+ "doctor.config.mjs",
3299
+ "doctor.config.cjs",
3300
+ "doctor.config.json",
3301
+ "doctor.config.jsonc",
3269
3302
  "oxlint.json",
3270
3303
  ".oxlintrc.json"
3271
3304
  ];
@@ -3280,6 +3313,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
3280
3313
  "Accessibility",
3281
3314
  "Maintainability"
3282
3315
  ];
3316
+ const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
3317
+ const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
3318
+ const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
3283
3319
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
3284
3320
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
3285
3321
  var InvalidGlobPatternError = class extends Error {
@@ -3399,10 +3435,11 @@ const restampSeverity = (diagnostic, override) => {
3399
3435
  */
3400
3436
  const buildRuleSeverityControls = (config) => {
3401
3437
  if (!config) return void 0;
3402
- if (config.rules === void 0 && config.categories === void 0) return void 0;
3438
+ if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
3403
3439
  return {
3404
3440
  ...config.rules !== void 0 ? { rules: config.rules } : {},
3405
- ...config.categories !== void 0 ? { categories: config.categories } : {}
3441
+ ...config.categories !== void 0 ? { categories: config.categories } : {},
3442
+ ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
3406
3443
  };
3407
3444
  };
3408
3445
  const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
@@ -3766,6 +3803,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
3766
3803
  }
3767
3804
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
3768
3805
  };
3806
+ const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
3807
+ const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
3808
+ const findNearestPackageDirectory = (filename) => {
3809
+ if (!filename) return null;
3810
+ const fromCache = cachedPackageDirectoryByFilename.get(filename);
3811
+ if (fromCache !== void 0) return fromCache;
3812
+ let currentDirectory = path.dirname(filename);
3813
+ while (true) {
3814
+ const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
3815
+ let hasPackageJson = false;
3816
+ try {
3817
+ hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
3818
+ } catch {
3819
+ hasPackageJson = false;
3820
+ }
3821
+ if (hasPackageJson) {
3822
+ cachedPackageDirectoryByFilename.set(filename, currentDirectory);
3823
+ return currentDirectory;
3824
+ }
3825
+ const parentDirectory = path.dirname(currentDirectory);
3826
+ if (parentDirectory === currentDirectory) {
3827
+ cachedPackageDirectoryByFilename.set(filename, null);
3828
+ return null;
3829
+ }
3830
+ currentDirectory = parentDirectory;
3831
+ }
3832
+ };
3833
+ const readManifest = (packageJsonPath) => {
3834
+ try {
3835
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
3836
+ if (typeof parsed === "object" && parsed !== null) return parsed;
3837
+ return null;
3838
+ } catch {
3839
+ return null;
3840
+ }
3841
+ };
3842
+ const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
3843
+ const classifyByDirectoryCohort = (packageDirectory) => {
3844
+ let current = packageDirectory;
3845
+ while (true) {
3846
+ if (path.basename(current) === "apps") return "app";
3847
+ const parent = path.dirname(current);
3848
+ if (parent === current) return null;
3849
+ current = parent;
3850
+ }
3851
+ };
3852
+ const clearPackageRoleCache = () => {
3853
+ cachedRoleByPackageDirectory.clear();
3854
+ cachedPackageDirectoryByFilename.clear();
3855
+ };
3856
+ const classifyPackageRole = (filename) => {
3857
+ if (!filename) return "unknown";
3858
+ const packageDirectory = findNearestPackageDirectory(filename);
3859
+ if (!packageDirectory) return "unknown";
3860
+ const cached = cachedRoleByPackageDirectory.get(packageDirectory);
3861
+ if (cached !== void 0) return cached;
3862
+ const manifest = readManifest(path.join(packageDirectory, "package.json"));
3863
+ let result;
3864
+ if (manifest && hasPublishContract(manifest)) result = "library";
3865
+ else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
3866
+ cachedRoleByPackageDirectory.set(packageDirectory, result);
3867
+ return result;
3868
+ };
3769
3869
  /**
3770
3870
  * Resolves the absolute path to read for a diagnostic's `filePath`,
3771
3871
  * accounting for the various shapes oxlint emits:
@@ -3928,6 +4028,15 @@ const buildDiagnosticPipeline = (input) => {
3928
4028
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
3929
4029
  const fileLinesCache = /* @__PURE__ */ new Map();
3930
4030
  const testFileCache = /* @__PURE__ */ new Map();
4031
+ const libraryFileCache = /* @__PURE__ */ new Map();
4032
+ const isLibraryFile = (filePath) => {
4033
+ let cached = libraryFileCache.get(filePath);
4034
+ if (cached === void 0) {
4035
+ cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
4036
+ libraryFileCache.set(filePath, cached);
4037
+ }
4038
+ return cached;
4039
+ };
3931
4040
  const getFileLines = (filePath) => {
3932
4041
  const cached = fileLinesCache.get(filePath);
3933
4042
  if (cached !== void 0) return cached;
@@ -3954,6 +4063,10 @@ const buildDiagnosticPipeline = (input) => {
3954
4063
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
3955
4064
  return false;
3956
4065
  };
4066
+ const isAppOnlyRule = (ruleIdentifier) => {
4067
+ for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
4068
+ return false;
4069
+ };
3957
4070
  const isRnRawTextSuppressedByConfig = (diagnostic) => {
3958
4071
  if (diagnostic.rule !== "rn-no-raw-text") return false;
3959
4072
  if (diagnostic.line <= 0) return false;
@@ -3968,8 +4081,10 @@ const buildDiagnosticPipeline = (input) => {
3968
4081
  if (shouldAutoSuppress(diagnostic)) return null;
3969
4082
  let current = diagnostic;
3970
4083
  let explicitSeverityOverride;
4084
+ let explicitRuleOverride;
3971
4085
  if (severityControls) {
3972
4086
  const { ruleKey, category } = getDiagnosticRuleIdentity(current);
4087
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
3973
4088
  explicitSeverityOverride = resolveRuleSeverityOverride({
3974
4089
  ruleKey,
3975
4090
  category
@@ -3977,6 +4092,9 @@ const buildDiagnosticPipeline = (input) => {
3977
4092
  if (explicitSeverityOverride === "off") return null;
3978
4093
  if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
3979
4094
  }
4095
+ if (explicitRuleOverride === void 0) {
4096
+ if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
4097
+ }
3980
4098
  if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
3981
4099
  if (userConfig) {
3982
4100
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
@@ -4162,6 +4280,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
4162
4280
  }).pipe(Layer.provide(FetchHttpClient.layer));
4163
4281
  }).pipe(Effect.orDie));
4164
4282
  /**
4283
+ * Resolves a requested lint worker count to a clamped integer within
4284
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
4285
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
4286
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
4287
+ */
4288
+ const resolveScanConcurrency = (requested) => {
4289
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
4290
+ if (!Number.isFinite(desired) || desired < 1) return 1;
4291
+ return Math.max(1, Math.min(Math.floor(desired), 16));
4292
+ };
4293
+ /**
4165
4294
  * Per-batch oxlint wall-clock budget. Reads from the env var on
4166
4295
  * startup so the eval harness can raise the budget under sandbox
4167
4296
  * microVMs without recompiling react-doctor. Tests override via
@@ -4181,6 +4310,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
4181
4310
  * tests that exercise the cap behavior.
4182
4311
  */
4183
4312
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
4313
+ /**
4314
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
4315
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
4316
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
4317
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
4318
+ * CI callers that never touch the flag:
4319
+ *
4320
+ * - unset / `0` / `false` / `off` → `1` (serial)
4321
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
4322
+ * - a positive integer → that many workers (clamped)
4323
+ *
4324
+ * The resolved value is always within
4325
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
4326
+ */
4327
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
4328
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
4329
+ if (raw === void 0) return 1;
4330
+ const normalized = raw.trim().toLowerCase();
4331
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
4332
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
4333
+ const parsed = Number.parseInt(normalized, 10);
4334
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
4335
+ return resolveScanConcurrency(parsed);
4336
+ } }) {};
4184
4337
  const DIAGNOSTIC_SURFACES = [
4185
4338
  "cli",
4186
4339
  "prComment",
@@ -4337,69 +4490,109 @@ const validateConfigTypes = (config) => {
4337
4490
  const warn = (message) => {
4338
4491
  Effect.runSync(Console.warn(message));
4339
4492
  };
4340
- const CONFIG_FILENAME = "react-doctor.config.json";
4493
+ const CONFIG_BASENAME = "doctor.config";
4494
+ const CONFIG_EXTENSIONS = [
4495
+ "ts",
4496
+ "mts",
4497
+ "cts",
4498
+ "js",
4499
+ "mjs",
4500
+ "cjs",
4501
+ "json",
4502
+ "jsonc"
4503
+ ];
4504
+ const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
4505
+ const PACKAGE_JSON_FILENAME = "package.json";
4341
4506
  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);
4507
+ const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
4508
+ const jiti = createJiti(import.meta.url);
4509
+ const formatError = (error) => error instanceof Error ? error.message : String(error);
4510
+ const loadModuleConfig = async (filePath) => {
4511
+ const imported = await jiti.import(filePath);
4512
+ return imported?.default ?? imported;
4513
+ };
4514
+ const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
4515
+ const readEmbeddedPackageJsonConfig = (directory) => {
4516
+ const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
4517
+ if (!isFile(packageJsonPath)) return null;
4518
+ try {
4519
+ const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
4359
4520
  if (isPlainObject(packageJson)) {
4360
4521
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4361
- if (isPlainObject(embeddedConfig)) return {
4362
- config: validateConfigTypes(embeddedConfig),
4363
- sourceDirectory: directory
4522
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
4523
+ }
4524
+ } catch {}
4525
+ return null;
4526
+ };
4527
+ const loadPackageJsonConfig = (directory) => {
4528
+ const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
4529
+ if (!embeddedConfig) return null;
4530
+ return {
4531
+ config: validateConfigTypes(embeddedConfig),
4532
+ sourceDirectory: directory,
4533
+ configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
4534
+ format: "package-json"
4535
+ };
4536
+ };
4537
+ const loadConfigFromDirectory = async (directory) => {
4538
+ let sawBrokenConfigFile = false;
4539
+ for (const extension of CONFIG_EXTENSIONS) {
4540
+ const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
4541
+ if (!isFile(filePath)) continue;
4542
+ const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
4543
+ try {
4544
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
4545
+ if (isPlainObject(parsed)) return {
4546
+ status: "found",
4547
+ loaded: {
4548
+ config: validateConfigTypes(parsed),
4549
+ sourceDirectory: directory,
4550
+ configFilePath: filePath,
4551
+ format: isDataFile ? "json" : "module"
4552
+ }
4364
4553
  };
4554
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
4555
+ sawBrokenConfigFile = true;
4556
+ } catch (error) {
4557
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
4558
+ sawBrokenConfigFile = true;
4365
4559
  }
4366
- } catch {
4367
- return null;
4368
4560
  }
4369
- return null;
4561
+ const packageJsonConfig = loadPackageJsonConfig(directory);
4562
+ if (packageJsonConfig) return {
4563
+ status: "found",
4564
+ loaded: packageJsonConfig
4565
+ };
4566
+ 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).`);
4567
+ return {
4568
+ status: sawBrokenConfigFile ? "invalid" : "absent",
4569
+ loaded: null
4570
+ };
4370
4571
  };
4371
4572
  const cachedConfigs = /* @__PURE__ */ new Map();
4372
4573
  const clearConfigCache = () => {
4373
4574
  cachedConfigs.clear();
4374
4575
  };
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
- }
4576
+ const loadConfigWalkingUp = async (rootDirectory) => {
4577
+ const localResult = await loadConfigFromDirectory(rootDirectory);
4578
+ if (localResult.status === "found") return localResult.loaded;
4579
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
4387
4580
  let ancestorDirectory = path.dirname(rootDirectory);
4388
4581
  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
- }
4582
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
4583
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
4584
+ if (isProjectBoundary(ancestorDirectory)) return null;
4398
4585
  ancestorDirectory = path.dirname(ancestorDirectory);
4399
4586
  }
4400
- cachedConfigs.set(rootDirectory, null);
4401
4587
  return null;
4402
4588
  };
4589
+ const loadConfigWithSource = (rootDirectory) => {
4590
+ const cached = cachedConfigs.get(rootDirectory);
4591
+ if (cached !== void 0) return cached;
4592
+ const loadPromise = loadConfigWalkingUp(rootDirectory);
4593
+ cachedConfigs.set(rootDirectory, loadPromise);
4594
+ return loadPromise;
4595
+ };
4403
4596
  const resolveConfigRootDir = (config, configSourceDirectory) => {
4404
4597
  if (!config || !configSourceDirectory) return null;
4405
4598
  const rawRootDir = config.rootDir;
@@ -4414,11 +4607,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
4414
4607
  }
4415
4608
  return resolvedRootDir;
4416
4609
  };
4417
- const resolveDiagnoseTarget = (directory) => {
4610
+ const resolveDiagnoseTarget = (directory, options = {}) => {
4418
4611
  if (isFile(path.join(directory, "package.json"))) return directory;
4419
4612
  const reactSubprojects = discoverReactSubprojects(directory);
4420
4613
  if (reactSubprojects.length === 0) return null;
4421
4614
  if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
4615
+ if (options.allowAmbiguous === true) return null;
4422
4616
  throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
4423
4617
  };
4424
4618
  /**
@@ -4426,13 +4620,13 @@ const resolveDiagnoseTarget = (directory) => {
4426
4620
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
4427
4621
  *
4428
4622
  * 1. Resolve the requested directory to absolute.
4429
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
4430
- * if present.
4623
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
4431
4624
  * 3. Honor `config.rootDir` to redirect the scan to a nested
4432
4625
  * project root, if configured.
4433
4626
  * 4. Walk into a nested React subproject when the requested
4434
4627
  * directory has no `package.json` of its own (raises
4435
- * `AmbiguousProjectError` when multiple candidates exist).
4628
+ * `AmbiguousProjectError` when multiple candidates exist unless
4629
+ * the caller opts into keeping the wrapper directory).
4436
4630
  *
4437
4631
  * Throws `ProjectNotFoundError` when neither the requested directory
4438
4632
  * nor any discoverable nested project has a `package.json`.
@@ -4444,14 +4638,14 @@ const resolveDiagnoseTarget = (directory) => {
4444
4638
  * via its own cache). Routing through `resolveScanTarget` keeps every
4445
4639
  * shell in agreement on what "the scan directory" means.
4446
4640
  */
4447
- const resolveScanTarget = (requestedDirectory) => {
4641
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
4448
4642
  const absoluteRequested = path.resolve(requestedDirectory);
4449
- const loadedConfig = loadConfigWithSource(absoluteRequested);
4643
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
4450
4644
  const userConfig = loadedConfig?.config ?? null;
4451
4645
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4452
4646
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
4453
4647
  const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
4454
- const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
4648
+ const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
4455
4649
  if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
4456
4650
  return {
4457
4651
  resolvedDirectory,
@@ -4461,6 +4655,359 @@ const resolveScanTarget = (requestedDirectory) => {
4461
4655
  didRedirectViaRootDir: redirectedDirectory !== null
4462
4656
  };
4463
4657
  };
4658
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
4659
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
4660
+ const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
4661
+ return {
4662
+ rootDirectory,
4663
+ packageJson,
4664
+ directDependencyNames: getDirectDependencyNames(packageJson),
4665
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
4666
+ };
4667
+ };
4668
+ const buildExpoDiagnostic = (input) => ({
4669
+ filePath: input.filePath ?? "package.json",
4670
+ plugin: "react-doctor",
4671
+ rule: input.rule,
4672
+ severity: input.severity ?? "warning",
4673
+ message: input.message,
4674
+ help: input.help,
4675
+ line: input.line ?? 0,
4676
+ column: input.column ?? 0,
4677
+ category: input.category ?? "Correctness"
4678
+ });
4679
+ const CRITICAL_OVERRIDE_NAMES = new Set([
4680
+ "@expo/cli",
4681
+ "@expo/config",
4682
+ "@expo/metro-config",
4683
+ "@expo/metro-runtime",
4684
+ "@expo/metro",
4685
+ "metro"
4686
+ ]);
4687
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
4688
+ const collectOverrideNames = (packageJson) => new Set([
4689
+ ...Object.keys(packageJson.overrides ?? {}),
4690
+ ...Object.keys(packageJson.resolutions ?? {}),
4691
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
4692
+ ]);
4693
+ const checkExpoDependencyOverrides = (context) => {
4694
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
4695
+ if (overriddenCriticalNames.length === 0) return [];
4696
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
4697
+ return [buildExpoDiagnostic({
4698
+ rule: "expo-no-conflicting-dependency-override",
4699
+ 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`,
4700
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
4701
+ })];
4702
+ };
4703
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
4704
+ const result = spawnSync("git", [
4705
+ "check-ignore",
4706
+ "-q",
4707
+ absolutePath
4708
+ ], {
4709
+ cwd: rootDirectory,
4710
+ stdio: [
4711
+ "ignore",
4712
+ "ignore",
4713
+ "ignore"
4714
+ ]
4715
+ });
4716
+ if (result.error) return null;
4717
+ if (result.status === 0) return true;
4718
+ if (result.status === 1) return false;
4719
+ return null;
4720
+ };
4721
+ const LOCAL_ENV_FILE_NAMES = [
4722
+ ".env.local",
4723
+ ".env.development.local",
4724
+ ".env.production.local",
4725
+ ".env.test.local"
4726
+ ];
4727
+ const checkExpoEnvLocalFiles = (context) => {
4728
+ const { rootDirectory } = context;
4729
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
4730
+ const filePath = path.join(rootDirectory, fileName);
4731
+ if (!isFile(filePath)) return false;
4732
+ return isPathGitIgnored(rootDirectory, filePath) === false;
4733
+ });
4734
+ if (committedEnvFiles.length === 0) return [];
4735
+ return [buildExpoDiagnostic({
4736
+ rule: "expo-env-local-not-gitignored",
4737
+ category: "Security",
4738
+ 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`,
4739
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
4740
+ })];
4741
+ };
4742
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
4743
+ 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";
4744
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
4745
+ const unimodulesEntry = (packageName) => ({
4746
+ packageName,
4747
+ rule: "expo-no-unimodules-packages",
4748
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
4749
+ help: UNIMODULES_HELP
4750
+ });
4751
+ const FLAGGED_DEPENDENCIES = [
4752
+ unimodulesEntry("@unimodules/core"),
4753
+ unimodulesEntry("@unimodules/react-native-adapter"),
4754
+ unimodulesEntry("react-native-unimodules"),
4755
+ {
4756
+ packageName: "expo-cli",
4757
+ rule: "expo-no-cli-dependencies",
4758
+ 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`",
4759
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
4760
+ },
4761
+ {
4762
+ packageName: "eas-cli",
4763
+ rule: "expo-no-cli-dependencies",
4764
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
4765
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
4766
+ },
4767
+ {
4768
+ packageName: "expo-modules-autolinking",
4769
+ rule: "expo-no-redundant-dependency",
4770
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
4771
+ help: "Remove `expo-modules-autolinking` from your package.json"
4772
+ },
4773
+ {
4774
+ packageName: "expo-dev-launcher",
4775
+ rule: "expo-no-redundant-dependency",
4776
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4777
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
4778
+ },
4779
+ {
4780
+ packageName: "expo-dev-menu",
4781
+ rule: "expo-no-redundant-dependency",
4782
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4783
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
4784
+ },
4785
+ {
4786
+ packageName: "expo-modules-core",
4787
+ rule: "expo-no-redundant-dependency",
4788
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
4789
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
4790
+ },
4791
+ {
4792
+ packageName: "@expo/metro-config",
4793
+ rule: "expo-no-redundant-dependency",
4794
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
4795
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
4796
+ },
4797
+ {
4798
+ packageName: "@types/react-native",
4799
+ rule: "expo-no-redundant-dependency",
4800
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
4801
+ help: "Remove `@types/react-native` from your package.json",
4802
+ minSdkMajor: 48
4803
+ },
4804
+ {
4805
+ packageName: "@expo/config-plugins",
4806
+ rule: "expo-no-redundant-dependency",
4807
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
4808
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
4809
+ minSdkMajor: 48
4810
+ },
4811
+ {
4812
+ packageName: "@expo/prebuild-config",
4813
+ rule: "expo-no-redundant-dependency",
4814
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
4815
+ help: "Remove `@expo/prebuild-config` from your package.json",
4816
+ minSdkMajor: 53
4817
+ },
4818
+ {
4819
+ packageName: "expo-permissions",
4820
+ rule: "expo-no-redundant-dependency",
4821
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
4822
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
4823
+ minSdkMajor: 50
4824
+ },
4825
+ {
4826
+ packageName: "expo-app-loading",
4827
+ rule: "expo-no-redundant-dependency",
4828
+ message: "\"expo-app-loading\" was removed in SDK 49",
4829
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
4830
+ minSdkMajor: 49
4831
+ },
4832
+ {
4833
+ packageName: "expo-firebase-analytics",
4834
+ rule: "expo-no-redundant-dependency",
4835
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
4836
+ help: FIREBASE_HELP,
4837
+ minSdkMajor: 48
4838
+ },
4839
+ {
4840
+ packageName: "expo-firebase-recaptcha",
4841
+ rule: "expo-no-redundant-dependency",
4842
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
4843
+ help: FIREBASE_HELP,
4844
+ minSdkMajor: 48
4845
+ },
4846
+ {
4847
+ packageName: "expo-firebase-core",
4848
+ rule: "expo-no-redundant-dependency",
4849
+ message: "\"expo-firebase-core\" was removed in SDK 48",
4850
+ help: FIREBASE_HELP,
4851
+ minSdkMajor: 48
4852
+ }
4853
+ ];
4854
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
4855
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
4856
+ if (flaggedDependency.minSdkMajor === void 0) return true;
4857
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
4858
+ }).map((flaggedDependency) => buildExpoDiagnostic({
4859
+ rule: flaggedDependency.rule,
4860
+ message: flaggedDependency.message,
4861
+ help: flaggedDependency.help
4862
+ }));
4863
+ const findLocalModuleNativeFiles = (rootDirectory) => {
4864
+ const modulesDirectory = path.join(rootDirectory, "modules");
4865
+ if (!isDirectory(modulesDirectory)) return [];
4866
+ const nativeFilePaths = [];
4867
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
4868
+ if (!moduleEntry.isDirectory()) continue;
4869
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
4870
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
4871
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
4872
+ const iosDirectory = path.join(moduleDirectory, "ios");
4873
+ if (isDirectory(iosDirectory)) {
4874
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
4875
+ }
4876
+ }
4877
+ return nativeFilePaths;
4878
+ };
4879
+ const checkExpoGitignore = (context) => {
4880
+ const { rootDirectory } = context;
4881
+ const diagnostics = [];
4882
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
4883
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
4884
+ rule: "expo-gitignore",
4885
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
4886
+ help: "Add `.expo/` to your .gitignore"
4887
+ }));
4888
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
4889
+ rule: "expo-gitignore",
4890
+ 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",
4891
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
4892
+ }));
4893
+ return diagnostics;
4894
+ };
4895
+ const LOCKFILE_NAMES = [
4896
+ "pnpm-lock.yaml",
4897
+ "yarn.lock",
4898
+ "package-lock.json",
4899
+ "bun.lockb",
4900
+ "bun.lock"
4901
+ ];
4902
+ const checkExpoLockfile = (context) => {
4903
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
4904
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
4905
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
4906
+ rule: "expo-lockfile",
4907
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
4908
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
4909
+ })];
4910
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
4911
+ rule: "expo-lockfile",
4912
+ 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`,
4913
+ help: "Delete the lock files for the package managers you are not using and keep only one"
4914
+ })];
4915
+ return [];
4916
+ };
4917
+ const METRO_CONFIG_FILE_NAMES = [
4918
+ "metro.config.js",
4919
+ "metro.config.cjs",
4920
+ "metro.config.mjs",
4921
+ "metro.config.ts"
4922
+ ];
4923
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
4924
+ "expo/metro-config",
4925
+ "@sentry/react-native/metro",
4926
+ "getSentryExpoConfig"
4927
+ ];
4928
+ const checkExpoMetroConfig = (context) => {
4929
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
4930
+ if (metroConfigPath === void 0) return [];
4931
+ let contents;
4932
+ try {
4933
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
4934
+ } catch {
4935
+ return [];
4936
+ }
4937
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
4938
+ return [buildExpoDiagnostic({
4939
+ rule: "expo-metro-config",
4940
+ filePath: path.basename(metroConfigPath),
4941
+ 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",
4942
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
4943
+ })];
4944
+ };
4945
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
4946
+ const checkExpoPackageJsonConflicts = (context) => {
4947
+ const { packageJson } = context;
4948
+ const diagnostics = [];
4949
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
4950
+ if (conflictingScriptNames.length > 0) {
4951
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
4952
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
4953
+ diagnostics.push(buildExpoDiagnostic({
4954
+ rule: "expo-package-json-conflict",
4955
+ 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" : ""}`,
4956
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
4957
+ }));
4958
+ }
4959
+ const packageName = packageJson.name;
4960
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
4961
+ rule: "expo-package-json-conflict",
4962
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
4963
+ help: "Rename your package so it no longer matches one of its dependencies"
4964
+ }));
4965
+ return diagnostics;
4966
+ };
4967
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
4968
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
4969
+ const checkExpoRouterReactNavigation = (context) => {
4970
+ const { expoSdkMajor } = context;
4971
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
4972
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
4973
+ if (!context.directDependencyNames.has("expo-router")) return [];
4974
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
4975
+ if (reactNavigationNames.length === 0) return [];
4976
+ return [buildExpoDiagnostic({
4977
+ rule: "expo-router-no-react-navigation",
4978
+ 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"}`,
4979
+ 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/"
4980
+ })];
4981
+ };
4982
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
4983
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
4984
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
4985
+ const checkExpoVectorIcons = (context) => {
4986
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
4987
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
4988
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
4989
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
4990
+ return [buildExpoDiagnostic({
4991
+ rule: "expo-vector-icons-conflict",
4992
+ 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",
4993
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
4994
+ })];
4995
+ };
4996
+ const checkExpoProject = (rootDirectory, project) => {
4997
+ if (project.expoVersion === null) return [];
4998
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
4999
+ return [
5000
+ ...checkExpoFlaggedDependencies(context),
5001
+ ...checkExpoDependencyOverrides(context),
5002
+ ...checkExpoRouterReactNavigation(context),
5003
+ ...checkExpoVectorIcons(context),
5004
+ ...checkExpoPackageJsonConflicts(context),
5005
+ ...checkExpoLockfile(context),
5006
+ ...checkExpoGitignore(context),
5007
+ ...checkExpoEnvLocalFiles(context),
5008
+ ...checkExpoMetroConfig(context)
5009
+ ];
5010
+ };
4464
5011
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4465
5012
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
4466
5013
  const PACKAGE_JSON_FILE = "package.json";
@@ -5143,8 +5690,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
5143
5690
  const cache = yield* Cache.make({
5144
5691
  capacity: 16,
5145
5692
  timeToLive: CONFIG_CACHE_TTL_MS,
5146
- lookup: (directory) => Effect.sync(() => {
5147
- const loaded = loadConfigWithSource(directory);
5693
+ lookup: (directory) => Effect.promise(async () => {
5694
+ const loaded = await loadConfigWithSource(directory);
5148
5695
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
5149
5696
  return {
5150
5697
  config: loaded?.config ?? null,
@@ -5741,6 +6288,7 @@ const buildCapabilities = (project) => {
5741
6288
  const capabilities = /* @__PURE__ */ new Set();
5742
6289
  capabilities.add(project.framework);
5743
6290
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
6291
+ if (project.expoVersion !== null) capabilities.add("expo");
5744
6292
  const reactMajor = project.reactMajorVersion;
5745
6293
  if (reactMajor !== null) {
5746
6294
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -5912,10 +6460,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
5912
6460
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
5913
6461
  return fs.realpathSync(rootDirectory);
5914
6462
  };
6463
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
6464
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
6465
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
6466
+ };
5915
6467
  const applyRuleSeverityControls = (rules, severityControls) => {
5916
6468
  const enabledRules = {};
5917
6469
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
5918
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
6470
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
5919
6471
  if (severity === "off") continue;
5920
6472
  enabledRules[ruleKey] = severity;
5921
6473
  }
@@ -5957,7 +6509,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
5957
6509
  category: rule.category
5958
6510
  }, severityControls);
5959
6511
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
5960
- const severity = explicitSeverity ?? rule.severity;
6512
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
5961
6513
  if (severity === "off") continue;
5962
6514
  enabledReactDoctorRules[registryEntry.key] = severity;
5963
6515
  }
@@ -6014,6 +6566,44 @@ const dedupeDiagnostics = (diagnostics) => {
6014
6566
  }
6015
6567
  return uniqueDiagnostics;
6016
6568
  };
6569
+ /**
6570
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
6571
+ * once, returning results in input order. A pool of workers each pulls the
6572
+ * next not-yet-started index until the list drains — so a worker that
6573
+ * finishes a fast task immediately picks up the next one (greedy load
6574
+ * balancing), which matters when tasks have uneven durations (oxlint
6575
+ * batches do).
6576
+ *
6577
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
6578
+ * no further tasks are started, the already-in-flight tasks are awaited to
6579
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
6580
+ * rejects with that first error. This keeps the caller's fail-fast retry
6581
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
6582
+ * top of a still-running first one.
6583
+ */
6584
+ const mapWithConcurrency = async (items, concurrency, task) => {
6585
+ const results = new Array(items.length);
6586
+ if (items.length === 0) return results;
6587
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
6588
+ let nextIndex = 0;
6589
+ const errors = [];
6590
+ const runWorker = async () => {
6591
+ while (errors.length === 0) {
6592
+ const index = nextIndex;
6593
+ nextIndex += 1;
6594
+ if (index >= items.length) return;
6595
+ try {
6596
+ results[index] = await task(items[index], index);
6597
+ } catch (error) {
6598
+ errors.push(error);
6599
+ return;
6600
+ }
6601
+ }
6602
+ };
6603
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
6604
+ if (errors.length > 0) throw errors[0];
6605
+ return results;
6606
+ };
6017
6607
  const getPublicEnvPrefix = (framework) => {
6018
6608
  switch (framework) {
6019
6609
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -6696,6 +7286,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
6696
7286
  */
6697
7287
  const spawnLintBatches = async (input) => {
6698
7288
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
7289
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
6699
7290
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
6700
7291
  const allDiagnostics = [];
6701
7292
  const droppedFiles = [];
@@ -6715,23 +7306,31 @@ const spawnLintBatches = async (input) => {
6715
7306
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
6716
7307
  }
6717
7308
  };
7309
+ let startedFileCount = 0;
6718
7310
  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 {
7311
+ let displayedFileCount = 0;
7312
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
7313
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
7314
+ if (displayedFileCount < ceiling) {
7315
+ displayedFileCount += 1;
7316
+ onFileProgress(displayedFileCount, totalFileCount);
7317
+ }
7318
+ }, 50) : null;
7319
+ progressTimer?.unref?.();
7320
+ try {
7321
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
7322
+ startedFileCount += batch.length;
6728
7323
  const batchDiagnostics = await spawnLintBatch(batch);
6729
- allDiagnostics.push(...batchDiagnostics);
6730
7324
  scannedFileCount += batch.length;
6731
- onFileProgress?.(scannedFileCount, totalFileCount);
6732
- } finally {
6733
- if (progressInterval !== null) clearInterval(progressInterval);
6734
- }
7325
+ if (onFileProgress) {
7326
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
7327
+ onFileProgress(displayedFileCount, totalFileCount);
7328
+ }
7329
+ return batchDiagnostics;
7330
+ });
7331
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
7332
+ } finally {
7333
+ if (progressTimer !== null) clearInterval(progressTimer);
6735
7334
  }
6736
7335
  if (droppedFiles.length > 0 && onPartialFailure) {
6737
7336
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6858,7 +7457,8 @@ const runOxlint = async (options) => {
6858
7457
  onPartialFailure,
6859
7458
  onFileProgress: options.onFileProgress,
6860
7459
  spawnTimeoutMs,
6861
- outputMaxBytes
7460
+ outputMaxBytes,
7461
+ concurrency: options.concurrency
6862
7462
  });
6863
7463
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
6864
7464
  try {
@@ -6926,6 +7526,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6926
7526
  const partialFailures = yield* LintPartialFailures;
6927
7527
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
6928
7528
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
7529
+ const concurrency = yield* OxlintConcurrency;
6929
7530
  const collectedFailures = [];
6930
7531
  const diagnostics = yield* Effect.tryPromise({
6931
7532
  try: () => runOxlint({
@@ -6944,7 +7545,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6944
7545
  },
6945
7546
  onFileProgress: input.onFileProgress,
6946
7547
  spawnTimeoutMs,
6947
- outputMaxBytes
7548
+ outputMaxBytes,
7549
+ concurrency
6948
7550
  }),
6949
7551
  catch: ensureReactDoctorError
6950
7552
  });
@@ -7268,7 +7870,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7268
7870
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
7269
7871
  yield* beforeLint(project, lintIncludePaths ?? void 0);
7270
7872
  const isDiffMode = input.includePaths.length > 0;
7271
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
7873
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
7272
7874
  const transform = buildDiagnosticPipeline({
7273
7875
  rootDirectory: scanDirectory,
7274
7876
  userConfig: resolvedConfig.config,
@@ -7277,7 +7879,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7277
7879
  showWarnings
7278
7880
  });
7279
7881
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
7280
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
7882
+ const environmentDiagnostics = isDiffMode ? [] : [
7883
+ ...checkReducedMotion(scanDirectory),
7884
+ ...checkPnpmHardening(scanDirectory),
7885
+ ...checkExpoProject(scanDirectory, project)
7886
+ ];
7281
7887
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7282
7888
  const lintFailure = yield* Ref.make({
7283
7889
  didFail: false,
@@ -7289,6 +7895,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7289
7895
  didFail: false,
7290
7896
  reason: null
7291
7897
  });
7898
+ const scanConcurrency = yield* OxlintConcurrency;
7899
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
7292
7900
  const scanProgress = yield* progressService.start("Scanning...");
7293
7901
  const scanStartTime = Date.now();
7294
7902
  let lastReportedTotalFileCount = 0;
@@ -7305,7 +7913,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7305
7913
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
7306
7914
  onFileProgress: (scannedFileCount, totalFileCount) => {
7307
7915
  lastReportedTotalFileCount = totalFileCount;
7308
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
7916
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
7309
7917
  }
7310
7918
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
7311
7919
  yield* Ref.set(lintFailure, {
@@ -7337,7 +7945,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7337
7945
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
7338
7946
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
7339
7947
  else if (input.suppressScanSummary) yield* scanProgress.stop();
7340
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
7948
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
7341
7949
  yield* reporterService.finalize;
7342
7950
  const finalDiagnostics = [
7343
7951
  ...envCollected,
@@ -7389,7 +7997,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7389
7997
  "inspect.isCi": input.isCi,
7390
7998
  "inspect.scoreSurface": input.scoreSurface ?? "score"
7391
7999
  } }));
7392
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7393
8000
  const parseNodeVersion = (versionString) => {
7394
8001
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
7395
8002
  return {
@@ -7688,6 +8295,26 @@ const buildJsonReport = (input) => {
7688
8295
  };
7689
8296
  };
7690
8297
  /**
8298
+ * Single source of truth for the skipped-check accounting shared by the
8299
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
8300
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
8301
+ * failed lint / dead-code pass instead of a false "all clear", so the
8302
+ * branch logic lives here once.
8303
+ */
8304
+ const buildSkippedChecks = (input) => {
8305
+ const skippedChecks = [];
8306
+ if (input.didLintFail) skippedChecks.push("lint");
8307
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
8308
+ const skippedCheckReasons = {};
8309
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
8310
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
8311
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
8312
+ return {
8313
+ skippedChecks,
8314
+ skippedCheckReasons
8315
+ };
8316
+ };
8317
+ /**
7691
8318
  * Programmatic façade over `Git.diffSelection`. Async because the
7692
8319
  * Git service runs through Effect's `ChildProcess` (true subprocess
7693
8320
  * spawn, not `spawnSync`).
@@ -7793,7 +8420,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
7793
8420
  const clearAutoSuppressionCaches = () => {};
7794
8421
  //#endregion
7795
8422
  //#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);
8423
+ 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
8424
  const buildInspectProgram = (scanTarget, options, configOverride) => {
7798
8425
  const effectiveConfig = configOverride ?? scanTarget.userConfig;
7799
8426
  const includePaths = options.includePaths ?? [];
@@ -7802,7 +8429,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7802
8429
  includePaths,
7803
8430
  customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
7804
8431
  respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
7805
- warnings: options.warnings ?? effectiveConfig?.warnings ?? false,
8432
+ warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
7806
8433
  adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
7807
8434
  ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
7808
8435
  runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
@@ -7812,13 +8439,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7812
8439
  };
7813
8440
  const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7814
8441
  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;
8442
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
7822
8443
  return {
7823
8444
  diagnostics: [...output.diagnostics],
7824
8445
  score: output.score,
@@ -7830,8 +8451,8 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7830
8451
  };
7831
8452
  const diagnose = async (directory, options = {}) => {
7832
8453
  const startTime = globalThis.performance.now();
7833
- 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);
8454
+ const program = buildInspectProgram(await resolveScanTarget(directory), options);
8455
+ return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
7835
8456
  };
7836
8457
  //#endregion
7837
8458
  //#region src/index.ts
@@ -7840,6 +8461,7 @@ const clearCaches = () => {
7840
8461
  clearConfigCache();
7841
8462
  clearPackageJsonCache();
7842
8463
  clearIgnorePatternsCache();
8464
+ clearPackageRoleCache();
7843
8465
  clearAutoSuppressionCaches();
7844
8466
  };
7845
8467
  const toJsonReport = (result, options) => buildJsonReport({