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/cli.js CHANGED
@@ -15,6 +15,7 @@ import * as Redacted from "effect/Redacted";
15
15
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
16
16
  import * as Otlp from "effect/unstable/observability/Otlp";
17
17
  import * as Context from "effect/Context";
18
+ import os, { tmpdir } from "node:os";
18
19
  import * as Console from "effect/Console";
19
20
  import * as Fiber from "effect/Fiber";
20
21
  import * as Filter from "effect/Filter";
@@ -27,9 +28,9 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
27
28
  import * as NodePath from "@effect/platform-node-shared/NodePath";
28
29
  import * as ChildProcess from "effect/unstable/process/ChildProcess";
29
30
  import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
30
- import os, { tmpdir } from "node:os";
31
31
  import * as ts from "typescript";
32
32
  import { gzipSync } from "node:zlib";
33
+ import * as Sentry from "@sentry/node";
33
34
  import { performance } from "node:perf_hooks";
34
35
  import { stripVTControlCharacters } from "node:util";
35
36
  import tty from "node:tty";
@@ -5892,29 +5893,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
5892
5893
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
5893
5894
  };
5894
5895
  };
5895
- const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
5896
- if (predicate(rootPackageJson)) return true;
5896
+ const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
5897
+ const rootValue = select(rootPackageJson);
5898
+ if (rootValue !== null) return rootValue;
5897
5899
  const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
5898
- if (patterns.length === 0) return false;
5900
+ if (patterns.length === 0) return null;
5899
5901
  const visitedDirectories = /* @__PURE__ */ new Set();
5900
5902
  for (const pattern of patterns) {
5901
- const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
5903
+ const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
5902
5904
  for (const workspaceDirectory of directories) {
5903
5905
  if (visitedDirectories.has(workspaceDirectory)) continue;
5904
5906
  visitedDirectories.add(workspaceDirectory);
5905
- if (predicate(readPackageJson$1(path.join(workspaceDirectory, "package.json")))) return true;
5907
+ const value = select(readPackageJson$1(path.join(workspaceDirectory, "package.json")));
5908
+ if (value !== null) return value;
5906
5909
  }
5907
5910
  }
5908
- return false;
5911
+ return null;
5909
5912
  };
5913
+ const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
5910
5914
  const NAMES = new Set([
5911
5915
  "react-native",
5912
5916
  "react-native-tvos",
5913
- "expo",
5914
- "expo-router",
5915
- "@expo/cli",
5916
- "@expo/metro-config",
5917
- "@expo/metro-runtime",
5917
+ ...new Set([
5918
+ "expo",
5919
+ "expo-router",
5920
+ "@expo/cli",
5921
+ "@expo/metro-config",
5922
+ "@expo/metro-runtime"
5923
+ ]),
5918
5924
  "react-native-windows",
5919
5925
  "react-native-macos"
5920
5926
  ]);
@@ -5938,6 +5944,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
5938
5944
  return false;
5939
5945
  };
5940
5946
  const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
