react-doctor 0.2.14-dev.daef23c → 0.2.14-dev.e9e71bb

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
  ];
@@ -4247,6 +4282,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
4247
4282
  }).pipe(Layer.provide(FetchHttpClient.layer));
4248
4283
  }).pipe(Effect.orDie));
4249
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
+ /**
4250
4296
  * Per-batch oxlint wall-clock budget. Reads from the env var on
4251
4297
  * startup so the eval harness can raise the budget under sandbox
4252
4298
  * microVMs without recompiling react-doctor. Tests override via
@@ -4266,6 +4312,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
4266
4312
  * tests that exercise the cap behavior.
4267
4313
  */
4268
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
+ } }) {};
4269
4339
  const DIAGNOSTIC_SURFACES = [
4270
4340
  "cli",
4271
4341
  "prComment",
@@ -4422,69 +4492,109 @@ const validateConfigTypes = (config) => {
4422
4492
  const warn = (message) => {
4423
4493
  Effect.runSync(Console.warn(message));
4424
4494
  };
4425
- 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";
4426
4508
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
4427
- const loadConfigFromDirectory = (directory) => {
4428
- const configFilePath = path.join(directory, CONFIG_FILENAME);
4429
- if (isFile(configFilePath)) try {
4430
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
4431
- const parsed = JSON.parse(fileContent);
4432
- if (isPlainObject(parsed)) return {
4433
- config: validateConfigTypes(parsed),
4434
- sourceDirectory: directory
4435
- };
4436
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4437
- } catch (error) {
4438
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
4439
- }
4440
- const packageJsonPath = path.join(directory, "package.json");
4441
- if (isFile(packageJsonPath)) try {
4442
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
4443
- 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"));
4444
4522
  if (isPlainObject(packageJson)) {
4445
4523
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4446
- if (isPlainObject(embeddedConfig)) return {
4447
- config: validateConfigTypes(embeddedConfig),
4448
- 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
+ }
4449
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;
4450
4561
  }
4451
- } catch {
4452
- return null;
4453
4562
  }
4454
- 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
+ };
4455
4573
  };
4456
4574
  const cachedConfigs = /* @__PURE__ */ new Map();
4457
4575
  const clearConfigCache = () => {
4458
4576
  cachedConfigs.clear();
4459
4577
  };
4460
- const loadConfigWithSource = (rootDirectory) => {
4461
- const cached = cachedConfigs.get(rootDirectory);
4462
- if (cached !== void 0) return cached;
4463
- const localConfig = loadConfigFromDirectory(rootDirectory);
4464
- if (localConfig) {
4465
- cachedConfigs.set(rootDirectory, localConfig);
4466
- return localConfig;
4467
- }
4468
- if (isProjectBoundary(rootDirectory)) {
4469
- cachedConfigs.set(rootDirectory, null);
4470
- return null;
4471
- }
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;
4472
4582
  let ancestorDirectory = path.dirname(rootDirectory);
4473
4583
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4474
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4475
- if (ancestorConfig) {
4476
- cachedConfigs.set(rootDirectory, ancestorConfig);
4477
- return ancestorConfig;
4478
- }
4479
- if (isProjectBoundary(ancestorDirectory)) {
4480
- cachedConfigs.set(rootDirectory, null);
4481
- return null;
4482
- }
4584
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
4585
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
4586
+ if (isProjectBoundary(ancestorDirectory)) return null;
4483
4587
  ancestorDirectory = path.dirname(ancestorDirectory);
4484
4588
  }
4485
- cachedConfigs.set(rootDirectory, null);
4486
4589
  return null;
4487
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
+ };
4488
4598
  const resolveConfigRootDir = (config, configSourceDirectory) => {
4489
4599
  if (!config || !configSourceDirectory) return null;
4490
4600
  const rawRootDir = config.rootDir;
@@ -4512,8 +4622,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
4512
4622
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
4513
4623
  *
4514
4624
  * 1. Resolve the requested directory to absolute.
4515
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
4516
- * if present.
4625
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
4517
4626
  * 3. Honor `config.rootDir` to redirect the scan to a nested
4518
4627
  * project root, if configured.
4519
4628
  * 4. Walk into a nested React subproject when the requested
@@ -4531,9 +4640,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
4531
4640
  * via its own cache). Routing through `resolveScanTarget` keeps every
4532
4641
  * shell in agreement on what "the scan directory" means.
4533
4642
  */
