react-doctor 0.2.14-dev.09fe1ff → 0.2.14-dev.24425b1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import * as Redacted from "effect/Redacted";
14
14
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
15
15
  import * as Otlp from "effect/unstable/observability/Otlp";
16
16
  import * as Context from "effect/Context";
17
+ import os from "node:os";
17
18
  import * as Console from "effect/Console";
18
19
  import * as Fiber from "effect/Fiber";
19
20
  import * as Filter from "effect/Filter";
@@ -26,7 +27,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
26
27
  import * as NodePath from "@effect/platform-node-shared/NodePath";
27
28
  import * as ChildProcess from "effect/unstable/process/ChildProcess";
28
29
  import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
29
- import os from "node:os";
30
30
  import * as ts from "typescript";
31
31
  import { gzipSync } from "node:zlib";
32
32
  //#region \0rolldown/runtime.js
@@ -2874,29 +2874,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
2874
2874
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
2875
2875
  };
2876
2876
  };
2877
- const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
2878
- if (predicate(rootPackageJson)) return true;
2877
+ const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
2878
+ const rootValue = select(rootPackageJson);
2879
+ if (rootValue !== null) return rootValue;
2879
2880
  const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
2880
- if (patterns.length === 0) return false;
2881
+ if (patterns.length === 0) return null;
2881
2882
  const visitedDirectories = /* @__PURE__ */ new Set();
2882
2883
  for (const pattern of patterns) {
2883
- const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
2884
+ const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
2884
2885
  for (const workspaceDirectory of directories) {
2885
2886
  if (visitedDirectories.has(workspaceDirectory)) continue;
2886
2887
  visitedDirectories.add(workspaceDirectory);
2887
- if (predicate(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
2888
+ const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
2889
+ if (value !== null) return value;
2888
2890
  }
2889
2891
  }
2890
- return false;
2892
+ return null;
2891
2893
  };
2894
+ const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
2892
2895
  const NAMES = new Set([
2893
2896
  "react-native",
2894
2897
  "react-native-tvos",
2895
- "expo",
2896
- "expo-router",
2897
- "@expo/cli",
2898
- "@expo/metro-config",
2899
- "@expo/metro-runtime",
2898
+ ...new Set([
2899
+ "expo",
2900
+ "expo-router",
2901
+ "@expo/cli",
2902
+ "@expo/metro-config",
2903
+ "@expo/metro-runtime"
2904
+ ]),
2900
2905
  "react-native-windows",
2901
2906
  "react-native-macos"
2902
2907
  ]);
@@ -2920,6 +2925,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
2920
2925
  return false;
2921
2926
  };
2922
2927
  const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