5947
+ const getExpoDependencySpec = (packageJson) => {
5948
+ const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
5949
+ return typeof spec === "string" ? spec : null;
5950
+ };
5951
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
5941
5952
  const getPreactVersion = (packageJson) => {
5942
5953
  return {
5943
5954
  ...packageJson.peerDependencies,
@@ -6174,6 +6185,19 @@ const discoverProject = (directory) => {
6174
6185
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
6175
6186
  const sourceFileCount = countSourceFiles(directory);
6176
6187
  const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
6188
+ let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
6189
+ if (expoVersion !== null && isCatalogReference(expoVersion)) {
6190
+ const catalogName = extractCatalogName(expoVersion);
6191
+ let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
6192
+ if (!resolvedExpoVersion) {
6193
+ const monorepoRoot = findMonorepoRoot(directory);
6194
+ if (monorepoRoot) {
6195
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
6196
+ if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson$1(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
6197
+ }
6198
+ }
6199
+ expoVersion = resolvedExpoVersion ?? expoVersion;
6200
+ }
6177
6201
  const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
6178
6202
  const preactVersion = getPreactVersion(packageJson);
6179
6203
  const projectInfo = {
@@ -6191,6 +6215,7 @@ const discoverProject = (directory) => {
6191
6215
  preactVersion,
6192
6216
  preactMajorVersion: parseReactMajor(preactVersion),
6193
6217
  hasReactNativeWorkspace,
6218
+ expoVersion,
6194
6219
  hasReanimated,
6195
6220
  sourceFileCount
6196
6221
  };
@@ -6289,6 +6314,7 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
6289
6314
  ".oxlintrc.json"
6290
6315
  ];
6291
6316
  const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
6317
+ const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
6292
6318
  const SKILL_NAME = "react-doctor";
6293
6319
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
6294
6320
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
@@ -6301,6 +6327,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
6301
6327
  "Accessibility",
6302
6328
  "Maintainability"
6303
6329
  ];
6330
+ const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
6331
+ const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
6332
+ const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
6304
6333
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
6305
6334
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
6306
6335
  var InvalidGlobPatternError = class extends Error {
@@ -6420,10 +6449,11 @@ const restampSeverity = (diagnostic, override) => {
6420
6449
  */
6421
6450
  const buildRuleSeverityControls = (config) => {
6422
6451
  if (!config) return void 0;
6423
- if (config.rules === void 0 && config.categories === void 0) return void 0;
6452
+ if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
6424
6453
  return {
6425
6454
  ...config.rules !== void 0 ? { rules: config.rules } : {},
6426
- ...config.categories !== void 0 ? { categories: config.categories } : {}
6455
+ ...config.categories !== void 0 ? { categories: config.categories } : {},
6456
+ ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
6427
6457
  };
6428
6458
  };
6429
6459
  const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
@@ -6787,6 +6817,65 @@ const resolveRuleSeverityOverride = (input, controls) => {
6787
6817
  }
6788
6818
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
6789
6819
  };
6820
+ const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
6821
+ const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
6822
+ const findNearestPackageDirectory$1 = (filename) => {
6823
+ if (!filename) return null;
6824
+ const fromCache = cachedPackageDirectoryByFilename.get(filename);
6825
+ if (fromCache !== void 0) return fromCache;
6826
+ let currentDirectory = path.dirname(filename);
6827
+ while (true) {
6828
+ const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
6829
+ let hasPackageJson = false;
6830
+ try {
6831
+ hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
6832
+ } catch {
6833
+ hasPackageJson = false;
6834
+ }
6835
+ if (hasPackageJson) {
6836
+ cachedPackageDirectoryByFilename.set(filename, currentDirectory);
6837
+ return currentDirectory;
6838
+ }
6839
+ const parentDirectory = path.dirname(currentDirectory);
6840
+ if (parentDirectory === currentDirectory) {
6841
+ cachedPackageDirectoryByFilename.set(filename, null);
6842
+ return null;
6843
+ }
6844
+ currentDirectory = parentDirectory;
6845
+ }
6846
+ };
6847
+ const readManifest = (packageJsonPath) => {
6848
+ try {
6849
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
6850
+ if (typeof parsed === "object" && parsed !== null) return parsed;
6851
+ return null;
6852
+ } catch {
6853
+ return null;
6854
+ }
6855
+ };
6856
+ const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
6857
+ const classifyByDirectoryCohort = (packageDirectory) => {
6858
+ let current = packageDirectory;
6859
+ while (true) {
6860
+ if (path.basename(current) === "apps") return "app";
6861
+ const parent = path.dirname(current);
6862
+ if (parent === current) return null;
6863
+ current = parent;
6864
+ }
6865
+ };
6866
+ const classifyPackageRole = (filename) => {
6867
+ if (!filename) return "unknown";
6868
+ const packageDirectory = findNearestPackageDirectory$1(filename);
6869
+ if (!packageDirectory) return "unknown";
6870
+ const cached = cachedRoleByPackageDirectory.get(packageDirectory);
6871
+ if (cached !== void 0) return cached;
6872
+ const manifest = readManifest(path.join(packageDirectory, "package.json"));
6873
+ let result;
6874
+ if (manifest && hasPublishContract(manifest)) result = "library";
6875
+ else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
6876
+ cachedRoleByPackageDirectory.set(packageDirectory, result);
6877
+ return result;
6878
+ };
6790
6879
  /**
6791
6880
  * Resolves the absolute path to read for a diagnostic's `filePath`,
6792
6881
  * accounting for the various shapes oxlint emits:
@@ -6949,6 +7038,15 @@ const buildDiagnosticPipeline = (input) => {
6949
7038
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
6950
7039
  const fileLinesCache = /* @__PURE__ */ new Map();
6951
7040
  const testFileCache = /* @__PURE__ */ new Map();
7041
+ const libraryFileCache = /* @__PURE__ */ new Map();
7042
+ const isLibraryFile = (filePath) => {
7043
+ let cached = libraryFileCache.get(filePath);
7044
+ if (cached === void 0) {
7045
+ cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
7046
+ libraryFileCache.set(filePath, cached);
7047
+ }
7048
+ return cached;
7049
+ };
6952
7050
  const getFileLines = (filePath) => {
6953
7051
  const cached = fileLinesCache.get(filePath);
6954
7052
  if (cached !== void 0) return cached;
@@ -6975,6 +7073,10 @@ const buildDiagnosticPipeline = (input) => {
6975
7073
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
6976
7074
  return false;
6977
7075
  };
7076
+ const isAppOnlyRule = (ruleIdentifier) => {
7077
+ for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
7078
+ return false;
7079
+ };
6978
7080
  const isRnRawTextSuppressedByConfig = (diagnostic) => {
6979
7081
  if (diagnostic.rule !== "rn-no-raw-text") return false;
6980
7082
  if (diagnostic.line <= 0) return false;
@@ -6989,8 +7091,10 @@ const buildDiagnosticPipeline = (input) => {
6989
7091
  if (shouldAutoSuppress(diagnostic)) return null;
6990
7092
  let current = diagnostic;
6991
7093
  let explicitSeverityOverride;
7094
+ let explicitRuleOverride;
6992
7095
  if (severityControls) {
6993
7096
  const { ruleKey, category } = getDiagnosticRuleIdentity(current);
7097
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
6994
7098
  explicitSeverityOverride = resolveRuleSeverityOverride({
6995
7099
  ruleKey,
6996
7100
  category
@@ -6998,6 +7102,9 @@ const buildDiagnosticPipeline = (input) => {
6998
7102
  if (explicitSeverityOverride === "off") return null;
6999
7103
  if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
7000
7104
  }
7105
+ if (explicitRuleOverride === void 0) {
7106
+ if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
7107
+ }
7001
7108
  if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
7002
7109
  if (userConfig) {
7003
7110
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
@@ -7183,6 +7290,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
7183
7290
  }).pipe(Layer.provide(FetchHttpClient.layer));
7184
7291
  }).pipe(Effect.orDie));
7185
7292
  /**
7293
+ * Resolves a requested lint worker count to a clamped integer within
7294
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
7295
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
7296
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
7297
+ */
7298
+ const resolveScanConcurrency = (requested) => {
7299
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
7300
+ if (!Number.isFinite(desired) || desired < 1) return 1;
7301
+ return Math.max(1, Math.min(Math.floor(desired), 16));
7302
+ };
7303
+ /**
7186
7304
  * Per-batch oxlint wall-clock budget. Reads from the env var on
7187
7305
  * startup so the eval harness can raise the budget under sandbox
7188
7306
  * microVMs without recompiling react-doctor. Tests override via
@@ -7202,6 +7320,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
7202
7320
  * tests that exercise the cap behavior.
7203
7321
  */
7204
7322
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
7323
+ /**
7324
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
7325
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
7326
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
7327
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
7328
+ * CI callers that never touch the flag:
7329
+ *
7330
+ * - unset / `0` / `false` / `off` → `1` (serial)
7331
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
7332
+ * - a positive integer → that many workers (clamped)
7333
+ *
7334
+ * The resolved value is always within
7335
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
7336
+ */
7337
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
7338
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
7339
+ if (raw === void 0) return 1;
7340
+ const normalized = raw.trim().toLowerCase();
7341
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
7342
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
7343
+ const parsed = Number.parseInt(normalized, 10);
7344
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
7345
+ return resolveScanConcurrency(parsed);
7346
+ } }) {};
7205
7347
  const DIAGNOSTIC_SURFACES = [
7206
7348
  "cli",
7207
7349
  "prComment",
@@ -7362,16 +7504,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
7362
7504
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
7363
7505
  const loadConfigFromDirectory = (directory) => {
7364
7506
  const configFilePath = path.join(directory, CONFIG_FILENAME);
7365
- if (isFile(configFilePath)) try {
7366
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
7367
- const parsed = JSON.parse(fileContent);
7368
- if (isPlainObject(parsed)) return {
7369
- config: validateConfigTypes(parsed),
7370
- sourceDirectory: directory
7371
- };
7372
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
7373
- } catch (error) {
7374
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
7507
+ let sawBrokenConfigFile = false;
7508
+ if (isFile(configFilePath)) {
7509
+ try {
7510
+ const fileContent = fs.readFileSync(configFilePath, "utf-8");
7511
+ const parsed = JSON.parse(fileContent);
7512
+ if (isPlainObject(parsed)) return {
7513
+ status: "found",
7514
+ loaded: {
7515
+ config: validateConfigTypes(parsed),
7516
+ sourceDirectory: directory
7517
+ }
7518
+ };
7519
+ warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
7520
+ } catch (error) {
7521
+ warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
7522
+ }
7523
+ sawBrokenConfigFile = true;
7375
7524
  }
7376
7525
  const packageJsonPath = path.join(directory, "package.json");
7377
7526
  if (isFile(packageJsonPath)) try {
@@ -7380,34 +7529,38 @@ const loadConfigFromDirectory = (directory) => {
7380
7529
  if (isPlainObject(packageJson)) {
7381
7530
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
7382
7531
  if (isPlainObject(embeddedConfig)) return {
7383
- config: validateConfigTypes(embeddedConfig),
7384
- sourceDirectory: directory
7532
+ status: "found",
7533
+ loaded: {
7534
+ config: validateConfigTypes(embeddedConfig),
7535
+ sourceDirectory: directory
7536
+ }
7385
7537
  };
7386
7538
  }
7387
- } catch {
7388
- return null;
7389
- }
7390
- return null;
7539
+ } catch {}
7540
+ return {
7541
+ status: sawBrokenConfigFile ? "invalid" : "absent",
7542
+ loaded: null
7543
+ };
7391
7544
  };
7392
7545
  const cachedConfigs = /* @__PURE__ */ new Map();
7393
7546
  const loadConfigWithSource = (rootDirectory) => {
7394
7547
  const cached = cachedConfigs.get(rootDirectory);
7395
7548
  if (cached !== void 0) return cached;
7396
- const localConfig = loadConfigFromDirectory(rootDirectory);
7397
- if (localConfig) {
7398
- cachedConfigs.set(rootDirectory, localConfig);
7399
- return localConfig;
7549
+ const localResult = loadConfigFromDirectory(rootDirectory);
7550
+ if (localResult.status === "found") {
7551
+ cachedConfigs.set(rootDirectory, localResult.loaded);
7552
+ return localResult.loaded;
7400
7553
  }
7401
- if (isProjectBoundary(rootDirectory)) {
7554
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
7402
7555
  cachedConfigs.set(rootDirectory, null);
7403
7556
  return null;
7404
7557
  }
7405
7558
  let ancestorDirectory = path.dirname(rootDirectory);
7406
7559
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
7407
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
7408
- if (ancestorConfig) {
7409
- cachedConfigs.set(rootDirectory, ancestorConfig);
7410
- return ancestorConfig;
7560
+ const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
7561
+ if (ancestorResult.status === "found") {
7562
+ cachedConfigs.set(rootDirectory, ancestorResult.loaded);
7563
+ return ancestorResult.loaded;
7411
7564
  }
7412
7565
  if (isProjectBoundary(ancestorDirectory)) {
7413
7566
  cachedConfigs.set(rootDirectory, null);
@@ -7481,6 +7634,359 @@ const resolveScanTarget = (requestedDirectory, options = {}) => {
7481
7634
  didRedirectViaRootDir: redirectedDirectory !== null
7482
7635
  };
7483
7636
  };
7637
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
7638
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
7639
+ const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
7640
+ return {
7641
+ rootDirectory,
7642
+ packageJson,
7643
+ directDependencyNames: getDirectDependencyNames(packageJson),
7644
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
7645
+ };
7646
+ };
7647
+ const buildExpoDiagnostic = (input) => ({
7648
+ filePath: input.filePath ?? "package.json",
7649
+ plugin: "react-doctor",
7650
+ rule: input.rule,
7651
+ severity: input.severity ?? "warning",
7652
+ message: input.message,
7653
+ help: input.help,
7654
+ line: input.line ?? 0,
7655
+ column: input.column ?? 0,
7656
+ category: input.category ?? "Correctness"
7657
+ });
7658
+ const CRITICAL_OVERRIDE_NAMES = new Set([
7659
+ "@expo/cli",
7660
+ "@expo/config",
7661
+ "@expo/metro-config",
7662
+ "@expo/metro-runtime",
7663
+ "@expo/metro",
7664
+ "metro"
7665
+ ]);
7666
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
7667
+ const collectOverrideNames = (packageJson) => new Set([
7668
+ ...Object.keys(packageJson.overrides ?? {}),
7669
+ ...Object.keys(packageJson.resolutions ?? {}),
7670
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
7671
+ ]);
7672
+ const checkExpoDependencyOverrides = (context) => {
7673
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
7674
+ if (overriddenCriticalNames.length === 0) return [];
7675
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
7676
+ return [buildExpoDiagnostic({
7677
+ rule: "expo-no-conflicting-dependency-override",
7678
+ 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`,
7679
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
7680
+ })];
7681
+ };
7682
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
7683
+ const result = spawnSync("git", [
7684
+ "check-ignore",
7685
+ "-q",
7686
+ absolutePath
7687
+ ], {
7688
+ cwd: rootDirectory,
7689
+ stdio: [
7690
+ "ignore",
7691
+ "ignore",
7692
+ "ignore"
7693
+ ]
7694
+ });
7695
+ if (result.error) return null;
7696
+ if (result.status === 0) return true;
7697
+ if (result.status === 1) return false;
7698
+ return null;
7699
+ };
7700
+ const LOCAL_ENV_FILE_NAMES = [
7701
+ ".env.local",
7702
+ ".env.development.local",
7703
+ ".env.production.local",
7704
+ ".env.test.local"
7705
+ ];
7706
+ const checkExpoEnvLocalFiles = (context) => {
7707
+ const { rootDirectory } = context;
7708
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
7709
+ const filePath = path.join(rootDirectory, fileName);
7710
+ if (!isFile(filePath)) return false;
7711
+ return isPathGitIgnored(rootDirectory, filePath) === false;
7712
+ });
7713
+ if (committedEnvFiles.length === 0) return [];
7714
+ return [buildExpoDiagnostic({
7715
+ rule: "expo-env-local-not-gitignored",
7716
+ category: "Security",
7717
+ 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`,
7718
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
7719
+ })];
7720
+ };
7721
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
7722
+ 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";
7723
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
7724
+ const unimodulesEntry = (packageName) => ({
7725
+ packageName,
7726
+ rule: "expo-no-unimodules-packages",
7727
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
7728
+ help: UNIMODULES_HELP
7729
+ });
7730
+ const FLAGGED_DEPENDENCIES = [
7731
+ unimodulesEntry("@unimodules/core"),
7732
+ unimodulesEntry("@unimodules/react-native-adapter"),
7733
+ unimodulesEntry("react-native-unimodules"),
7734
+ {
7735
+ packageName: "expo-cli",
7736
+ rule: "expo-no-cli-dependencies",
7737
+ 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`",
7738
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
7739
+ },
7740
+ {
7741
+ packageName: "eas-cli",
7742
+ rule: "expo-no-cli-dependencies",
7743
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
7744
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
7745
+ },
7746
+ {
7747
+ packageName: "expo-modules-autolinking",
7748
+ rule: "expo-no-redundant-dependency",
7749
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
7750
+ help: "Remove `expo-modules-autolinking` from your package.json"
7751
+ },
7752
+ {
7753
+ packageName: "expo-dev-launcher",
7754
+ rule: "expo-no-redundant-dependency",
7755
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
7756
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
7757
+ },
7758
+ {
7759
+ packageName: "expo-dev-menu",
7760
+ rule: "expo-no-redundant-dependency",
7761
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
7762
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
7763
+ },
7764
+ {
7765
+ packageName: "expo-modules-core",
7766
+ rule: "expo-no-redundant-dependency",
7767
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
7768
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
7769
+ },
7770
+ {
7771
+ packageName: "@expo/metro-config",
7772
+ rule: "expo-no-redundant-dependency",
7773
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
7774
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
7775
+ },
7776
+ {
7777
+ packageName: "@types/react-native",
7778
+ rule: "expo-no-redundant-dependency",
7779
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
7780
+ help: "Remove `@types/react-native` from your package.json",
7781
+ minSdkMajor: 48
7782
+ },
7783
+ {
7784
+ packageName: "@expo/config-plugins",
7785
+ rule: "expo-no-redundant-dependency",
7786
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
7787
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
7788
+ minSdkMajor: 48
7789
+ },
7790
+ {
7791
+ packageName: "@expo/prebuild-config",
7792
+ rule: "expo-no-redundant-dependency",
7793
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
7794
+ help: "Remove `@expo/prebuild-config` from your package.json",
7795
+ minSdkMajor: 53
7796
+ },
7797
+ {
7798
+ packageName: "expo-permissions",
7799
+ rule: "expo-no-redundant-dependency",
7800
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
7801
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
7802
+ minSdkMajor: 50
7803
+ },
7804
+ {
7805
+ packageName: "expo-app-loading",
7806
+ rule: "expo-no-redundant-dependency",
7807
+ message: "\"expo-app-loading\" was removed in SDK 49",
7808
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
7809
+ minSdkMajor: 49
7810
+ },
7811
+ {
7812
+ packageName: "expo-firebase-analytics",
7813
+ rule: "expo-no-redundant-dependency",
7814
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
7815
+ help: FIREBASE_HELP,
7816
+ minSdkMajor: 48
7817
+ },
7818
+ {
7819
+ packageName: "expo-firebase-recaptcha",
7820
+ rule: "expo-no-redundant-dependency",
7821
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
7822
+ help: FIREBASE_HELP,
7823
+ minSdkMajor: 48
7824
+ },
7825
+ {
7826
+ packageName: "expo-firebase-core",
7827
+ rule: "expo-no-redundant-dependency",
7828
+ message: "\"expo-firebase-core\" was removed in SDK 48",
7829
+ help: FIREBASE_HELP,
7830
+ minSdkMajor: 48
7831
+ }
7832
+ ];
7833
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
7834
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
7835
+ if (flaggedDependency.minSdkMajor === void 0) return true;
7836
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
7837
+ }).map((flaggedDependency) => buildExpoDiagnostic({
7838
+ rule: flaggedDependency.rule,
7839
+ message: flaggedDependency.message,
7840
+ help: flaggedDependency.help
7841
+ }));
7842
+ const findLocalModuleNativeFiles = (rootDirectory) => {
7843
+ const modulesDirectory = path.join(rootDirectory, "modules");
7844
+ if (!isDirectory(modulesDirectory)) return [];
7845
+ const nativeFilePaths = [];
7846
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
7847
+ if (!moduleEntry.isDirectory()) continue;
7848
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
7849
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
7850
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
7851
+ const iosDirectory = path.join(moduleDirectory, "ios");
7852
+ if (isDirectory(iosDirectory)) {
7853
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
7854
+ }
7855
+ }
7856
+ return nativeFilePaths;
7857
+ };
7858
+ const checkExpoGitignore = (context) => {
7859
+ const { rootDirectory } = context;
7860
+ const diagnostics = [];
7861
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
7862
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
7863
+ rule: "expo-gitignore",
7864
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
7865
+ help: "Add `.expo/` to your .gitignore"
7866
+ }));
7867
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
7868
+ rule: "expo-gitignore",
7869
+ 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",
7870
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
7871
+ }));
7872
+ return diagnostics;
7873
+ };
7874
+ const LOCKFILE_NAMES = [
7875
+ "pnpm-lock.yaml",
7876
+ "yarn.lock",
7877
+ "package-lock.json",
7878
+ "bun.lockb",
7879
+ "bun.lock"
7880
+ ];
7881
+ const checkExpoLockfile = (context) => {
7882
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
7883
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
7884
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
7885
+ rule: "expo-lockfile",
7886
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
7887
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
7888
+ })];
7889
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
7890
+ rule: "expo-lockfile",
7891
+ 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`,
7892
+ help: "Delete the lock files for the package managers you are not using and keep only one"
7893
+ })];
7894
+ return [];
7895
+ };
7896
+ const METRO_CONFIG_FILE_NAMES = [
7897
+ "metro.config.js",
7898
+ "metro.config.cjs",
7899
+ "metro.config.mjs",
7900
+ "metro.config.ts"
7901
+ ];
7902
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
7903
+ "expo/metro-config",
7904
+ "@sentry/react-native/metro",
7905
+ "getSentryExpoConfig"
7906
+ ];
7907
+ const checkExpoMetroConfig = (context) => {
7908
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
7909
+ if (metroConfigPath === void 0) return [];
7910
+ let contents;
7911
+ try {
7912
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
7913
+ } catch {
7914
+ return [];
7915
+ }
7916
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
7917
+ return [buildExpoDiagnostic({
7918
+ rule: "expo-metro-config",
7919
+ filePath: path.basename(metroConfigPath),
7920
+ 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",
7921
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
7922
+ })];
7923
+ };
7924
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
7925
+ const checkExpoPackageJsonConflicts = (context) => {
7926
+ const { packageJson } = context;
7927
+ const diagnostics = [];
7928
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
7929
+ if (conflictingScriptNames.length > 0) {
7930
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
7931
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
7932
+ diagnostics.push(buildExpoDiagnostic({
7933
+ rule: "expo-package-json-conflict",
7934
+ 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" : ""}`,
7935
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
7936
+ }));
7937
+ }
7938
+ const packageName = packageJson.name;
7939
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
7940
+ rule: "expo-package-json-conflict",
7941
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
7942
+ help: "Rename your package so it no longer matches one of its dependencies"
7943
+ }));
7944
+ return diagnostics;
7945
+ };
7946
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
7947
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
7948
+ const checkExpoRouterReactNavigation = (context) => {
7949
+ const { expoSdkMajor } = context;
7950
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
7951
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
7952
+ if (!context.directDependencyNames.has("expo-router")) return [];
7953
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
7954
+ if (reactNavigationNames.length === 0) return [];
7955
+ return [buildExpoDiagnostic({
7956
+ rule: "expo-router-no-react-navigation",
7957
+ 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"}`,
7958
+ 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/"
7959
+ })];
7960
+ };
7961
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
7962
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
7963
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
7964
+ const checkExpoVectorIcons = (context) => {
7965
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
7966
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
7967
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
7968
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
7969
+ return [buildExpoDiagnostic({
7970
+ rule: "expo-vector-icons-conflict",
7971
+ 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",
7972
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
7973
+ })];
7974
+ };
7975
+ const checkExpoProject = (rootDirectory, project) => {
7976
+ if (project.expoVersion === null) return [];
7977
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
7978
+ return [
7979
+ ...checkExpoFlaggedDependencies(context),
7980
+ ...checkExpoDependencyOverrides(context),
7981
+ ...checkExpoRouterReactNavigation(context),
7982
+ ...checkExpoVectorIcons(context),
7983
+ ...checkExpoPackageJsonConflicts(context),
7984
+ ...checkExpoLockfile(context),
7985
+ ...checkExpoGitignore(context),
7986
+ ...checkExpoEnvLocalFiles(context),
7987
+ ...checkExpoMetroConfig(context)
7988
+ ];
7989
+ };
7484
7990
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
7485
7991
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
7486
7992
  const PACKAGE_JSON_FILE = "package.json";
@@ -8758,6 +9264,7 @@ const buildCapabilities = (project) => {
8758
9264
  const capabilities = /* @__PURE__ */ new Set();
8759
9265
  capabilities.add(project.framework);
8760
9266
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
9267
+ if (project.expoVersion !== null) capabilities.add("expo");
8761
9268
  const reactMajor = project.reactMajorVersion;
8762
9269
  if (reactMajor !== null) {
8763
9270
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -8929,10 +9436,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
8929
9436
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
8930
9437
  return fs.realpathSync(rootDirectory);
8931
9438
  };
9439
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
9440
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
9441
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
9442
+ };
8932
9443
  const applyRuleSeverityControls = (rules, severityControls) => {
8933
9444
  const enabledRules = {};
8934
9445
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
8935
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
9446
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
8936
9447
  if (severity === "off") continue;
8937
9448
  enabledRules[ruleKey] = severity;
8938
9449
  }
@@ -8974,7 +9485,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
8974
9485
  category: rule.category
8975
9486
  }, severityControls);