4534
- const resolveScanTarget = (requestedDirectory, options = {}) => {
4643
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
4535
4644
  const absoluteRequested = path.resolve(requestedDirectory);
4536
- const loadedConfig = loadConfigWithSource(absoluteRequested);
4645
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
4537
4646
  const userConfig = loadedConfig?.config ?? null;
4538
4647
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4539
4648
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
@@ -4548,6 +4657,359 @@ const resolveScanTarget = (requestedDirectory, options = {}) => {
4548
4657
  didRedirectViaRootDir: redirectedDirectory !== null
4549
4658
  };
4550
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
+ };
4551
5013
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4552
5014
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
4553
5015
  const PACKAGE_JSON_FILE = "package.json";
@@ -5230,8 +5692,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
5230
5692
  const cache = yield* Cache.make({
5231
5693
  capacity: 16,
5232
5694
  timeToLive: CONFIG_CACHE_TTL_MS,
5233
- lookup: (directory) => Effect.sync(() => {
5234
- const loaded = loadConfigWithSource(directory);
5695
+ lookup: (directory) => Effect.promise(async () => {
5696
+ const loaded = await loadConfigWithSource(directory);
5235
5697
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
5236
5698
  return {
5237
5699
  config: loaded?.config ?? null,
@@ -5828,6 +6290,7 @@ const buildCapabilities = (project) => {
5828
6290
  const capabilities = /* @__PURE__ */ new Set();
5829
6291
  capabilities.add(project.framework);
5830
6292
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
6293
+ if (project.expoVersion !== null) capabilities.add("expo");
5831
6294
  const reactMajor = project.reactMajorVersion;
5832
6295
  if (reactMajor !== null) {
5833
6296
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -6105,6 +6568,44 @@ const dedupeDiagnostics = (diagnostics) => {
6105
6568
  }
6106
6569
  return uniqueDiagnostics;
6107
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
+ };
6108
6609
  const getPublicEnvPrefix = (framework) => {
6109
6610
  switch (framework) {
6110
6611
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -6787,6 +7288,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
6787
7288
  */
6788
7289
  const spawnLintBatches = async (input) => {
6789
7290
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
7291
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
6790
7292
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
6791
7293
  const allDiagnostics = [];
6792
7294
  const droppedFiles = [];
@@ -6806,23 +7308,31 @@ const spawnLintBatches = async (input) => {
6806
7308
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
6807
7309
  }
6808
7310
  };
7311
+ let startedFileCount = 0;
6809
7312
  let scannedFileCount = 0;
6810
- for (const batch of fileBatches) {
6811
- let batchFileIndex = 0;
6812
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
6813
- if (batchFileIndex < batch.length) {
6814
- batchFileIndex += 1;
6815
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
6816
- }
6817
- }, 50) : null;
6818
- 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;
6819
7325
  const batchDiagnostics = await spawnLintBatch(batch);
6820
- allDiagnostics.push(...batchDiagnostics);
6821
7326
  scannedFileCount += batch.length;
6822
- onFileProgress?.(scannedFileCount, totalFileCount);
6823
- } finally {
6824
- if (progressInterval !== null) clearInterval(progressInterval);
6825
- }
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);
6826
7336
  }
6827
7337
  if (droppedFiles.length > 0 && onPartialFailure) {
6828
7338
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6949,7 +7459,8 @@ const runOxlint = async (options) => {
6949
7459
  onPartialFailure,
6950
7460
  onFileProgress: options.onFileProgress,
6951
7461
  spawnTimeoutMs,
6952
- outputMaxBytes
7462
+ outputMaxBytes,
7463
+ concurrency: options.concurrency
6953
7464
  });
6954
7465
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
6955
7466
  try {
@@ -7017,6 +7528,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
7017
7528
  const partialFailures = yield* LintPartialFailures;
7018
7529
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
7019
7530
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
7531
+ const concurrency = yield* OxlintConcurrency;
7020
7532
  const collectedFailures = [];
7021
7533
  const diagnostics = yield* Effect.tryPromise({
7022
7534
  try: () => runOxlint({
@@ -7035,7 +7547,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
7035
7547
  },
7036
7548
  onFileProgress: input.onFileProgress,
7037
7549
  spawnTimeoutMs,
7038
- outputMaxBytes
7550
+ outputMaxBytes,
7551
+ concurrency
7039
7552
  }),
7040
7553
  catch: ensureReactDoctorError
7041
7554
  });
@@ -7359,7 +7872,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7359
7872
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
7360
7873
  yield* beforeLint(project, lintIncludePaths ?? void 0);
7361
7874
  const isDiffMode = input.includePaths.length > 0;
7362
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
7875
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
7363
7876
  const transform = buildDiagnosticPipeline({
7364
7877
  rootDirectory: scanDirectory,
7365
7878
  userConfig: resolvedConfig.config,
@@ -7368,7 +7881,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7368
7881
  showWarnings
7369
7882
  });
7370
7883
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
7371
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
7884
+ const environmentDiagnostics = isDiffMode ? [] : [
7885
+ ...checkReducedMotion(scanDirectory),
7886
+ ...checkPnpmHardening(scanDirectory),
7887
+ ...checkExpoProject(scanDirectory, project)
7888
+ ];
7372
7889
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7373
7890
  const lintFailure = yield* Ref.make({
7374
7891
  didFail: false,
@@ -7380,6 +7897,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7380
7897
  didFail: false,
7381
7898
  reason: null
7382
7899
  });
7900
+ const scanConcurrency = yield* OxlintConcurrency;
7901
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
7383
7902
  const scanProgress = yield* progressService.start("Scanning...");
7384
7903
  const scanStartTime = Date.now();
7385
7904
  let lastReportedTotalFileCount = 0;
@@ -7396,7 +7915,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7396
7915
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
7397
7916
  onFileProgress: (scannedFileCount, totalFileCount) => {
7398
7917
  lastReportedTotalFileCount = totalFileCount;
7399
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
7918
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
7400
7919
  }
7401
7920
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
7402
7921
  yield* Ref.set(lintFailure, {
@@ -7428,7 +7947,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7428
7947
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
7429
7948
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
7430
7949
  else if (input.suppressScanSummary) yield* scanProgress.stop();
7431
- 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}`);
7432
7951
  yield* reporterService.finalize;
7433
7952
  const finalDiagnostics = [
7434
7953
  ...envCollected,
@@ -7480,7 +7999,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7480
7999
  "inspect.isCi": input.isCi,
7481
8000
  "inspect.scoreSurface": input.scoreSurface ?? "score"
7482
8001
  } }));
7483
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7484
8002
  const parseNodeVersion = (versionString) => {
7485
8003
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
7486
8004
  return {
@@ -7779,6 +8297,26 @@ const buildJsonReport = (input) => {
7779
8297
  };
7780
8298
  };
7781
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
+ /**
7782
8320
  * Programmatic façade over `Git.diffSelection`. Async because the
7783
8321
  * Git service runs through Effect's `ChildProcess` (true subprocess
7784
8322
  * spawn, not `spawnSync`).
@@ -7884,7 +8422,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
7884
8422
  const clearAutoSuppressionCaches = () => {};
7885
8423
  //#endregion
7886
8424
  //#region ../api/dist/index.js
7887
- 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);
7888
8426
  const buildInspectProgram = (scanTarget, options, configOverride) => {
7889
8427
  const effectiveConfig = configOverride ?? scanTarget.userConfig;
7890
8428
  const includePaths = options.includePaths ?? [];
@@ -7893,7 +8431,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7893
8431
  includePaths,
7894
8432
  customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
7895
8433
  respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
7896
- warnings: options.warnings ?? effectiveConfig?.warnings ?? false,
8434
+ warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
7897
8435
  adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
7898
8436
  ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
7899
8437
  runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
@@ -7903,13 +8441,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7903
8441
  };
7904
8442
  const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7905
8443
  if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
7906
- const skippedChecks = [];
7907
- if (output.didLintFail) skippedChecks.push("lint");
7908
- if (output.didDeadCodeFail) skippedChecks.push("dead-code");
7909
- const skippedCheckReasons = {};
7910
- if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
7911
- else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
7912
- if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
8444
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
7913
8445
  return {
7914
8446
  diagnostics: [...output.diagnostics],
7915
8447
  score: output.score,
@@ -7921,8 +8453,8 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7921
8453
  };
7922
8454
  const diagnose = async (directory, options = {}) => {
7923
8455
  const startTime = globalThis.performance.now();
7924
- const program = buildInspectProgram(resolveScanTarget(directory), options);
7925
- 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);
7926
8458
  };
7927
8459
  //#endregion
7928
8460
  //#region src/index.ts
@@ -7955,4 +8487,5 @@ const toJsonReport = (result, options) => buildJsonReport({
7955
8487
  //#endregion
7956
8488
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
7957
8489
 
7958
- //# sourceMappingURL=index.js.map
8490
+ //# sourceMappingURL=index.js.map
8491
+ //# debugId=fce73b02-d297-5132-af08-817f37e1467c