2928
+ const getExpoDependencySpec = (packageJson) => {
2929
+ const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
2930
+ return typeof spec === "string" ? spec : null;
2931
+ };
2932
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
2923
2933
  const getPreactVersion = (packageJson) => {
2924
2934
  return {
2925
2935
  ...packageJson.peerDependencies,
@@ -3159,6 +3169,19 @@ const discoverProject = (directory) => {
3159
3169
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
3160
3170
  const sourceFileCount = countSourceFiles(directory);
3161
3171
  const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
3172
+ let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
3173
+ if (expoVersion !== null && isCatalogReference(expoVersion)) {
3174
+ const catalogName = extractCatalogName(expoVersion);
3175
+ let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
3176
+ if (!resolvedExpoVersion) {
3177
+ const monorepoRoot = findMonorepoRoot(directory);
3178
+ if (monorepoRoot) {
3179
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
3180
+ if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
3181
+ }
3182
+ }
3183
+ expoVersion = resolvedExpoVersion ?? expoVersion;
3184
+ }
3162
3185
  const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
3163
3186
  const preactVersion = getPreactVersion(packageJson);
3164
3187
  const projectInfo = {
@@ -3176,6 +3199,7 @@ const discoverProject = (directory) => {
3176
3199
  preactVersion,
3177
3200
  preactMajorVersion: parseReactMajor(preactVersion),
3178
3201
  hasReactNativeWorkspace,
3202
+ expoVersion,
3179
3203
  hasReanimated,
3180
3204
  sourceFileCount
3181
3205
  };
@@ -3280,6 +3304,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
3280
3304
  "Accessibility",
3281
3305
  "Maintainability"
3282
3306
  ];
3307
+ const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
3308
+ const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
3309
+ const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
3283
3310
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
3284
3311
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
3285
3312
  var InvalidGlobPatternError = class extends Error {
@@ -3399,10 +3426,11 @@ const restampSeverity = (diagnostic, override) => {
3399
3426
  */
3400
3427
  const buildRuleSeverityControls = (config) => {
3401
3428
  if (!config) return void 0;
3402
- if (config.rules === void 0 && config.categories === void 0) return void 0;
3429
+ if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
3403
3430
  return {
3404
3431
  ...config.rules !== void 0 ? { rules: config.rules } : {},
3405
- ...config.categories !== void 0 ? { categories: config.categories } : {}
3432
+ ...config.categories !== void 0 ? { categories: config.categories } : {},
3433
+ ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
3406
3434
  };
3407
3435
  };
3408
3436
  const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
@@ -3766,6 +3794,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
3766
3794
  }
3767
3795
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
3768
3796
  };
3797
+ const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
3798
+ const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
3799
+ const findNearestPackageDirectory = (filename) => {
3800
+ if (!filename) return null;
3801
+ const fromCache = cachedPackageDirectoryByFilename.get(filename);
3802
+ if (fromCache !== void 0) return fromCache;
3803
+ let currentDirectory = path.dirname(filename);
3804
+ while (true) {
3805
+ const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
3806
+ let hasPackageJson = false;
3807
+ try {
3808
+ hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
3809
+ } catch {
3810
+ hasPackageJson = false;
3811
+ }
3812
+ if (hasPackageJson) {
3813
+ cachedPackageDirectoryByFilename.set(filename, currentDirectory);
3814
+ return currentDirectory;
3815
+ }
3816
+ const parentDirectory = path.dirname(currentDirectory);
3817
+ if (parentDirectory === currentDirectory) {
3818
+ cachedPackageDirectoryByFilename.set(filename, null);
3819
+ return null;
3820
+ }
3821
+ currentDirectory = parentDirectory;
3822
+ }
3823
+ };
3824
+ const readManifest = (packageJsonPath) => {
3825
+ try {
3826
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
3827
+ if (typeof parsed === "object" && parsed !== null) return parsed;
3828
+ return null;
3829
+ } catch {
3830
+ return null;
3831
+ }
3832
+ };
3833
+ const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
3834
+ const classifyByDirectoryCohort = (packageDirectory) => {
3835
+ let current = packageDirectory;
3836
+ while (true) {
3837
+ if (path.basename(current) === "apps") return "app";
3838
+ const parent = path.dirname(current);
3839
+ if (parent === current) return null;
3840
+ current = parent;
3841
+ }
3842
+ };
3843
+ const clearPackageRoleCache = () => {
3844
+ cachedRoleByPackageDirectory.clear();
3845
+ cachedPackageDirectoryByFilename.clear();
3846
+ };
3847
+ const classifyPackageRole = (filename) => {
3848
+ if (!filename) return "unknown";
3849
+ const packageDirectory = findNearestPackageDirectory(filename);
3850
+ if (!packageDirectory) return "unknown";
3851
+ const cached = cachedRoleByPackageDirectory.get(packageDirectory);
3852
+ if (cached !== void 0) return cached;
3853
+ const manifest = readManifest(path.join(packageDirectory, "package.json"));
3854
+ let result;
3855
+ if (manifest && hasPublishContract(manifest)) result = "library";
3856
+ else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
3857
+ cachedRoleByPackageDirectory.set(packageDirectory, result);
3858
+ return result;
3859
+ };
3769
3860
  /**
3770
3861
  * Resolves the absolute path to read for a diagnostic's `filePath`,
3771
3862
  * accounting for the various shapes oxlint emits:
@@ -3928,6 +4019,15 @@ const buildDiagnosticPipeline = (input) => {
3928
4019
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
3929
4020
  const fileLinesCache = /* @__PURE__ */ new Map();
3930
4021
  const testFileCache = /* @__PURE__ */ new Map();
4022
+ const libraryFileCache = /* @__PURE__ */ new Map();
4023
+ const isLibraryFile = (filePath) => {
4024
+ let cached = libraryFileCache.get(filePath);
4025
+ if (cached === void 0) {
4026
+ cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
4027
+ libraryFileCache.set(filePath, cached);
4028
+ }
4029
+ return cached;
4030
+ };
3931
4031
  const getFileLines = (filePath) => {
3932
4032
  const cached = fileLinesCache.get(filePath);
3933
4033
  if (cached !== void 0) return cached;
@@ -3954,6 +4054,10 @@ const buildDiagnosticPipeline = (input) => {
3954
4054
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
3955
4055
  return false;
3956
4056
  };
4057
+ const isAppOnlyRule = (ruleIdentifier) => {
4058
+ for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
4059
+ return false;
4060
+ };
3957
4061
  const isRnRawTextSuppressedByConfig = (diagnostic) => {
3958
4062
  if (diagnostic.rule !== "rn-no-raw-text") return false;
3959
4063
  if (diagnostic.line <= 0) return false;
@@ -3968,8 +4072,10 @@ const buildDiagnosticPipeline = (input) => {
3968
4072
  if (shouldAutoSuppress(diagnostic)) return null;
3969
4073
  let current = diagnostic;
3970
4074
  let explicitSeverityOverride;
4075
+ let explicitRuleOverride;
3971
4076
  if (severityControls) {
3972
4077
  const { ruleKey, category } = getDiagnosticRuleIdentity(current);
4078
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
3973
4079
  explicitSeverityOverride = resolveRuleSeverityOverride({
3974
4080
  ruleKey,
3975
4081
  category
@@ -3977,6 +4083,9 @@ const buildDiagnosticPipeline = (input) => {
3977
4083
  if (explicitSeverityOverride === "off") return null;
3978
4084
  if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
3979
4085
  }
4086
+ if (explicitRuleOverride === void 0) {
4087
+ if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
4088
+ }
3980
4089
  if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
3981
4090
  if (userConfig) {
3982
4091
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
@@ -4162,6 +4271,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
4162
4271
  }).pipe(Layer.provide(FetchHttpClient.layer));
4163
4272
  }).pipe(Effect.orDie));
4164
4273
  /**
4274
+ * Resolves a requested lint worker count to a clamped integer within
4275
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
4276
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
4277
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
4278
+ */
4279
+ const resolveScanConcurrency = (requested) => {
4280
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
4281
+ if (!Number.isFinite(desired) || desired < 1) return 1;
4282
+ return Math.max(1, Math.min(Math.floor(desired), 16));
4283
+ };
4284
+ /**
4165
4285
  * Per-batch oxlint wall-clock budget. Reads from the env var on
4166
4286
  * startup so the eval harness can raise the budget under sandbox
4167
4287
  * microVMs without recompiling react-doctor. Tests override via
@@ -4181,6 +4301,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
4181
4301
  * tests that exercise the cap behavior.
4182
4302
  */
4183
4303
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
4304
+ /**
4305
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
4306
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
4307
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
4308
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
4309
+ * CI callers that never touch the flag:
4310
+ *
4311
+ * - unset / `0` / `false` / `off` → `1` (serial)
4312
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
4313
+ * - a positive integer → that many workers (clamped)
4314
+ *
4315
+ * The resolved value is always within
4316
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
4317
+ */
4318
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
4319
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
4320
+ if (raw === void 0) return 1;
4321
+ const normalized = raw.trim().toLowerCase();
4322
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
4323
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
4324
+ const parsed = Number.parseInt(normalized, 10);
4325
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
4326
+ return resolveScanConcurrency(parsed);
4327
+ } }) {};
4184
4328
  const DIAGNOSTIC_SURFACES = [
4185
4329
  "cli",
4186
4330
  "prComment",
@@ -4341,16 +4485,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
4341
4485
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
4342
4486
  const loadConfigFromDirectory = (directory) => {
4343
4487
  const configFilePath = path.join(directory, CONFIG_FILENAME);
4344
- if (isFile(configFilePath)) try {
4345
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
4346
- const parsed = JSON.parse(fileContent);
4347
- if (isPlainObject(parsed)) return {
4348
- config: validateConfigTypes(parsed),
4349
- sourceDirectory: directory
4350
- };
4351
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4352
- } catch (error) {
4353
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
4488
+ let sawBrokenConfigFile = false;
4489
+ if (isFile(configFilePath)) {
4490
+ try {
4491
+ const fileContent = fs.readFileSync(configFilePath, "utf-8");
4492
+ const parsed = JSON.parse(fileContent);
4493
+ if (isPlainObject(parsed)) return {
4494
+ status: "found",
4495
+ loaded: {
4496
+ config: validateConfigTypes(parsed),
4497
+ sourceDirectory: directory
4498
+ }
4499
+ };
4500
+ warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4501
+ } catch (error) {
4502
+ warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
4503
+ }
4504
+ sawBrokenConfigFile = true;
4354
4505
  }
4355
4506
  const packageJsonPath = path.join(directory, "package.json");
4356
4507
  if (isFile(packageJsonPath)) try {
@@ -4359,14 +4510,18 @@ const loadConfigFromDirectory = (directory) => {
4359
4510
  if (isPlainObject(packageJson)) {
4360
4511
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4361
4512
  if (isPlainObject(embeddedConfig)) return {
4362
- config: validateConfigTypes(embeddedConfig),
4363
- sourceDirectory: directory
4513
+ status: "found",
4514
+ loaded: {
4515
+ config: validateConfigTypes(embeddedConfig),
4516
+ sourceDirectory: directory
4517
+ }
4364
4518
  };
4365
4519
  }
4366
- } catch {
4367
- return null;
4368
- }
4369
- return null;
4520
+ } catch {}
4521
+ return {
4522
+ status: sawBrokenConfigFile ? "invalid" : "absent",
4523
+ loaded: null
4524
+ };
4370
4525
  };
4371
4526
  const cachedConfigs = /* @__PURE__ */ new Map();
4372
4527
  const clearConfigCache = () => {
@@ -4375,21 +4530,21 @@ const clearConfigCache = () => {
4375
4530
  const loadConfigWithSource = (rootDirectory) => {
4376
4531
  const cached = cachedConfigs.get(rootDirectory);
4377
4532
  if (cached !== void 0) return cached;
4378
- const localConfig = loadConfigFromDirectory(rootDirectory);
4379
- if (localConfig) {
4380
- cachedConfigs.set(rootDirectory, localConfig);
4381
- return localConfig;
4533
+ const localResult = loadConfigFromDirectory(rootDirectory);
4534
+ if (localResult.status === "found") {
4535
+ cachedConfigs.set(rootDirectory, localResult.loaded);
4536
+ return localResult.loaded;
4382
4537
  }
4383
- if (isProjectBoundary(rootDirectory)) {
4538
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
4384
4539
  cachedConfigs.set(rootDirectory, null);
4385
4540
  return null;
4386
4541
  }
4387
4542
  let ancestorDirectory = path.dirname(rootDirectory);
4388
4543
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4389
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4390
- if (ancestorConfig) {
4391
- cachedConfigs.set(rootDirectory, ancestorConfig);
4392
- return ancestorConfig;
4544
+ const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
4545
+ if (ancestorResult.status === "found") {
4546
+ cachedConfigs.set(rootDirectory, ancestorResult.loaded);
4547
+ return ancestorResult.loaded;
4393
4548
  }
4394
4549
  if (isProjectBoundary(ancestorDirectory)) {
4395
4550
  cachedConfigs.set(rootDirectory, null);
@@ -4463,6 +4618,359 @@ const resolveScanTarget = (requestedDirectory, options = {}) => {
4463
4618
  didRedirectViaRootDir: redirectedDirectory !== null
4464
4619
  };
4465
4620
  };
4621
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
4622
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
4623
+ const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
4624
+ return {
4625
+ rootDirectory,
4626
+ packageJson,
4627
+ directDependencyNames: getDirectDependencyNames(packageJson),
4628
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
4629
+ };
4630
+ };
4631
+ const buildExpoDiagnostic = (input) => ({
4632
+ filePath: input.filePath ?? "package.json",
4633
+ plugin: "react-doctor",
4634
+ rule: input.rule,
4635
+ severity: input.severity ?? "warning",
4636
+ message: input.message,
4637
+ help: input.help,
4638
+ line: input.line ?? 0,
4639
+ column: input.column ?? 0,
4640
+ category: input.category ?? "Correctness"
4641
+ });
4642
+ const CRITICAL_OVERRIDE_NAMES = new Set([
4643
+ "@expo/cli",
4644
+ "@expo/config",
4645
+ "@expo/metro-config",
4646
+ "@expo/metro-runtime",
4647
+ "@expo/metro",
4648
+ "metro"
4649
+ ]);
4650
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
4651
+ const collectOverrideNames = (packageJson) => new Set([
4652
+ ...Object.keys(packageJson.overrides ?? {}),
4653
+ ...Object.keys(packageJson.resolutions ?? {}),
4654
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
4655
+ ]);
4656
+ const checkExpoDependencyOverrides = (context) => {
4657
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
4658
+ if (overriddenCriticalNames.length === 0) return [];
4659
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
4660
+ return [buildExpoDiagnostic({
4661
+ rule: "expo-no-conflicting-dependency-override",
4662
+ message: `package.json pins SDK-critical ${overriddenCriticalNames.length === 1 ? "package" : "packages"} via overrides/resolutions (${quotedNames}) — these versions are tied to the Expo SDK release and overriding them is unsupported and may break Metro or native builds`,
4663
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
4664
+ })];
4665
+ };
4666
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
4667
+ const result = spawnSync("git", [
4668
+ "check-ignore",
4669
+ "-q",
4670
+ absolutePath
4671
+ ], {
4672
+ cwd: rootDirectory,
4673
+ stdio: [
4674
+ "ignore",
4675
+ "ignore",
4676
+ "ignore"
4677
+ ]
4678
+ });
4679
+ if (result.error) return null;
4680
+ if (result.status === 0) return true;
4681
+ if (result.status === 1) return false;
4682
+ return null;
4683
+ };
4684
+ const LOCAL_ENV_FILE_NAMES = [
4685
+ ".env.local",
4686
+ ".env.development.local",
4687
+ ".env.production.local",
4688
+ ".env.test.local"
4689
+ ];
4690
+ const checkExpoEnvLocalFiles = (context) => {
4691
+ const { rootDirectory } = context;
4692
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
4693
+ const filePath = path.join(rootDirectory, fileName);
4694
+ if (!isFile(filePath)) return false;
4695
+ return isPathGitIgnored(rootDirectory, filePath) === false;
4696
+ });
4697
+ if (committedEnvFiles.length === 0) return [];
4698
+ return [buildExpoDiagnostic({
4699
+ rule: "expo-env-local-not-gitignored",
4700
+ category: "Security",
4701
+ message: `Local environment ${committedEnvFiles.length === 1 ? "file" : "files"} (${committedEnvFiles.join(", ")}) ${committedEnvFiles.length === 1 ? "is" : "are"} not ignored by Git — committing \`.env*.local\` risks leaking secrets and overriding committed defaults for everyone who clones the project`,
4702
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
4703
+ })];
4704
+ };
4705
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
4706
+ const UNIMODULES_HELP = "Remove every `@unimodules/*` and `react-native-unimodules` package — their functionality now lives in `expo-modules-core`. See https://expo.fyi/r/sdk-44-remove-unimodules";
4707
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
4708
+ const unimodulesEntry = (packageName) => ({
4709
+ packageName,
4710
+ rule: "expo-no-unimodules-packages",
4711
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
4712
+ help: UNIMODULES_HELP
4713
+ });
4714
+ const FLAGGED_DEPENDENCIES = [
4715
+ unimodulesEntry("@unimodules/core"),
4716
+ unimodulesEntry("@unimodules/react-native-adapter"),
4717
+ unimodulesEntry("react-native-unimodules"),
4718
+ {
4719
+ packageName: "expo-cli",
4720
+ rule: "expo-no-cli-dependencies",
4721
+ message: "`expo-cli` (the legacy global CLI) is a project dependency — the CLI now ships inside the `expo` package, and keeping `expo-cli` causes failures such as `unknown option --fix` when running `npx expo install --fix`",
4722
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
4723
+ },
4724
+ {
4725
+ packageName: "eas-cli",
4726
+ rule: "expo-no-cli-dependencies",
4727
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
4728
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
4729
+ },
4730
+ {
4731
+ packageName: "expo-modules-autolinking",
4732
+ rule: "expo-no-redundant-dependency",
4733
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
4734
+ help: "Remove `expo-modules-autolinking` from your package.json"
4735
+ },
4736
+ {
4737
+ packageName: "expo-dev-launcher",
4738
+ rule: "expo-no-redundant-dependency",
4739
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4740
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
4741
+ },
4742
+ {
4743
+ packageName: "expo-dev-menu",
4744
+ rule: "expo-no-redundant-dependency",
4745
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4746
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
4747
+ },
4748
+ {
4749
+ packageName: "expo-modules-core",
4750
+ rule: "expo-no-redundant-dependency",
4751
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
4752
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
4753
+ },
4754
+ {
4755
+ packageName: "@expo/metro-config",
4756
+ rule: "expo-no-redundant-dependency",
4757
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
4758
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
4759
+ },
4760
+ {
4761
+ packageName: "@types/react-native",
4762
+ rule: "expo-no-redundant-dependency",
4763
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
4764
+ help: "Remove `@types/react-native` from your package.json",
4765
+ minSdkMajor: 48
4766
+ },
4767
+ {
4768
+ packageName: "@expo/config-plugins",
4769
+ rule: "expo-no-redundant-dependency",
4770
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
4771
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
4772
+ minSdkMajor: 48
4773
+ },
4774
+ {
4775
+ packageName: "@expo/prebuild-config",
4776
+ rule: "expo-no-redundant-dependency",
4777
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
4778
+ help: "Remove `@expo/prebuild-config` from your package.json",
4779
+ minSdkMajor: 53
4780
+ },
4781
+ {
4782
+ packageName: "expo-permissions",
4783
+ rule: "expo-no-redundant-dependency",
4784
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
4785
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
4786
+ minSdkMajor: 50
4787
+ },
4788
+ {
4789
+ packageName: "expo-app-loading",
4790
+ rule: "expo-no-redundant-dependency",
4791
+ message: "\"expo-app-loading\" was removed in SDK 49",
4792
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
4793
+ minSdkMajor: 49
4794
+ },
4795
+ {
4796
+ packageName: "expo-firebase-analytics",
4797
+ rule: "expo-no-redundant-dependency",
4798
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
4799
+ help: FIREBASE_HELP,
4800
+ minSdkMajor: 48
4801
+ },
4802
+ {
4803
+ packageName: "expo-firebase-recaptcha",
4804
+ rule: "expo-no-redundant-dependency",
4805
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
4806
+ help: FIREBASE_HELP,
4807
+ minSdkMajor: 48
4808
+ },
4809
+ {
4810
+ packageName: "expo-firebase-core",
4811
+ rule: "expo-no-redundant-dependency",
4812
+ message: "\"expo-firebase-core\" was removed in SDK 48",
4813
+ help: FIREBASE_HELP,
4814
+ minSdkMajor: 48
4815
+ }
4816
+ ];
4817
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
4818
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
4819
+ if (flaggedDependency.minSdkMajor === void 0) return true;
4820
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
4821
+ }).map((flaggedDependency) => buildExpoDiagnostic({
4822
+ rule: flaggedDependency.rule,
4823
+ message: flaggedDependency.message,
4824
+ help: flaggedDependency.help
4825
+ }));
4826
+ const findLocalModuleNativeFiles = (rootDirectory) => {
4827
+ const modulesDirectory = path.join(rootDirectory, "modules");
4828
+ if (!isDirectory(modulesDirectory)) return [];
4829
+ const nativeFilePaths = [];
4830
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
4831
+ if (!moduleEntry.isDirectory()) continue;
4832
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
4833
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
4834
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
4835
+ const iosDirectory = path.join(moduleDirectory, "ios");
4836
+ if (isDirectory(iosDirectory)) {
4837
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
4838
+ }
4839
+ }
4840
+ return nativeFilePaths;
4841
+ };
4842
+ const checkExpoGitignore = (context) => {
4843
+ const { rootDirectory } = context;
4844
+ const diagnostics = [];
4845
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
4846
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
4847
+ rule: "expo-gitignore",
4848
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
4849
+ help: "Add `.expo/` to your .gitignore"
4850
+ }));
4851
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
4852
+ rule: "expo-gitignore",
4853
+ message: "The native `ios`/`android` directories of a local Expo module under `modules/` are gitignored — usually caused by an overly broad `ios`/`android` ignore rule",
4854
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
4855
+ }));
4856
+ return diagnostics;
4857
+ };
4858
+ const LOCKFILE_NAMES = [
4859
+ "pnpm-lock.yaml",
4860
+ "yarn.lock",
4861
+ "package-lock.json",
4862
+ "bun.lockb",
4863
+ "bun.lock"
4864
+ ];
4865
+ const checkExpoLockfile = (context) => {
4866
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
4867
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
4868
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
4869
+ rule: "expo-lockfile",
4870
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
4871
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
4872
+ })];
4873
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
4874
+ rule: "expo-lockfile",
4875
+ message: `Multiple lock files detected (${presentLockfiles.join(", ")}) — CI environments such as EAS Build infer the package manager from the lock file, so this is ambiguous`,
4876
+ help: "Delete the lock files for the package managers you are not using and keep only one"
4877
+ })];
4878
+ return [];
4879
+ };
4880
+ const METRO_CONFIG_FILE_NAMES = [
4881
+ "metro.config.js",
4882
+ "metro.config.cjs",
4883
+ "metro.config.mjs",
4884
+ "metro.config.ts"
4885
+ ];
4886
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
4887
+ "expo/metro-config",
4888
+ "@sentry/react-native/metro",
4889
+ "getSentryExpoConfig"
4890
+ ];
4891
+ const checkExpoMetroConfig = (context) => {
4892
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
4893
+ if (metroConfigPath === void 0) return [];
4894
+ let contents;
4895
+ try {
4896
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
4897
+ } catch {
4898
+ return [];
4899
+ }
4900
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
4901
+ return [buildExpoDiagnostic({
4902
+ rule: "expo-metro-config",
4903
+ filePath: path.basename(metroConfigPath),
4904
+ message: "Your metro.config does not extend `expo/metro-config` — a custom Metro config that doesn't extend Expo's leads to unexpected, hard-to-debug bundling issues",
4905
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
4906
+ })];
4907
+ };
4908
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
4909
+ const checkExpoPackageJsonConflicts = (context) => {
4910
+ const { packageJson } = context;
4911
+ const diagnostics = [];
4912
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
4913
+ if (conflictingScriptNames.length > 0) {
4914
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
4915
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
4916
+ diagnostics.push(buildExpoDiagnostic({
4917
+ rule: "expo-package-json-conflict",
4918
+ message: `package.json defines ${quotedNames} ${conflictingScriptNames.length === 1 ? "as a script that conflicts" : "as scripts that conflict"} with binaries in node_modules/.bin${shadowsExpoCli ? " — a `expo` script shadows the Expo CLI and will likely cause build failures" : ""}`,
4919
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
4920
+ }));
4921
+ }
4922
+ const packageName = packageJson.name;
4923
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
4924
+ rule: "expo-package-json-conflict",
4925
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
4926
+ help: "Rename your package so it no longer matches one of its dependencies"
4927
+ }));
4928
+ return diagnostics;
4929
+ };
4930
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
4931
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
4932
+ const checkExpoRouterReactNavigation = (context) => {
4933
+ const { expoSdkMajor } = context;
4934
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
4935
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
4936
+ if (!context.directDependencyNames.has("expo-router")) return [];
4937
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
4938
+ if (reactNavigationNames.length === 0) return [];
4939
+ return [buildExpoDiagnostic({
4940
+ rule: "expo-router-no-react-navigation",
4941
+ message: `As of SDK 56, expo-router is no longer compatible with react-navigation, but ${reactNavigationNames.map((name) => `"${name}"`).join(", ")} ${reactNavigationNames.length === 1 ? "is" : "are"} installed as direct ${reactNavigationNames.length === 1 ? "dependency" : "dependencies"}`,
4942
+ help: "Remove these `@react-navigation/*` packages and replace direct imports with their expo-router equivalents. See https://docs.expo.dev/router/migrate/sdk-55-to-56/"
4943
+ })];
4944
+ };
4945
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
4946
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
4947
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
4948
+ const checkExpoVectorIcons = (context) => {
4949
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
4950
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
4951
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
4952
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
4953
+ return [buildExpoDiagnostic({
4954
+ rule: "expo-vector-icons-conflict",
4955
+ message: "This project installs both the scoped `@react-native-vector-icons/*` packages and `@expo/vector-icons` (or the deprecated `react-native-vector-icons`) — mixing them causes icon-rendering conflicts",
4956
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
4957
+ })];
4958
+ };
4959
+ const checkExpoProject = (rootDirectory, project) => {
4960
+ if (project.expoVersion === null) return [];
4961
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
4962
+ return [
4963
+ ...checkExpoFlaggedDependencies(context),
4964
+ ...checkExpoDependencyOverrides(context),
4965
+ ...checkExpoRouterReactNavigation(context),
4966
+ ...checkExpoVectorIcons(context),
4967
+ ...checkExpoPackageJsonConflicts(context),
4968
+ ...checkExpoLockfile(context),
4969
+ ...checkExpoGitignore(context),
4970
+ ...checkExpoEnvLocalFiles(context),
4971
+ ...checkExpoMetroConfig(context)
4972
+ ];
4973
+ };
4466
4974
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4467
4975
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
4468
4976
  const PACKAGE_JSON_FILE = "package.json";