8976
9487
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
8977
- const severity = explicitSeverity ?? rule.severity;
9488
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
8978
9489
  if (severity === "off") continue;
8979
9490
  enabledReactDoctorRules[registryEntry.key] = severity;
8980
9491
  }
@@ -9031,6 +9542,44 @@ const dedupeDiagnostics = (diagnostics) => {
9031
9542
  }
9032
9543
  return uniqueDiagnostics;
9033
9544
  };
9545
+ /**
9546
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
9547
+ * once, returning results in input order. A pool of workers each pulls the
9548
+ * next not-yet-started index until the list drains — so a worker that
9549
+ * finishes a fast task immediately picks up the next one (greedy load
9550
+ * balancing), which matters when tasks have uneven durations (oxlint
9551
+ * batches do).
9552
+ *
9553
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
9554
+ * no further tasks are started, the already-in-flight tasks are awaited to
9555
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
9556
+ * rejects with that first error. This keeps the caller's fail-fast retry
9557
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
9558
+ * top of a still-running first one.
9559
+ */
9560
+ const mapWithConcurrency = async (items, concurrency, task) => {
9561
+ const results = new Array(items.length);
9562
+ if (items.length === 0) return results;
9563
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
9564
+ let nextIndex = 0;
9565
+ const errors = [];
9566
+ const runWorker = async () => {
9567
+ while (errors.length === 0) {
9568
+ const index = nextIndex;
9569
+ nextIndex += 1;
9570
+ if (index >= items.length) return;
9571
+ try {
9572
+ results[index] = await task(items[index], index);
9573
+ } catch (error) {
9574
+ errors.push(error);
9575
+ return;
9576
+ }
9577
+ }
9578
+ };
9579
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
9580
+ if (errors.length > 0) throw errors[0];
9581
+ return results;
9582
+ };
9034
9583
  const getPublicEnvPrefix = (framework) => {
9035
9584
  switch (framework) {
9036
9585
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -9713,6 +10262,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
9713
10262
  */
9714
10263
  const spawnLintBatches = async (input) => {
9715
10264
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
10265
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
9716
10266
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
9717
10267
  const allDiagnostics = [];
9718
10268
  const droppedFiles = [];
@@ -9732,23 +10282,31 @@ const spawnLintBatches = async (input) => {
9732
10282
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
9733
10283
  }
9734
10284
  };
10285
+ let startedFileCount = 0;
9735
10286
  let scannedFileCount = 0;
9736
- for (const batch of fileBatches) {
9737
- let batchFileIndex = 0;
9738
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
9739
- if (batchFileIndex < batch.length) {
9740
- batchFileIndex += 1;
9741
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
9742
- }
9743
- }, 50) : null;
9744
- try {
10287
+ let displayedFileCount = 0;
10288
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
10289
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
10290
+ if (displayedFileCount < ceiling) {
10291
+ displayedFileCount += 1;
10292
+ onFileProgress(displayedFileCount, totalFileCount);
10293
+ }
10294
+ }, 50) : null;
10295
+ progressTimer?.unref?.();
10296
+ try {
10297
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
10298
+ startedFileCount += batch.length;
9745
10299
  const batchDiagnostics = await spawnLintBatch(batch);
9746
- allDiagnostics.push(...batchDiagnostics);
9747
10300
  scannedFileCount += batch.length;
9748
- onFileProgress?.(scannedFileCount, totalFileCount);
9749
- } finally {
9750
- if (progressInterval !== null) clearInterval(progressInterval);
9751
- }
10301
+ if (onFileProgress) {
10302
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
10303
+ onFileProgress(displayedFileCount, totalFileCount);
10304
+ }
10305
+ return batchDiagnostics;
10306
+ });
10307
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
10308
+ } finally {
10309
+ if (progressTimer !== null) clearInterval(progressTimer);
9752
10310
  }