@@ -5743,6 +6251,7 @@ const buildCapabilities = (project) => {
5743
6251
  const capabilities = /* @__PURE__ */ new Set();
5744
6252
  capabilities.add(project.framework);
5745
6253
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
6254
+ if (project.expoVersion !== null) capabilities.add("expo");
5746
6255
  const reactMajor = project.reactMajorVersion;
5747
6256
  if (reactMajor !== null) {
5748
6257
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -5914,10 +6423,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
5914
6423
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
5915
6424
  return fs.realpathSync(rootDirectory);
5916
6425
  };
6426
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
6427
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
6428
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
6429
+ };
5917
6430
  const applyRuleSeverityControls = (rules, severityControls) => {
5918
6431
  const enabledRules = {};
5919
6432
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
5920
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
6433
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
5921
6434
  if (severity === "off") continue;
5922
6435
  enabledRules[ruleKey] = severity;
5923
6436
  }
@@ -5959,7 +6472,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
5959
6472
  category: rule.category
5960
6473
  }, severityControls);
5961
6474
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
5962
- const severity = explicitSeverity ?? rule.severity;
6475
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
5963
6476
  if (severity === "off") continue;
5964
6477
  enabledReactDoctorRules[registryEntry.key] = severity;
5965
6478
  }
@@ -6016,6 +6529,44 @@ const dedupeDiagnostics = (diagnostics) => {
6016
6529
  }
6017
6530
  return uniqueDiagnostics;
6018
6531
  };
6532
+ /**
6533
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
6534
+ * once, returning results in input order. A pool of workers each pulls the
6535
+ * next not-yet-started index until the list drains — so a worker that
6536
+ * finishes a fast task immediately picks up the next one (greedy load
6537
+ * balancing), which matters when tasks have uneven durations (oxlint
6538
+ * batches do).
6539
+ *
6540
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
6541
+ * no further tasks are started, the already-in-flight tasks are awaited to
6542
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
6543
+ * rejects with that first error. This keeps the caller's fail-fast retry
6544
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
6545
+ * top of a still-running first one.
6546
+ */
6547
+ const mapWithConcurrency = async (items, concurrency, task) => {
6548
+ const results = new Array(items.length);
6549
+ if (items.length === 0) return results;
6550
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
6551
+ let nextIndex = 0;
6552
+ const errors = [];
6553
+ const runWorker = async () => {
6554
+ while (errors.length === 0) {
6555
+ const index = nextIndex;
6556
+ nextIndex += 1;
6557
+ if (index >= items.length) return;
6558
+ try {
6559
+ results[index] = await task(items[index], index);
6560
+ } catch (error) {
6561
+ errors.push(error);
6562
+ return;
6563
+ }
6564
+ }
6565
+ };
6566
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
6567
+ if (errors.length > 0) throw errors[0];
6568
+ return results;
6569
+ };
6019
6570
  const getPublicEnvPrefix = (framework) => {
6020
6571
  switch (framework) {
6021
6572
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -6698,6 +7249,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
6698
7249
  */
6699
7250
  const spawnLintBatches = async (input) => {
6700
7251
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
7252
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
6701
7253
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
6702
7254
  const allDiagnostics = [];
6703
7255
  const droppedFiles = [];
@@ -6717,23 +7269,31 @@ const spawnLintBatches = async (input) => {
6717
7269
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
6718
7270
  }
6719
7271
  };
7272
+ let startedFileCount = 0;
6720
7273
  let scannedFileCount = 0;
6721
- for (const batch of fileBatches) {
6722
- let batchFileIndex = 0;
6723
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
6724
- if (batchFileIndex < batch.length) {
6725
- batchFileIndex += 1;
6726
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
6727
- }
6728
- }, 50) : null;
6729
- try {
7274
+ let displayedFileCount = 0;
7275
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
7276
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
7277
+ if (displayedFileCount < ceiling) {
7278
+ displayedFileCount += 1;
7279
+ onFileProgress(displayedFileCount, totalFileCount);
7280
+ }
7281
+ }, 50) : null;
7282
+ progressTimer?.unref?.();
7283
+ try {
7284
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
7285
+ startedFileCount += batch.length;
6730
7286
  const batchDiagnostics = await spawnLintBatch(batch);
6731
- allDiagnostics.push(...batchDiagnostics);
6732
7287
  scannedFileCount += batch.length;
6733
- onFileProgress?.(scannedFileCount, totalFileCount);
6734
- } finally {
6735
- if (progressInterval !== null) clearInterval(progressInterval);
6736
- }
7288
+ if (onFileProgress) {
7289
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
7290
+ onFileProgress(displayedFileCount, totalFileCount);
7291
+ }
7292
+ return batchDiagnostics;
7293
+ });
7294
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
7295
+ } finally {
7296
+ if (progressTimer !== null) clearInterval(progressTimer);
6737
7297
  }