9753
10311
  if (droppedFiles.length > 0 && onPartialFailure) {
9754
10312
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -9875,7 +10433,8 @@ const runOxlint = async (options) => {
9875
10433
  onPartialFailure,
9876
10434
  onFileProgress: options.onFileProgress,
9877
10435
  spawnTimeoutMs,
9878
- outputMaxBytes
10436
+ outputMaxBytes,
10437
+ concurrency: options.concurrency
9879
10438
  });
9880
10439
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
9881
10440
  try {
@@ -9943,6 +10502,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
9943
10502
  const partialFailures = yield* LintPartialFailures;
9944
10503
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
9945
10504
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
10505
+ const concurrency = yield* OxlintConcurrency;
9946
10506
  const collectedFailures = [];
9947
10507
  const diagnostics = yield* Effect.tryPromise({
9948
10508
  try: () => runOxlint({
@@ -9961,7 +10521,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
9961
10521
  },
9962
10522
  onFileProgress: input.onFileProgress,
9963
10523
  spawnTimeoutMs,
9964
- outputMaxBytes
10524
+ outputMaxBytes,
10525
+ concurrency
9965
10526
  }),
9966
10527
  catch: ensureReactDoctorError
9967
10528
  });
@@ -10294,7 +10855,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10294
10855
  showWarnings