6738
7298
  if (droppedFiles.length > 0 && onPartialFailure) {
6739
7299
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6860,7 +7420,8 @@ const runOxlint = async (options) => {
6860
7420
  onPartialFailure,
6861
7421
  onFileProgress: options.onFileProgress,
6862
7422
  spawnTimeoutMs,
6863
- outputMaxBytes
7423
+ outputMaxBytes,
7424
+ concurrency: options.concurrency
6864
7425
  });
6865
7426
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
6866
7427
  try {
@@ -6928,6 +7489,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6928
7489
  const partialFailures = yield* LintPartialFailures;
6929
7490
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
6930
7491
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
7492
+ const concurrency = yield* OxlintConcurrency;
6931
7493
  const collectedFailures = [];
6932
7494
  const diagnostics = yield* Effect.tryPromise({
6933
7495
  try: () => runOxlint({
@@ -6946,7 +7508,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6946
7508
  },
6947
7509
  onFileProgress: input.onFileProgress,
6948
7510
  spawnTimeoutMs,
6949
- outputMaxBytes
7511
+ outputMaxBytes,
7512
+ concurrency
6950
7513
  }),
6951
7514
  catch: ensureReactDoctorError
6952
7515
  });
@@ -7279,7 +7842,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7279
7842
  showWarnings
7280
7843
  });
7281
7844
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
7282
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
7845
+ const environmentDiagnostics = isDiffMode ? [] : [
7846
+ ...checkReducedMotion(scanDirectory),
7847
+ ...checkPnpmHardening(scanDirectory),
7848
+ ...checkExpoProject(scanDirectory, project)
7849
+ ];
7283
7850
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7284
7851
  const lintFailure = yield* Ref.make({
7285
7852
  didFail: false,
@@ -7291,6 +7858,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7291
7858
  didFail: false,
7292
7859
  reason: null
7293
7860
  });
7861
+ const scanConcurrency = yield* OxlintConcurrency;
7862
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
7294
7863
  const scanProgress = yield* progressService.start("Scanning...");
7295
7864
  const scanStartTime = Date.now();
7296
7865
  let lastReportedTotalFileCount = 0;
@@ -7307,7 +7876,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7307
7876
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
7308
7877
  onFileProgress: (scannedFileCount, totalFileCount) => {
7309
7878
  lastReportedTotalFileCount = totalFileCount;
7310
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
7879
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
7311
7880
  }
7312
7881
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
7313
7882
  yield* Ref.set(lintFailure, {
@@ -7339,7 +7908,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7339
7908
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
7340
7909
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
7341
7910
  else if (input.suppressScanSummary) yield* scanProgress.stop();
7342
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
7911
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
7343
7912
  yield* reporterService.finalize;
7344
7913
  const finalDiagnostics = [
7345
7914
  ...envCollected,
@@ -7391,7 +7960,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7391
7960
  "inspect.isCi": input.isCi,
7392
7961
  "inspect.scoreSurface": input.scoreSurface ?? "score"
7393
7962
  } }));