10295
10856
  });
10296
10857
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
10297
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
10858
+ const environmentDiagnostics = isDiffMode ? [] : [
10859
+ ...checkReducedMotion(scanDirectory),
10860
+ ...checkPnpmHardening(scanDirectory),
10861
+ ...checkExpoProject(scanDirectory, project)
10862
+ ];
10298
10863
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
10299
10864
  const lintFailure = yield* Ref.make({
10300
10865
  didFail: false,
@@ -10306,6 +10871,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10306
10871
  didFail: false,
10307
10872
  reason: null
10308
10873
  });
10874
+ const scanConcurrency = yield* OxlintConcurrency;
10875
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
10309
10876
  const scanProgress = yield* progressService.start("Scanning...");
10310
10877
  const scanStartTime = Date.now();
10311
10878
  let lastReportedTotalFileCount = 0;
@@ -10322,7 +10889,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10322
10889
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
10323
10890
  onFileProgress: (scannedFileCount, totalFileCount) => {
10324
10891
  lastReportedTotalFileCount = totalFileCount;
10325
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
10892
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
10326
10893
  }
10327
10894
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
10328
10895
  yield* Ref.set(lintFailure, {
@@ -10354,7 +10921,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10354
10921
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
10355
10922
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
10356
10923
  else if (input.suppressScanSummary) yield* scanProgress.stop();
10357
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
10924
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
10358
10925
  yield* reporterService.finalize;
10359
10926
  const finalDiagnostics = [
10360
10927
  ...envCollected,
@@ -10406,7 +10973,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10406
10973
  "inspect.isCi": input.isCi,
10407
10974
  "inspect.scoreSurface": input.scoreSurface ?? "score"
10408
10975
  } }));