7394
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7395
7963
  const parseNodeVersion = (versionString) => {
7396
7964
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
7397
7965
  return {
@@ -7690,6 +8258,26 @@ const buildJsonReport = (input) => {
7690
8258
  };
7691
8259
  };
7692
8260
  /**
8261
+ * Single source of truth for the skipped-check accounting shared by the
8262
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
8263
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
8264
+ * failed lint / dead-code pass instead of a false "all clear", so the
8265
+ * branch logic lives here once.
8266
+ */
8267
+ const buildSkippedChecks = (input) => {
8268
+ const skippedChecks = [];
8269
+ if (input.didLintFail) skippedChecks.push("lint");
8270
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
8271
+ const skippedCheckReasons = {};
8272
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
8273
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
8274
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
8275
+ return {
8276
+ skippedChecks,
8277
+ skippedCheckReasons
8278
+ };
8279
+ };
8280
+ /**
7693
8281
  * Programmatic façade over `Git.diffSelection`. Async because the
7694
8282
  * Git service runs through Effect's `ChildProcess` (true subprocess
7695
8283
  * spawn, not `spawnSync`).
@@ -7795,7 +8383,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
7795
8383
  const clearAutoSuppressionCaches = () => {};
7796
8384
  //#endregion
7797
8385
  //#region ../api/dist/index.js
7798
- const DEFAULT_LAYER = Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
8386
+ const buildDiagnoseLayer = (configLayer = Config.layerNode) => Layer.mergeAll(Project.layerNode, configLayer, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7799
8387
  const buildInspectProgram = (scanTarget, options, configOverride) => {
7800
8388
  const effectiveConfig = configOverride ?? scanTarget.userConfig;
7801
8389
  const includePaths = options.includePaths ?? [];
@@ -7814,13 +8402,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7814
8402
  };
7815
8403
  const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7816
8404
  if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
7817
- const skippedChecks = [];
7818
- if (output.didLintFail) skippedChecks.push("lint");
7819
- if (output.didDeadCodeFail) skippedChecks.push("dead-code");
7820
- const skippedCheckReasons = {};
7821
- if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
7822
- else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
7823
- if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
8405
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
7824
8406
  return {
7825
8407
  diagnostics: [...output.diagnostics],
7826
8408
  score: output.score,
@@ -7833,7 +8415,7 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7833
8415
  const diagnose = async (directory, options = {}) => {
7834
8416
  const startTime = globalThis.performance.now();
7835
8417
  const program = buildInspectProgram(resolveScanTarget(directory), options);
7836
- return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(DEFAULT_LAYER), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
8418
+ return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
7837
8419
  };
7838
8420
  //#endregion
7839
8421
  //#region src/index.ts
@@ -7842,6 +8424,7 @@ const clearCaches = () => {
7842
8424
  clearConfigCache();
7843
8425
  clearPackageJsonCache();
7844
8426
  clearIgnorePatternsCache();
8427
+ clearPackageRoleCache();
7845
8428
  clearAutoSuppressionCaches();
7846
8429
  };
7847
8430
  const toJsonReport = (result, options) => buildJsonReport({