10409
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
10410
10976
  const parseNodeVersion = (versionString) => {
10411
10977
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
10412
10978
  return {
@@ -10729,6 +11295,26 @@ const buildJsonReport = (input) => {
10729
11295
  };
10730
11296
  };
10731
11297
  /**
11298
+ * Single source of truth for the skipped-check accounting shared by the
11299
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
11300
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
11301
+ * failed lint / dead-code pass instead of a false "all clear", so the
11302
+ * branch logic lives here once.
11303
+ */
11304
+ const buildSkippedChecks = (input) => {
11305
+ const skippedChecks = [];
11306
+ if (input.didLintFail) skippedChecks.push("lint");
11307
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
11308
+ const skippedCheckReasons = {};
11309
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
11310
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
11311
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
11312
+ return {
11313
+ skippedChecks,
11314
+ skippedCheckReasons
11315
+ };
11316
+ };
11317
+ /**
10732
11318
  * Programmatic façade over `Git.diffSelection`. Async because the
10733
11319
  * Git service runs through Effect's `ChildProcess` (true subprocess
10734
11320
  * spawn, not `spawnSync`).
@@ -10864,6 +11450,46 @@ const groupBy = (items, keyFn) => {
10864
11450
  */
10865
11451
  const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
10866
11452
  //#endregion
11453
+ //#region src/cli/utils/constants.ts
11454
+ const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
11455
+ const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
11456
+ const SENTRY_DSN = "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920";
11457
+ //#endregion
11458
+ //#region src/cli/utils/version.ts
11459
+ const VERSION = "0.2.14-dev.24425b1";
11460
+ //#endregion
11461
+ //#region src/instrument.ts
11462
+ let isInitialized = false;
11463
+ const shouldEnableSentry = () => {
11464
+ if (process.argv.includes("--no-score")) return false;
11465
+ if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
11466
+ return true;
11467
+ };
11468
+ /**
11469
+ * Initializes the Sentry Node SDK for CLI crash reporting. Invoked as
11470
+ * the first statement of the CLI entry (`cli/index.ts`) so the SDK's
11471
+ * global `uncaughtException` / `unhandledRejection` handlers are armed
11472
+ * before any command runs.
11473
+ *
11474
+ * Exported as a function rather than a bare side-effecting import
11475
+ * because the package declares `"sideEffects": false`, which lets the
11476
+ * bundler tree-shake side-effect-only modules. An explicit call keeps
11477
+ * the initialization in the published `dist/cli.js`.
11478
+ *
11479
+ * Scoped to the CLI application only — the programmatic
11480
+ * `@react-doctor/api` library never initializes Sentry, so importing
11481
+ * `diagnose()` into a consumer app can't hijack their telemetry.
11482
+ */
11483
+ const initializeSentry = () => {
11484
+ if (isInitialized || !shouldEnableSentry()) return;
11485
+ isInitialized = true;
11486
+ Sentry.init({
11487
+ dsn: SENTRY_DSN,
11488
+ sendDefaultPii: true,
11489
+ release: VERSION
11490
+ });
11491
+ };
11492
+ //#endregion
10867
11493
  //#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
10868
11494
  const ANSI_BACKGROUND_OFFSET = 10;
10869
11495
  const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
@@ -13817,23 +14443,60 @@ const CI_ENVIRONMENT_VARIABLES = [
13817
14443
  "GITLAB_CI",
13818
14444
  "CIRCLECI"
13819
14445
  ];
13820
- const CODING_AGENT_ENVIRONMENT_VARIABLES = [
13821
- "CLAUDECODE",
13822
- "CLAUDE_CODE",
13823
- "CURSOR_AGENT",
13824
- "CODEX_CI",
13825
- "CODEX_SANDBOX",
13826
- "CODEX_SANDBOX_NETWORK_DISABLED",
13827
- "OPENCODE",
13828
- "GOOSE_TERMINAL",
13829
- "AGENT_SESSION_ID",
13830
- "AMP_THREAD_ID",
13831
- "AGENT_THREAD_ID"
14446
+ const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
14447
+ ["GITHUB_ACTIONS", "github-actions"],
14448
+ ["GITLAB_CI", "gitlab-ci"],
14449
+ ["CIRCLECI", "circleci"],
14450
+ ["BUILDKITE", "buildkite"],
14451
+ ["JENKINS_URL", "jenkins"],
14452
+ ["TF_BUILD", "azure-pipelines"],
14453
+ ["CODEBUILD_BUILD_ID", "aws-codebuild"],
14454
+ ["TEAMCITY_VERSION", "teamcity"],
14455
+ ["BITBUCKET_BUILD_NUMBER", "bitbucket"],
14456
+ ["TRAVIS", "travis"],
14457
+ ["DRONE", "drone"]
13832
14458
  ];
14459
+ const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
14460
+ ["CLAUDECODE", "claude-code"],
14461
+ ["CLAUDE_CODE", "claude-code"],
14462
+ ["CURSOR_AGENT", "cursor"],
14463
+ ["CODEX_CI", "codex"],
14464
+ ["CODEX_SANDBOX", "codex"],
14465
+ ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
14466
+ ["OPENCODE", "opencode"],
14467
+ ["GOOSE_TERMINAL", "goose"],
14468
+ ["AMP_THREAD_ID", "amp"]
14469
+ ];
14470
+ const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
13833
14471
  const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
13834
14472
  const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
13835
- const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
13836
- const isCodingAgentEnvironment = () => CODING_AGENT_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES.some((envVariable) => CODING_AGENT_ENVIRONMENT_VALUES[envVariable].some((value) => process.env[envVariable]?.toLowerCase() === value));
14473
+ [...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
14474
+ const FALSY_CI_FLAG_VALUES = new Set([
14475
+ "",
14476
+ "0",
14477
+ "false"
14478
+ ]);
14479
+ const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
14480
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
14481
+ const detectCiProvider = () => {
14482
+ for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
14483
+ return isCiFlagSet(process.env.CI) ? "unknown" : null;
14484
+ };
14485
+ const detectCodingAgentFromValue = () => {
14486
+ for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
14487
+ const value = process.env[environmentVariable]?.toLowerCase();
14488
+ if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
14489
+ }
14490
+ return null;
14491
+ };
14492
+ const detectCodingAgent = () => {
14493
+ for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
14494
+ const agentFromValue = detectCodingAgentFromValue();
14495
+ if (agentFromValue) return agentFromValue;
14496
+ if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
14497
+ return null;
14498
+ };
14499
+ const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
13837
14500
  const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
13838
14501
  //#endregion
13839
14502
  //#region src/cli/utils/is-non-interactive-environment.ts
@@ -13935,9 +14598,8 @@ const buildSpinnerProgressHandle = (text) => {
13935
14598
  * construction and post-scan rendering — layer wiring is its own
13936
14599
  * concern with its own contract.
13937
14600
  *
13938
- * Same shape as `core/src/run-inspect.tslayerInspectLive`
13939
- * (the default for `@react-doctor/api diagnose()`) with the
13940
- * differences specific to the CLI path:
14601
+ * Same service shape as `@react-doctor/apidiagnose()`'s
14602
+ * `buildDiagnoseLayer`, with the differences specific to the CLI path:
13941
14603
  *
13942
14604
  * - **Config**: when the caller passes `configOverride`, the
13943
14605
  * already-loaded config is provided via `Config.layerOf` instead
@@ -13963,7 +14625,8 @@ const buildRuntimeLayers = (input) => {
13963
14625
  resolvedDirectory: input.directory,
13964
14626
  configSourceDirectory: input.configSourceDirectory
13965
14627
  }) : Config.layerNode;
13966
- return Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
14628
+ const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
14629
+ return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
13967
14630
  };
13968
14631
  //#endregion
13969
14632
  //#region src/cli/utils/noop-console.ts
@@ -14366,10 +15029,6 @@ const colorizeByScore = (text, score) => {
14366
15029
  return highlighter.error(text);
14367
15030
  };
14368
15031
  //#endregion
14369
- //#region src/cli/utils/constants.ts
14370
- const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
14371
- const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
14372
- //#endregion
14373
15032
  //#region src/cli/utils/render-score-header.ts
14374
15033
  const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
14375
15034
  const RAINBOW_GRADIENT_WIDTH = 80;
@@ -14754,9 +15413,6 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
14754
15413
  });
14755
15414
  const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
14756
15415
  //#endregion
14757
- //#region src/cli/utils/version.ts
14758
- const VERSION = "0.2.14-dev.09fe1ff";
14759
- //#endregion
14760
15416
  //#region src/inspect.ts
14761
15417
  const silentConsole = makeNoopConsole();
14762
15418
  const runConsole = (effect) => {
@@ -14785,7 +15441,8 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
14785
15441
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
14786
15442
  ignoredTags: buildIgnoredTags(userConfig),
14787
15443
  outputSurface: inputOptions.outputSurface ?? "cli",
14788
- suppressRendering: inputOptions.suppressRendering ?? false
15444
+ suppressRendering: inputOptions.suppressRendering ?? false,
15445
+ concurrency: inputOptions.concurrency
14789
15446
  });
14790
15447
  const inspect = async (directory, inputOptions = {}) => {
14791
15448
  const startTime = performance.now();
@@ -14825,7 +15482,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14825
15482
  shouldSkipLint: !options.lint || lintBindingMissing,
14826
15483
  shouldRunDeadCode: options.deadCode,
14827
15484
  shouldComputeScore: !options.noScore,
14828
- shouldShowProgressSpinners
15485
+ shouldShowProgressSpinners,
15486
+ oxlintConcurrency: options.concurrency
14829
15487
  });
14830
15488
  const program = runInspect({
14831
15489
  directory,
@@ -14881,15 +15539,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14881
15539
  };
14882
15540
  const finalizeAndRender = (input) => Effect.gen(function* () {
14883
15541
  const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
14884
- const skippedChecks = [];
14885
- if (didLintFail) skippedChecks.push("lint");
14886
- if (didDeadCodeFail) skippedChecks.push("dead-code");
15542
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
15543
+ didLintFail,
15544
+ lintFailureReason,
15545
+ lintPartialFailures,
15546
+ didDeadCodeFail,
15547
+ deadCodeFailureReason
15548
+ });
14887
15549
  const hasSkippedChecks = skippedChecks.length > 0;
14888
15550
  const noScoreMessage = buildNoScoreMessage(options.noScore);
14889
- const skippedCheckReasons = {};
14890
- if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
14891
- else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
14892
- if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
14893
15551
  const buildResult = () => ({
14894
15552
  diagnostics: [...diagnostics],
14895
15553
  score,
@@ -15043,6 +15701,7 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
15043
15701
  yield* Console.error("");
15044
15702
  yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
15045
15703
  yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
15704
+ yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
15046
15705
  yield* Console.error("");
15047
15706
  yield* Console.error(highlighter.error(formatErrorForReport(error)));
15048
15707
  yield* Console.error("");
@@ -16492,6 +17151,78 @@ const printBrandedHeader = Effect.gen(function* () {
16492
17151
  yield* Console.log("");
16493
17152
  });
16494
17153
  //#endregion
17154
+ //#region src/cli/utils/build-run-context.ts
17155
+ const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
17156
+ const detectOrigin = () => {
17157
+ if (process.env.GIT_DIR) return "git-hook";
17158
+ if (isCodingAgentEnvironment()) return "agent";
17159
+ if (isCiEnvironment()) return "ci";
17160
+ return "cli";
17161
+ };
17162
+ const detectCommand = (userArguments) => {
17163
+ for (const argument of userArguments) {
17164
+ if (argument === "--") break;
17165
+ if (argument.startsWith("-")) continue;
17166
+ return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
17167
+ }
17168
+ return "inspect";
17169
+ };
17170
+ /**
17171
+ * Snapshot of the current invocation, attached to Sentry events as the
17172
+ * `run` context to make crashes triage-able (which version, platform,
17173
+ * CI/agent, how it was invoked). Every field is cheap, synchronous, and
17174
+ * safe to read at any point — cwd reads fall back, env reads are
17175
+ * booleans — so it's rebuilt lazily at capture time when runtime-only
17176
+ * signals like `jsonMode` are finally known.
17177
+ */
17178
+ const buildRunContext = () => {
17179
+ const userArguments = process.argv.slice(2);
17180
+ return {
17181
+ version: VERSION,
17182
+ origin: detectOrigin(),
17183
+ command: detectCommand(userArguments),
17184
+ argv: userArguments.join(" "),
17185
+ cwd: process.cwd(),
17186
+ node: process.version,
17187
+ platform: process.platform,
17188
+ arch: process.arch,
17189
+ ci: isCiEnvironment(),
17190
+ ciProvider: detectCiProvider(),
17191
+ codingAgent: detectCodingAgent(),
17192
+ interactive: !isNonInteractiveEnvironment(),
17193
+ jsonMode: isJsonModeActive()
17194
+ };
17195
+ };
17196
+ //#endregion
17197
+ //#region src/cli/utils/report-error.ts
17198
+ /**
17199
+ * Sends an error to Sentry, enriched with a snapshot of the current run
17200
+ * (version, platform, CI/agent, invocation), and waits for delivery
17201
+ * before the caller exits. The CLI tears down the process synchronously
17202
+ * after rendering an error, so the awaited `flush` is what actually gets
17203
+ * the event off the machine (see the Sentry CLI/serverless flush
17204
+ * contract).
17205
+ *
17206
+ * Returns early when Sentry was never initialized (`--no-score`, tests,
17207
+ * or a missing DSN), and swallows any transport failure so telemetry can
17208
+ * never mask the user's original error.
17209
+ */
17210
+ const reportErrorToSentry = async (error) => {
17211
+ if (!Sentry.isInitialized()) return;
17212
+ try {
17213
+ const runContext = buildRunContext();
17214
+ Sentry.setContext("run", { ...runContext });
17215
+ Sentry.setTags({
17216
+ origin: runContext.origin,
17217
+ command: runContext.command,
17218
+ ciProvider: runContext.ciProvider,
17219
+ codingAgent: runContext.codingAgent
17220
+ });
17221
+ Sentry.captureException(error);
17222
+ await Sentry.flush(2e3);
17223
+ } catch {}
17224
+ };
17225
+ //#endregion
16495
17226
  //#region src/cli/utils/path-format.ts
16496
17227
  const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
16497
17228
  //#endregion
@@ -16636,6 +17367,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
16636
17367
  for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
16637
17368
  };
16638
17369
  //#endregion
17370
+ //#region src/cli/utils/resolve-parallel-flag.ts
17371
+ /**
17372
+ * Translates the `--experimental-parallel [workers]` flag into a concrete
17373
+ * worker count for `InspectOptions.concurrency`:
17374
+ *
17375
+ * - flag absent (`undefined`) → `undefined` (defer to the ambient
17376
+ * default: serial unless `REACT_DOCTOR_PARALLEL` is set)
17377
+ * - bare flag / `auto` → auto-detect CPU cores
17378
+ * - `--experimental-parallel <n>` → `n` workers (clamped)
17379
+ * - `false` / `off` / `0` → serial (an explicit opt-out, so
17380
+ * it overrides an env-enabled default rather than deferring to it)
17381
+ * - an unparseable value → auto-detect cores
17382
+ *
17383
+ * Commander yields `true` for a bare flag, the raw string for an explicit
17384
+ * value, and `undefined` when the flag is omitted.
17385
+ */
17386
+ const resolveParallelFlag = (parallel) => {
17387
+ if (parallel === void 0) return void 0;
17388
+ if (parallel === true) return resolveScanConcurrency("auto");
17389
+ if (parallel === false) return 1;
17390
+ const normalized = parallel.trim().toLowerCase();
17391
+ if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
17392
+ if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
17393
+ const parsed = Number.parseInt(normalized, 10);
17394
+ if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
17395
+ return resolveScanConcurrency(parsed);
17396
+ };
17397
+ //#endregion
16639
17398
  //#region src/cli/utils/resolve-cli-inspect-options.ts
16640
17399
  /**
16641
17400
  * Translates CLI flags into the `InspectOptions` contract `inspect()`
@@ -16661,7 +17420,8 @@ const resolveCliInspectOptions = (flags, userConfig) => {
16661
17420
  noScore: flags.score === false || (userConfig?.noScore ?? false),
16662
17421
  isCi: isCiEnvironment(),
16663
17422
  silent: Boolean(flags.json),
16664
- outputSurface: flags.prComment ? "prComment" : "cli"
17423
+ outputSurface: flags.prComment ? "prComment" : "cli",
17424
+ concurrency: resolveParallelFlag(flags.experimentalParallel)
16665
17425
  };
16666
17426
  };
16667
17427
  //#endregion
@@ -17145,6 +17905,7 @@ const inspectAction = async (directory, flags) => {
17145
17905
  })) printAgentInstallHint();
17146
17906
  }
17147
17907
  } catch (error) {
17908
+ await reportErrorToSentry(error);
17148
17909
  if (isJsonMode) {
17149
17910
  writeJsonErrorReport(error);
17150
17911
  process.exitCode = 1;
@@ -17166,6 +17927,7 @@ const installAction = async (options, command) => {
17166
17927
  projectRoot: options.cwd ?? process.cwd()
17167
17928
  });
17168
17929
  } catch (error) {
17930
+ await reportErrorToSentry(error);
17169
17931
  handleError(error);
17170
17932
  }
17171
17933
  };
@@ -17211,7 +17973,7 @@ const ROOT_FLAG_SPEC = {
17211
17973
  "--project",
17212
17974
  "--why"
17213
17975
  ]),
17214
- longOptionsWithOptionalValues: new Set(["--diff"]),
17976
+ longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
17215
17977
  shortOptionsWithoutValues: new Set([
17216
17978
  "-h",
17217
17979
  "-v",
@@ -17300,10 +18062,11 @@ const stripUnknownCliFlags = (argv) => {
17300
18062
  };
17301
18063
  //#endregion
17302
18064
  //#region src/cli/index.ts
18065
+ initializeSentry();
17303
18066
  process.on("SIGINT", exitGracefully);
17304
18067
  process.on("SIGTERM", exitGracefully);
17305
18068
  unrefStdin();
17306
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead-code analysis (default)").option("--no-dead-code", "skip dead-code analysis (unused files / exports / dependencies, circular imports)").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--changed-files-from <file>", "internal: scan source files listed in a newline-delimited changed-files file").option("--no-score", "skip the score API and the share URL").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none (default: none)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--pr-comment", "tune CLI output for sticky PR comments (drops weak-signal rule families like `design` from the printed list and the fail-on gate; configure via config.surfaces)").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "alias for --explain").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").option("--warnings", "show warning-severity diagnostics (errors always show)").option("--no-warnings", "hide warning-severity diagnostics (default)").addHelpText("after", `
18069
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead-code analysis (default)").option("--no-dead-code", "skip dead-code analysis (unused files / exports / dependencies, circular imports)").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--experimental-parallel [workers]", "experimental: lint with N parallel workers (default: auto-detect CPU cores) — speeds up large repos").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--changed-files-from <file>", "internal: scan source files listed in a newline-delimited changed-files file").option("--no-score", "skip the score API and the share URL").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none (default: none)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--pr-comment", "tune CLI output for sticky PR comments (drops weak-signal rule families like `design` from the printed list and the fail-on gate; configure via config.surfaces)").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "alias for --explain").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").option("--warnings", "show warning-severity diagnostics (errors always show)").option("--no-warnings", "hide warning-severity diagnostics (default)").addHelpText("after", `
17307
18070
  ${highlighter.dim("Configuration:")}
17308
18071
  Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
17309
18072
  CLI flags always override config values. See the README for the full schema.
@@ -17316,7 +18079,8 @@ program.command("install").alias("setup").description("Install the react-doctor
17316
18079
  process.stdout.on("error", (error) => {
17317
18080
  if (error.code === "EPIPE") process.exit(0);
17318
18081
  });
17319
- program.parseAsync(stripUnknownCliFlags(process.argv)).catch((error) => {
18082
+ program.parseAsync(stripUnknownCliFlags(process.argv)).catch(async (error) => {
18083
+ await reportErrorToSentry(error);
17320
18084
  if (isJsonModeActive()) {
17321
18085
  writeJsonErrorReport(error);
17322
18086
  process.exit(1);