react-doctor 0.2.14-dev.9fc6f62 → 0.2.14-dev.b3c3aa9

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,7 +28,6 @@ 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
33
  import { performance } from "node:perf_hooks";
@@ -5892,29 +5892,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
5892
5892
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
5893
5893
  };
5894
5894
  };
5895
- const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
5896
- if (predicate(rootPackageJson)) return true;
5895
+ const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
5896
+ const rootValue = select(rootPackageJson);
5897
+ if (rootValue !== null) return rootValue;
5897
5898
  const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
5898
- if (patterns.length === 0) return false;
5899
+ if (patterns.length === 0) return null;
5899
5900
  const visitedDirectories = /* @__PURE__ */ new Set();
5900
5901
  for (const pattern of patterns) {
5901
- const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
5902
+ const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
5902
5903
  for (const workspaceDirectory of directories) {
5903
5904
  if (visitedDirectories.has(workspaceDirectory)) continue;
5904
5905
  visitedDirectories.add(workspaceDirectory);
5905
- if (predicate(readPackageJson$1(path.join(workspaceDirectory, "package.json")))) return true;
5906
+ const value = select(readPackageJson$1(path.join(workspaceDirectory, "package.json")));
5907
+ if (value !== null) return value;
5906
5908
  }
5907
5909
  }
5908
- return false;
5910
+ return null;
5909
5911
  };
5912
+ const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
5910
5913
  const NAMES = new Set([
5911
5914
  "react-native",
5912
5915
  "react-native-tvos",
5913
- "expo",
5914
- "expo-router",
5915
- "@expo/cli",
5916
- "@expo/metro-config",
5917
- "@expo/metro-runtime",
5916
+ ...new Set([
5917
+ "expo",
5918
+ "expo-router",
5919
+ "@expo/cli",
5920
+ "@expo/metro-config",
5921
+ "@expo/metro-runtime"
5922
+ ]),
5918
5923
  "react-native-windows",
5919
5924
  "react-native-macos"
5920
5925
  ]);
@@ -5938,6 +5943,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
5938
5943
  return false;
5939
5944
  };
5940
5945
  const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
5946
+ const getExpoDependencySpec = (packageJson) => {
5947
+ const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
5948
+ return typeof spec === "string" ? spec : null;
5949
+ };
5950
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
5941
5951
  const getPreactVersion = (packageJson) => {
5942
5952
  return {
5943
5953
  ...packageJson.peerDependencies,
@@ -6174,6 +6184,19 @@ const discoverProject = (directory) => {
6174
6184
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
6175
6185
  const sourceFileCount = countSourceFiles(directory);
6176
6186
  const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
6187
+ let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
6188
+ if (expoVersion !== null && isCatalogReference(expoVersion)) {
6189
+ const catalogName = extractCatalogName(expoVersion);
6190
+ let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
6191
+ if (!resolvedExpoVersion) {
6192
+ const monorepoRoot = findMonorepoRoot(directory);
6193
+ if (monorepoRoot) {
6194
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
6195
+ if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson$1(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
6196
+ }
6197
+ }
6198
+ expoVersion = resolvedExpoVersion ?? expoVersion;
6199
+ }
6177
6200
  const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
6178
6201
  const preactVersion = getPreactVersion(packageJson);
6179
6202
  const projectInfo = {
@@ -6191,6 +6214,7 @@ const discoverProject = (directory) => {
6191
6214
  preactVersion,
6192
6215
  preactMajorVersion: parseReactMajor(preactVersion),
6193
6216
  hasReactNativeWorkspace,
6217
+ expoVersion,
6194
6218
  hasReanimated,
6195
6219
  sourceFileCount
6196
6220
  };
@@ -6301,6 +6325,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
6301
6325
  "Accessibility",
6302
6326
  "Maintainability"
6303
6327
  ];
6328
+ const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
6329
+ const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
6330
+ const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
6304
6331
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
6305
6332
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
6306
6333
  var InvalidGlobPatternError = class extends Error {
@@ -6420,10 +6447,11 @@ const restampSeverity = (diagnostic, override) => {
6420
6447
  */
6421
6448
  const buildRuleSeverityControls = (config) => {
6422
6449
  if (!config) return void 0;
6423
- if (config.rules === void 0 && config.categories === void 0) return void 0;
6450
+ if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
6424
6451
  return {
6425
6452
  ...config.rules !== void 0 ? { rules: config.rules } : {},
6426
- ...config.categories !== void 0 ? { categories: config.categories } : {}
6453
+ ...config.categories !== void 0 ? { categories: config.categories } : {},
6454
+ ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
6427
6455
  };
6428
6456
  };
6429
6457
  const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
@@ -6787,6 +6815,65 @@ const resolveRuleSeverityOverride = (input, controls) => {
6787
6815
  }
6788
6816
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
6789
6817
  };
6818
+ const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
6819
+ const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
6820
+ const findNearestPackageDirectory$1 = (filename) => {
6821
+ if (!filename) return null;
6822
+ const fromCache = cachedPackageDirectoryByFilename.get(filename);
6823
+ if (fromCache !== void 0) return fromCache;
6824
+ let currentDirectory = path.dirname(filename);
6825
+ while (true) {
6826
+ const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
6827
+ let hasPackageJson = false;
6828
+ try {
6829
+ hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
6830
+ } catch {
6831
+ hasPackageJson = false;
6832
+ }
6833
+ if (hasPackageJson) {
6834
+ cachedPackageDirectoryByFilename.set(filename, currentDirectory);
6835
+ return currentDirectory;
6836
+ }
6837
+ const parentDirectory = path.dirname(currentDirectory);
6838
+ if (parentDirectory === currentDirectory) {
6839
+ cachedPackageDirectoryByFilename.set(filename, null);
6840
+ return null;
6841
+ }
6842
+ currentDirectory = parentDirectory;
6843
+ }
6844
+ };
6845
+ const readManifest = (packageJsonPath) => {
6846
+ try {
6847
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
6848
+ if (typeof parsed === "object" && parsed !== null) return parsed;
6849
+ return null;
6850
+ } catch {
6851
+ return null;
6852
+ }
6853
+ };
6854
+ const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
6855
+ const classifyByDirectoryCohort = (packageDirectory) => {
6856
+ let current = packageDirectory;
6857
+ while (true) {
6858
+ if (path.basename(current) === "apps") return "app";
6859
+ const parent = path.dirname(current);
6860
+ if (parent === current) return null;
6861
+ current = parent;
6862
+ }
6863
+ };
6864
+ const classifyPackageRole = (filename) => {
6865
+ if (!filename) return "unknown";
6866
+ const packageDirectory = findNearestPackageDirectory$1(filename);
6867
+ if (!packageDirectory) return "unknown";
6868
+ const cached = cachedRoleByPackageDirectory.get(packageDirectory);
6869
+ if (cached !== void 0) return cached;
6870
+ const manifest = readManifest(path.join(packageDirectory, "package.json"));
6871
+ let result;
6872
+ if (manifest && hasPublishContract(manifest)) result = "library";
6873
+ else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
6874
+ cachedRoleByPackageDirectory.set(packageDirectory, result);
6875
+ return result;
6876
+ };
6790
6877
  /**
6791
6878
  * Resolves the absolute path to read for a diagnostic's `filePath`,
6792
6879
  * accounting for the various shapes oxlint emits:
@@ -6949,6 +7036,15 @@ const buildDiagnosticPipeline = (input) => {
6949
7036
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
6950
7037
  const fileLinesCache = /* @__PURE__ */ new Map();
6951
7038
  const testFileCache = /* @__PURE__ */ new Map();
7039
+ const libraryFileCache = /* @__PURE__ */ new Map();
7040
+ const isLibraryFile = (filePath) => {
7041
+ let cached = libraryFileCache.get(filePath);
7042
+ if (cached === void 0) {
7043
+ cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
7044
+ libraryFileCache.set(filePath, cached);
7045
+ }
7046
+ return cached;
7047
+ };
6952
7048
  const getFileLines = (filePath) => {
6953
7049
  const cached = fileLinesCache.get(filePath);
6954
7050
  if (cached !== void 0) return cached;
@@ -6975,6 +7071,10 @@ const buildDiagnosticPipeline = (input) => {
6975
7071
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
6976
7072
  return false;
6977
7073
  };
7074
+ const isAppOnlyRule = (ruleIdentifier) => {
7075
+ for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
7076
+ return false;
7077
+ };
6978
7078
  const isRnRawTextSuppressedByConfig = (diagnostic) => {
6979
7079
  if (diagnostic.rule !== "rn-no-raw-text") return false;
6980
7080
  if (diagnostic.line <= 0) return false;
@@ -6989,8 +7089,10 @@ const buildDiagnosticPipeline = (input) => {
6989
7089
  if (shouldAutoSuppress(diagnostic)) return null;
6990
7090
  let current = diagnostic;
6991
7091
  let explicitSeverityOverride;
7092
+ let explicitRuleOverride;
6992
7093
  if (severityControls) {
6993
7094
  const { ruleKey, category } = getDiagnosticRuleIdentity(current);
7095
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
6994
7096
  explicitSeverityOverride = resolveRuleSeverityOverride({
6995
7097
  ruleKey,
6996
7098
  category
@@ -6998,6 +7100,9 @@ const buildDiagnosticPipeline = (input) => {
6998
7100
  if (explicitSeverityOverride === "off") return null;
6999
7101
  if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
7000
7102
  }
7103
+ if (explicitRuleOverride === void 0) {
7104
+ if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
7105
+ }
7001
7106
  if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
7002
7107
  if (userConfig) {
7003
7108
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
@@ -7183,6 +7288,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
7183
7288
  }).pipe(Layer.provide(FetchHttpClient.layer));
7184
7289
  }).pipe(Effect.orDie));
7185
7290
  /**
7291
+ * Resolves a requested lint worker count to a clamped integer within
7292
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
7293
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
7294
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
7295
+ */
7296
+ const resolveScanConcurrency = (requested) => {
7297
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
7298
+ if (!Number.isFinite(desired) || desired < 1) return 1;
7299
+ return Math.max(1, Math.min(Math.floor(desired), 16));
7300
+ };
7301
+ /**
7186
7302
  * Per-batch oxlint wall-clock budget. Reads from the env var on
7187
7303
  * startup so the eval harness can raise the budget under sandbox
7188
7304
  * microVMs without recompiling react-doctor. Tests override via
@@ -7202,6 +7318,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
7202
7318
  * tests that exercise the cap behavior.
7203
7319
  */
7204
7320
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
7321
+ /**
7322
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
7323
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
7324
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
7325
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
7326
+ * CI callers that never touch the flag:
7327
+ *
7328
+ * - unset / `0` / `false` / `off` → `1` (serial)
7329
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
7330
+ * - a positive integer → that many workers (clamped)
7331
+ *
7332
+ * The resolved value is always within
7333
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
7334
+ */
7335
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
7336
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
7337
+ if (raw === void 0) return 1;
7338
+ const normalized = raw.trim().toLowerCase();
7339
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
7340
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
7341
+ const parsed = Number.parseInt(normalized, 10);
7342
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
7343
+ return resolveScanConcurrency(parsed);
7344
+ } }) {};
7205
7345
  const DIAGNOSTIC_SURFACES = [
7206
7346
  "cli",
7207
7347
  "prComment",
@@ -7362,16 +7502,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
7362
7502
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
7363
7503
  const loadConfigFromDirectory = (directory) => {
7364
7504
  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)}`);
7505
+ let sawBrokenConfigFile = false;
7506
+ if (isFile(configFilePath)) {
7507
+ try {
7508
+ const fileContent = fs.readFileSync(configFilePath, "utf-8");
7509
+ const parsed = JSON.parse(fileContent);
7510
+ if (isPlainObject(parsed)) return {
7511
+ status: "found",
7512
+ loaded: {
7513
+ config: validateConfigTypes(parsed),
7514
+ sourceDirectory: directory
7515
+ }
7516
+ };
7517
+ warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
7518
+ } catch (error) {
7519
+ warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
7520
+ }
7521
+ sawBrokenConfigFile = true;
7375
7522
  }
7376
7523
  const packageJsonPath = path.join(directory, "package.json");
7377
7524
  if (isFile(packageJsonPath)) try {
@@ -7380,34 +7527,38 @@ const loadConfigFromDirectory = (directory) => {
7380
7527
  if (isPlainObject(packageJson)) {
7381
7528
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
7382
7529
  if (isPlainObject(embeddedConfig)) return {
7383
- config: validateConfigTypes(embeddedConfig),
7384
- sourceDirectory: directory
7530
+ status: "found",
7531
+ loaded: {
7532
+ config: validateConfigTypes(embeddedConfig),
7533
+ sourceDirectory: directory
7534
+ }
7385
7535
  };
7386
7536
  }
7387
- } catch {
7388
- return null;
7389
- }
7390
- return null;
7537
+ } catch {}
7538
+ return {
7539
+ status: sawBrokenConfigFile ? "invalid" : "absent",
7540
+ loaded: null
7541
+ };
7391
7542
  };
7392
7543
  const cachedConfigs = /* @__PURE__ */ new Map();
7393
7544
  const loadConfigWithSource = (rootDirectory) => {
7394
7545
  const cached = cachedConfigs.get(rootDirectory);
7395
7546
  if (cached !== void 0) return cached;
7396
- const localConfig = loadConfigFromDirectory(rootDirectory);
7397
- if (localConfig) {
7398
- cachedConfigs.set(rootDirectory, localConfig);
7399
- return localConfig;
7547
+ const localResult = loadConfigFromDirectory(rootDirectory);
7548
+ if (localResult.status === "found") {
7549
+ cachedConfigs.set(rootDirectory, localResult.loaded);
7550
+ return localResult.loaded;
7400
7551
  }
7401
- if (isProjectBoundary(rootDirectory)) {
7552
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
7402
7553
  cachedConfigs.set(rootDirectory, null);
7403
7554
  return null;
7404
7555
  }
7405
7556
  let ancestorDirectory = path.dirname(rootDirectory);
7406
7557
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
7407
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
7408
- if (ancestorConfig) {
7409
- cachedConfigs.set(rootDirectory, ancestorConfig);
7410
- return ancestorConfig;
7558
+ const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
7559
+ if (ancestorResult.status === "found") {
7560
+ cachedConfigs.set(rootDirectory, ancestorResult.loaded);
7561
+ return ancestorResult.loaded;
7411
7562
  }
7412
7563
  if (isProjectBoundary(ancestorDirectory)) {
7413
7564
  cachedConfigs.set(rootDirectory, null);
@@ -7432,11 +7583,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
7432
7583
  }
7433
7584
  return resolvedRootDir;
7434
7585
  };
7435
- const resolveDiagnoseTarget = (directory) => {
7586
+ const resolveDiagnoseTarget = (directory, options = {}) => {
7436
7587
  if (isFile(path.join(directory, "package.json"))) return directory;
7437
7588
  const reactSubprojects = discoverReactSubprojects(directory);
7438
7589
  if (reactSubprojects.length === 0) return null;
7439
7590
  if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
7591
+ if (options.allowAmbiguous === true) return null;
7440
7592
  throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
7441
7593
  };
7442
7594
  /**
@@ -7450,7 +7602,8 @@ const resolveDiagnoseTarget = (directory) => {
7450
7602
  * project root, if configured.
7451
7603
  * 4. Walk into a nested React subproject when the requested
7452
7604
  * directory has no `package.json` of its own (raises
7453
- * `AmbiguousProjectError` when multiple candidates exist).
7605
+ * `AmbiguousProjectError` when multiple candidates exist unless
7606
+ * the caller opts into keeping the wrapper directory).
7454
7607
  *
7455
7608
  * Throws `ProjectNotFoundError` when neither the requested directory
7456
7609
  * nor any discoverable nested project has a `package.json`.
@@ -7462,14 +7615,14 @@ const resolveDiagnoseTarget = (directory) => {
7462
7615
  * via its own cache). Routing through `resolveScanTarget` keeps every
7463
7616
  * shell in agreement on what "the scan directory" means.
7464
7617
  */
7465
- const resolveScanTarget = (requestedDirectory) => {
7618
+ const resolveScanTarget = (requestedDirectory, options = {}) => {
7466
7619
  const absoluteRequested = path.resolve(requestedDirectory);
7467
7620
  const loadedConfig = loadConfigWithSource(absoluteRequested);
7468
7621
  const userConfig = loadedConfig?.config ?? null;
7469
7622
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
7470
7623
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
7471
7624
  const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
7472
- const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
7625
+ const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
7473
7626
  if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
7474
7627
  return {
7475
7628
  resolvedDirectory,
@@ -7479,6 +7632,359 @@ const resolveScanTarget = (requestedDirectory) => {
7479
7632
  didRedirectViaRootDir: redirectedDirectory !== null
7480
7633
  };
7481
7634
  };
7635
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
7636
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
7637
+ const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
7638
+ return {
7639
+ rootDirectory,
7640
+ packageJson,
7641
+ directDependencyNames: getDirectDependencyNames(packageJson),
7642
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
7643
+ };
7644
+ };
7645
+ const buildExpoDiagnostic = (input) => ({
7646
+ filePath: input.filePath ?? "package.json",
7647
+ plugin: "react-doctor",
7648
+ rule: input.rule,
7649
+ severity: input.severity ?? "warning",
7650
+ message: input.message,
7651
+ help: input.help,
7652
+ line: input.line ?? 0,
7653
+ column: input.column ?? 0,
7654
+ category: input.category ?? "Correctness"
7655
+ });
7656
+ const CRITICAL_OVERRIDE_NAMES = new Set([
7657
+ "@expo/cli",
7658
+ "@expo/config",
7659
+ "@expo/metro-config",
7660
+ "@expo/metro-runtime",
7661
+ "@expo/metro",
7662
+ "metro"
7663
+ ]);
7664
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
7665
+ const collectOverrideNames = (packageJson) => new Set([
7666
+ ...Object.keys(packageJson.overrides ?? {}),
7667
+ ...Object.keys(packageJson.resolutions ?? {}),
7668
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
7669
+ ]);
7670
+ const checkExpoDependencyOverrides = (context) => {
7671
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
7672
+ if (overriddenCriticalNames.length === 0) return [];
7673
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
7674
+ return [buildExpoDiagnostic({
7675
+ rule: "expo-no-conflicting-dependency-override",
7676
+ 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`,
7677
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
7678
+ })];
7679
+ };
7680
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
7681
+ const result = spawnSync("git", [
7682
+ "check-ignore",
7683
+ "-q",
7684
+ absolutePath
7685
+ ], {
7686
+ cwd: rootDirectory,
7687
+ stdio: [
7688
+ "ignore",
7689
+ "ignore",
7690
+ "ignore"
7691
+ ]
7692
+ });
7693
+ if (result.error) return null;
7694
+ if (result.status === 0) return true;
7695
+ if (result.status === 1) return false;
7696
+ return null;
7697
+ };
7698
+ const LOCAL_ENV_FILE_NAMES = [
7699
+ ".env.local",
7700
+ ".env.development.local",
7701
+ ".env.production.local",
7702
+ ".env.test.local"
7703
+ ];
7704
+ const checkExpoEnvLocalFiles = (context) => {
7705
+ const { rootDirectory } = context;
7706
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
7707
+ const filePath = path.join(rootDirectory, fileName);
7708
+ if (!isFile(filePath)) return false;
7709
+ return isPathGitIgnored(rootDirectory, filePath) === false;
7710
+ });
7711
+ if (committedEnvFiles.length === 0) return [];
7712
+ return [buildExpoDiagnostic({
7713
+ rule: "expo-env-local-not-gitignored",
7714
+ category: "Security",
7715
+ 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`,
7716
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
7717
+ })];
7718
+ };
7719
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
7720
+ 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";
7721
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
7722
+ const unimodulesEntry = (packageName) => ({
7723
+ packageName,
7724
+ rule: "expo-no-unimodules-packages",
7725
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
7726
+ help: UNIMODULES_HELP
7727
+ });
7728
+ const FLAGGED_DEPENDENCIES = [
7729
+ unimodulesEntry("@unimodules/core"),
7730
+ unimodulesEntry("@unimodules/react-native-adapter"),
7731
+ unimodulesEntry("react-native-unimodules"),
7732
+ {
7733
+ packageName: "expo-cli",
7734
+ rule: "expo-no-cli-dependencies",
7735
+ 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`",
7736
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
7737
+ },
7738
+ {
7739
+ packageName: "eas-cli",
7740
+ rule: "expo-no-cli-dependencies",
7741
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
7742
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
7743
+ },
7744
+ {
7745
+ packageName: "expo-modules-autolinking",
7746
+ rule: "expo-no-redundant-dependency",
7747
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
7748
+ help: "Remove `expo-modules-autolinking` from your package.json"
7749
+ },
7750
+ {
7751
+ packageName: "expo-dev-launcher",
7752
+ rule: "expo-no-redundant-dependency",
7753
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
7754
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
7755
+ },
7756
+ {
7757
+ packageName: "expo-dev-menu",
7758
+ rule: "expo-no-redundant-dependency",
7759
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
7760
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
7761
+ },
7762
+ {
7763
+ packageName: "expo-modules-core",
7764
+ rule: "expo-no-redundant-dependency",
7765
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
7766
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
7767
+ },
7768
+ {
7769
+ packageName: "@expo/metro-config",
7770
+ rule: "expo-no-redundant-dependency",
7771
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
7772
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
7773
+ },
7774
+ {
7775
+ packageName: "@types/react-native",
7776
+ rule: "expo-no-redundant-dependency",
7777
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
7778
+ help: "Remove `@types/react-native` from your package.json",
7779
+ minSdkMajor: 48
7780
+ },
7781
+ {
7782
+ packageName: "@expo/config-plugins",
7783
+ rule: "expo-no-redundant-dependency",
7784
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
7785
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
7786
+ minSdkMajor: 48
7787
+ },
7788
+ {
7789
+ packageName: "@expo/prebuild-config",
7790
+ rule: "expo-no-redundant-dependency",
7791
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
7792
+ help: "Remove `@expo/prebuild-config` from your package.json",
7793
+ minSdkMajor: 53
7794
+ },
7795
+ {
7796
+ packageName: "expo-permissions",
7797
+ rule: "expo-no-redundant-dependency",
7798
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
7799
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
7800
+ minSdkMajor: 50
7801
+ },
7802
+ {
7803
+ packageName: "expo-app-loading",
7804
+ rule: "expo-no-redundant-dependency",
7805
+ message: "\"expo-app-loading\" was removed in SDK 49",
7806
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
7807
+ minSdkMajor: 49
7808
+ },
7809
+ {
7810
+ packageName: "expo-firebase-analytics",
7811
+ rule: "expo-no-redundant-dependency",
7812
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
7813
+ help: FIREBASE_HELP,
7814
+ minSdkMajor: 48
7815
+ },
7816
+ {
7817
+ packageName: "expo-firebase-recaptcha",
7818
+ rule: "expo-no-redundant-dependency",
7819
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
7820
+ help: FIREBASE_HELP,
7821
+ minSdkMajor: 48
7822
+ },
7823
+ {
7824
+ packageName: "expo-firebase-core",
7825
+ rule: "expo-no-redundant-dependency",
7826
+ message: "\"expo-firebase-core\" was removed in SDK 48",
7827
+ help: FIREBASE_HELP,
7828
+ minSdkMajor: 48
7829
+ }
7830
+ ];
7831
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
7832
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
7833
+ if (flaggedDependency.minSdkMajor === void 0) return true;
7834
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
7835
+ }).map((flaggedDependency) => buildExpoDiagnostic({
7836
+ rule: flaggedDependency.rule,
7837
+ message: flaggedDependency.message,
7838
+ help: flaggedDependency.help
7839
+ }));
7840
+ const findLocalModuleNativeFiles = (rootDirectory) => {
7841
+ const modulesDirectory = path.join(rootDirectory, "modules");
7842
+ if (!isDirectory(modulesDirectory)) return [];
7843
+ const nativeFilePaths = [];
7844
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
7845
+ if (!moduleEntry.isDirectory()) continue;
7846
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
7847
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
7848
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
7849
+ const iosDirectory = path.join(moduleDirectory, "ios");
7850
+ if (isDirectory(iosDirectory)) {
7851
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
7852
+ }
7853
+ }
7854
+ return nativeFilePaths;
7855
+ };
7856
+ const checkExpoGitignore = (context) => {
7857
+ const { rootDirectory } = context;
7858
+ const diagnostics = [];
7859
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
7860
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
7861
+ rule: "expo-gitignore",
7862
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
7863
+ help: "Add `.expo/` to your .gitignore"
7864
+ }));
7865
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
7866
+ rule: "expo-gitignore",
7867
+ 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",
7868
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
7869
+ }));
7870
+ return diagnostics;
7871
+ };
7872
+ const LOCKFILE_NAMES = [
7873
+ "pnpm-lock.yaml",
7874
+ "yarn.lock",
7875
+ "package-lock.json",
7876
+ "bun.lockb",
7877
+ "bun.lock"
7878
+ ];
7879
+ const checkExpoLockfile = (context) => {
7880
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
7881
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
7882
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
7883
+ rule: "expo-lockfile",
7884
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
7885
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
7886
+ })];
7887
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
7888
+ rule: "expo-lockfile",
7889
+ 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`,
7890
+ help: "Delete the lock files for the package managers you are not using and keep only one"
7891
+ })];
7892
+ return [];
7893
+ };
7894
+ const METRO_CONFIG_FILE_NAMES = [
7895
+ "metro.config.js",
7896
+ "metro.config.cjs",
7897
+ "metro.config.mjs",
7898
+ "metro.config.ts"
7899
+ ];
7900
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
7901
+ "expo/metro-config",
7902
+ "@sentry/react-native/metro",
7903
+ "getSentryExpoConfig"
7904
+ ];
7905
+ const checkExpoMetroConfig = (context) => {
7906
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
7907
+ if (metroConfigPath === void 0) return [];
7908
+ let contents;
7909
+ try {
7910
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
7911
+ } catch {
7912
+ return [];
7913
+ }
7914
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
7915
+ return [buildExpoDiagnostic({
7916
+ rule: "expo-metro-config",
7917
+ filePath: path.basename(metroConfigPath),
7918
+ 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",
7919
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
7920
+ })];
7921
+ };
7922
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
7923
+ const checkExpoPackageJsonConflicts = (context) => {
7924
+ const { packageJson } = context;
7925
+ const diagnostics = [];
7926
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
7927
+ if (conflictingScriptNames.length > 0) {
7928
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
7929
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
7930
+ diagnostics.push(buildExpoDiagnostic({
7931
+ rule: "expo-package-json-conflict",
7932
+ 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" : ""}`,
7933
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
7934
+ }));
7935
+ }
7936
+ const packageName = packageJson.name;
7937
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
7938
+ rule: "expo-package-json-conflict",
7939
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
7940
+ help: "Rename your package so it no longer matches one of its dependencies"
7941
+ }));
7942
+ return diagnostics;
7943
+ };
7944
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
7945
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
7946
+ const checkExpoRouterReactNavigation = (context) => {
7947
+ const { expoSdkMajor } = context;
7948
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
7949
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
7950
+ if (!context.directDependencyNames.has("expo-router")) return [];
7951
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
7952
+ if (reactNavigationNames.length === 0) return [];
7953
+ return [buildExpoDiagnostic({
7954
+ rule: "expo-router-no-react-navigation",
7955
+ 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"}`,
7956
+ 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/"
7957
+ })];
7958
+ };
7959
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
7960
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
7961
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
7962
+ const checkExpoVectorIcons = (context) => {
7963
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
7964
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
7965
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
7966
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
7967
+ return [buildExpoDiagnostic({
7968
+ rule: "expo-vector-icons-conflict",
7969
+ 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",
7970
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
7971
+ })];
7972
+ };
7973
+ const checkExpoProject = (rootDirectory, project) => {
7974
+ if (project.expoVersion === null) return [];
7975
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
7976
+ return [
7977
+ ...checkExpoFlaggedDependencies(context),
7978
+ ...checkExpoDependencyOverrides(context),
7979
+ ...checkExpoRouterReactNavigation(context),
7980
+ ...checkExpoVectorIcons(context),
7981
+ ...checkExpoPackageJsonConflicts(context),
7982
+ ...checkExpoLockfile(context),
7983
+ ...checkExpoGitignore(context),
7984
+ ...checkExpoEnvLocalFiles(context),
7985
+ ...checkExpoMetroConfig(context)
7986
+ ];
7987
+ };
7482
7988
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
7483
7989
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
7484
7990
  const PACKAGE_JSON_FILE = "package.json";
@@ -7726,6 +8232,28 @@ const collectIgnorePatterns = (rootDirectory) => {
7726
8232
  cachedPatternsByRoot.set(rootDirectory, patterns);
7727
8233
  return patterns;
7728
8234
  };
8235
+ /**
8236
+ * Resolves a path to its canonical, symlink-free form, falling back to
8237
+ * the input when it cannot be realpath'd (broken symlink, permission
8238
+ * error) so a best-effort normalization never throws.
8239
+ *
8240
+ * deslop's dead-code module graph is collected with `fast-glob` (which
8241
+ * keeps the scan root's symlinks intact) while imports are resolved
8242
+ * through `oxc-resolver` (which returns realpath'd targets). When the
8243
+ * project root sits behind a symlink — e.g. macOS iCloud-synced
8244
+ * `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
8245
+ * spaces diverge: every resolved import misses the graph and the files
8246
+ * they point at (commonly every `@/…` alias target) are mis-reported as
8247
+ * unreachable. Canonicalizing the root before the scan keeps both path
8248
+ * spaces in agreement.
8249
+ */
8250
+ const toCanonicalPath = (filePath) => {
8251
+ try {
8252
+ return fs.realpathSync(filePath);
8253
+ } catch {
8254
+ return filePath;
8255
+ }
8256
+ };
7729
8257
  const DEAD_CODE_PLUGIN = "deslop";
7730
8258
  const DEAD_CODE_CATEGORY = "Maintainability";
7731
8259
  const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
@@ -7982,7 +8510,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
7982
8510
  });
7983
8511
  });
7984
8512
  const checkDeadCode = async (options) => {
7985
- const { rootDirectory, userConfig } = options;
8513
+ const { userConfig } = options;
8514
+ const rootDirectory = toCanonicalPath(options.rootDirectory);
7986
8515
  if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
7987
8516
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
7988
8517
  const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
@@ -8733,6 +9262,7 @@ const buildCapabilities = (project) => {
8733
9262
  const capabilities = /* @__PURE__ */ new Set();
8734
9263
  capabilities.add(project.framework);
8735
9264
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
9265
+ if (project.expoVersion !== null) capabilities.add("expo");
8736
9266
  const reactMajor = project.reactMajorVersion;
8737
9267
  if (reactMajor !== null) {
8738
9268
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -8904,10 +9434,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
8904
9434
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
8905
9435
  return fs.realpathSync(rootDirectory);
8906
9436
  };
9437
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
9438
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
9439
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
9440
+ };
8907
9441
  const applyRuleSeverityControls = (rules, severityControls) => {
8908
9442
  const enabledRules = {};
8909
9443
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
8910
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
9444
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
8911
9445
  if (severity === "off") continue;
8912
9446
  enabledRules[ruleKey] = severity;
8913
9447
  }
@@ -8949,7 +9483,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
8949
9483
  category: rule.category
8950
9484
  }, severityControls);
8951
9485
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
8952
- const severity = explicitSeverity ?? rule.severity;
9486
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
8953
9487
  if (severity === "off") continue;
8954
9488
  enabledReactDoctorRules[registryEntry.key] = severity;
8955
9489
  }
@@ -9006,6 +9540,44 @@ const dedupeDiagnostics = (diagnostics) => {
9006
9540
  }
9007
9541
  return uniqueDiagnostics;
9008
9542
  };
9543
+ /**
9544
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
9545
+ * once, returning results in input order. A pool of workers each pulls the
9546
+ * next not-yet-started index until the list drains — so a worker that
9547
+ * finishes a fast task immediately picks up the next one (greedy load
9548
+ * balancing), which matters when tasks have uneven durations (oxlint
9549
+ * batches do).
9550
+ *
9551
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
9552
+ * no further tasks are started, the already-in-flight tasks are awaited to
9553
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
9554
+ * rejects with that first error. This keeps the caller's fail-fast retry
9555
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
9556
+ * top of a still-running first one.
9557
+ */
9558
+ const mapWithConcurrency = async (items, concurrency, task) => {
9559
+ const results = new Array(items.length);
9560
+ if (items.length === 0) return results;
9561
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
9562
+ let nextIndex = 0;
9563
+ const errors = [];
9564
+ const runWorker = async () => {
9565
+ while (errors.length === 0) {
9566
+ const index = nextIndex;
9567
+ nextIndex += 1;
9568
+ if (index >= items.length) return;
9569
+ try {
9570
+ results[index] = await task(items[index], index);
9571
+ } catch (error) {
9572
+ errors.push(error);
9573
+ return;
9574
+ }
9575
+ }
9576
+ };
9577
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
9578
+ if (errors.length > 0) throw errors[0];
9579
+ return results;
9580
+ };
9009
9581
  const getPublicEnvPrefix = (framework) => {
9010
9582
  switch (framework) {
9011
9583
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -9688,6 +10260,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
9688
10260
  */
9689
10261
  const spawnLintBatches = async (input) => {
9690
10262
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
10263
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
9691
10264
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
9692
10265
  const allDiagnostics = [];
9693
10266
  const droppedFiles = [];
@@ -9707,23 +10280,31 @@ const spawnLintBatches = async (input) => {
9707
10280
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
9708
10281
  }
9709
10282
  };
10283
+ let startedFileCount = 0;
9710
10284
  let scannedFileCount = 0;
9711
- for (const batch of fileBatches) {
9712
- let batchFileIndex = 0;
9713
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
9714
- if (batchFileIndex < batch.length) {
9715
- batchFileIndex += 1;
9716
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
9717
- }
9718
- }, 50) : null;
9719
- try {
10285
+ let displayedFileCount = 0;
10286
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
10287
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
10288
+ if (displayedFileCount < ceiling) {
10289
+ displayedFileCount += 1;
10290
+ onFileProgress(displayedFileCount, totalFileCount);
10291
+ }
10292
+ }, 50) : null;
10293
+ progressTimer?.unref?.();
10294
+ try {
10295
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
10296
+ startedFileCount += batch.length;
9720
10297
  const batchDiagnostics = await spawnLintBatch(batch);
9721
- allDiagnostics.push(...batchDiagnostics);
9722
10298
  scannedFileCount += batch.length;
9723
- onFileProgress?.(scannedFileCount, totalFileCount);
9724
- } finally {
9725
- if (progressInterval !== null) clearInterval(progressInterval);
9726
- }
10299
+ if (onFileProgress) {
10300
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
10301
+ onFileProgress(displayedFileCount, totalFileCount);
10302
+ }
10303
+ return batchDiagnostics;
10304
+ });
10305
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
10306
+ } finally {
10307
+ if (progressTimer !== null) clearInterval(progressTimer);
9727
10308
  }
9728
10309
  if (droppedFiles.length > 0 && onPartialFailure) {
9729
10310
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -9850,7 +10431,8 @@ const runOxlint = async (options) => {
9850
10431
  onPartialFailure,
9851
10432
  onFileProgress: options.onFileProgress,
9852
10433
  spawnTimeoutMs,
9853
- outputMaxBytes
10434
+ outputMaxBytes,
10435
+ concurrency: options.concurrency
9854
10436
  });
9855
10437
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
9856
10438
  try {
@@ -9918,6 +10500,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
9918
10500
  const partialFailures = yield* LintPartialFailures;
9919
10501
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
9920
10502
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
10503
+ const concurrency = yield* OxlintConcurrency;
9921
10504
  const collectedFailures = [];
9922
10505
  const diagnostics = yield* Effect.tryPromise({
9923
10506
  try: () => runOxlint({
@@ -9936,7 +10519,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
9936
10519
  },
9937
10520
  onFileProgress: input.onFileProgress,
9938
10521
  spawnTimeoutMs,
9939
- outputMaxBytes
10522
+ outputMaxBytes,
10523
+ concurrency
9940
10524
  }),
9941
10525
  catch: ensureReactDoctorError
9942
10526
  });
@@ -10269,7 +10853,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10269
10853
  showWarnings
10270
10854
  });
10271
10855
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
10272
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
10856
+ const environmentDiagnostics = isDiffMode ? [] : [
10857
+ ...checkReducedMotion(scanDirectory),
10858
+ ...checkPnpmHardening(scanDirectory),
10859
+ ...checkExpoProject(scanDirectory, project)
10860
+ ];
10273
10861
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
10274
10862
  const lintFailure = yield* Ref.make({
10275
10863
  didFail: false,
@@ -10281,6 +10869,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10281
10869
  didFail: false,
10282
10870
  reason: null
10283
10871
  });
10872
+ const scanConcurrency = yield* OxlintConcurrency;
10873
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
10284
10874
  const scanProgress = yield* progressService.start("Scanning...");
10285
10875
  const scanStartTime = Date.now();
10286
10876
  let lastReportedTotalFileCount = 0;
@@ -10297,7 +10887,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10297
10887
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
10298
10888
  onFileProgress: (scannedFileCount, totalFileCount) => {
10299
10889
  lastReportedTotalFileCount = totalFileCount;
10300
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
10890
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
10301
10891
  }
10302
10892
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
10303
10893
  yield* Ref.set(lintFailure, {
@@ -10329,7 +10919,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10329
10919
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
10330
10920
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
10331
10921
  else if (input.suppressScanSummary) yield* scanProgress.stop();
10332
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
10922
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
10333
10923
  yield* reporterService.finalize;
10334
10924
  const finalDiagnostics = [
10335
10925
  ...envCollected,
@@ -10381,7 +10971,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10381
10971
  "inspect.isCi": input.isCi,
10382
10972
  "inspect.scoreSurface": input.scoreSurface ?? "score"
10383
10973
  } }));
10384
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
10385
10974
  const parseNodeVersion = (versionString) => {
10386
10975
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
10387
10976
  return {
@@ -10704,6 +11293,26 @@ const buildJsonReport = (input) => {
10704
11293
  };
10705
11294
  };
10706
11295
  /**
11296
+ * Single source of truth for the skipped-check accounting shared by the
11297
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
11298
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
11299
+ * failed lint / dead-code pass instead of a false "all clear", so the
11300
+ * branch logic lives here once.
11301
+ */
11302
+ const buildSkippedChecks = (input) => {
11303
+ const skippedChecks = [];
11304
+ if (input.didLintFail) skippedChecks.push("lint");
11305
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
11306
+ const skippedCheckReasons = {};
11307
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
11308
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
11309
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
11310
+ return {
11311
+ skippedChecks,
11312
+ skippedCheckReasons
11313
+ };
11314
+ };
11315
+ /**
10707
11316
  * Programmatic façade over `Git.diffSelection`. Async because the
10708
11317
  * Git service runs through Effect's `ChildProcess` (true subprocess
10709
11318
  * spawn, not `spawnSync`).
@@ -10824,6 +11433,20 @@ const groupBy = (items, keyFn) => {
10824
11433
  }
10825
11434
  return groups;
10826
11435
  };
11436
+ /**
11437
+ * Whether a diagnostic's rule has a published per-rule fix recipe at
11438
+ * `${PROMPTS_RULES_BASE_URL}/react-doctor/<rule>.md`
11439
+ * (see `buildRulePromptUrl`).
11440
+ *
11441
+ * Recipes are generated from react-doctor's own engine rules, so only
11442
+ * those resolve. Dead-code (`deslop`), the synthetic environment and
11443
+ * supply-chain checks (`require-reduced-motion`, `require-pnpm-hardening`
11444
+ * — `react-doctor`-namespaced but not engine rules), and adopted
11445
+ * third-party plugins (`eslint`, `unicorn`, `react-hooks-js`, …) have no
11446
+ * recipe, so advertising "fetch the fix recipe" for them sends agents to
11447
+ * a 404. Gate the directive on this predicate.
11448
+ */
11449
+ const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
10827
11450
  //#endregion
10828
11451
  //#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
10829
11452
  const ANSI_BACKGROUND_OFFSET = 10;
@@ -13793,7 +14416,13 @@ const CODING_AGENT_ENVIRONMENT_VARIABLES = [
13793
14416
  ];
13794
14417
  const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
13795
14418
  const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
13796
- const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
14419
+ const FALSY_CI_FLAG_VALUES = new Set([
14420
+ "",
14421
+ "0",
14422
+ "false"
14423
+ ]);
14424
+ const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
14425
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCiFlagSet(process.env.CI);
13797
14426
  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));
13798
14427
  const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
13799
14428
  //#endregion
@@ -13896,9 +14525,8 @@ const buildSpinnerProgressHandle = (text) => {
13896
14525
  * construction and post-scan rendering — layer wiring is its own
13897
14526
  * concern with its own contract.
13898
14527
  *
13899
- * Same shape as `core/src/run-inspect.tslayerInspectLive`
13900
- * (the default for `@react-doctor/api diagnose()`) with the
13901
- * differences specific to the CLI path:
14528
+ * Same service shape as `@react-doctor/apidiagnose()`'s
14529
+ * `buildDiagnoseLayer`, with the differences specific to the CLI path:
13902
14530
  *
13903
14531
  * - **Config**: when the caller passes `configOverride`, the
13904
14532
  * already-loaded config is provided via `Config.layerOf` instead
@@ -13924,7 +14552,8 @@ const buildRuntimeLayers = (input) => {
13924
14552
  resolvedDirectory: input.directory,
13925
14553
  configSourceDirectory: input.configSourceDirectory
13926
14554
  }) : Config.layerNode;
13927
- return Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
14555
+ const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
14556
+ return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
13928
14557
  };
13929
14558
  //#endregion
13930
14559
  //#region src/cli/utils/noop-console.ts
@@ -14012,7 +14641,7 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
14012
14641
  };
14013
14642
  const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
14014
14643
  const FETCH_FIX_RECIPE_LABEL = "Fetch & follow the canonical fix recipe before fixing";
14015
- const formatFixRecipeLine = (diagnostic) => `${FETCH_FIX_RECIPE_LABEL}: ${buildRulePromptUrl(diagnostic.plugin, diagnostic.rule)}`;
14644
+ const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FETCH_FIX_RECIPE_LABEL}: ${buildRulePromptUrl(diagnostic.plugin, diagnostic.rule)}` : null;
14016
14645
  //#endregion
14017
14646
  //#region src/cli/utils/box-text.ts
14018
14647
  const ESCAPE = String.fromCharCode(27);
@@ -14296,7 +14925,8 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
14296
14925
  ];
14297
14926
  if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
14298
14927
  if (firstDiagnostic.url) sections.push("", `Docs: ${firstDiagnostic.url}`);
14299
- sections.push("", formatFixRecipeLine(firstDiagnostic));
14928
+ const fixRecipeLine = formatFixRecipeLine(firstDiagnostic);
14929
+ if (fixRecipeLine) sections.push("", fixRecipeLine);
14300
14930
  sections.push("", "Files:");
14301
14931
  const fileSites = buildVerboseSiteMap(ruleDiagnostics);
14302
14932
  for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
@@ -14715,7 +15345,7 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
14715
15345
  const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
14716
15346
  //#endregion
14717
15347
  //#region src/cli/utils/version.ts
14718
- const VERSION = "0.2.14-dev.9fc6f62";
15348
+ const VERSION = "0.2.14-dev.b3c3aa9";
14719
15349
  //#endregion
14720
15350
  //#region src/inspect.ts
14721
15351
  const silentConsole = makeNoopConsole();
@@ -14745,7 +15375,8 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
14745
15375
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
14746
15376
  ignoredTags: buildIgnoredTags(userConfig),
14747
15377
  outputSurface: inputOptions.outputSurface ?? "cli",
14748
- suppressRendering: inputOptions.suppressRendering ?? false
15378
+ suppressRendering: inputOptions.suppressRendering ?? false,
15379
+ concurrency: inputOptions.concurrency
14749
15380
  });
14750
15381
  const inspect = async (directory, inputOptions = {}) => {
14751
15382
  const startTime = performance.now();
@@ -14785,7 +15416,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14785
15416
  shouldSkipLint: !options.lint || lintBindingMissing,
14786
15417
  shouldRunDeadCode: options.deadCode,
14787
15418
  shouldComputeScore: !options.noScore,
14788
- shouldShowProgressSpinners
15419
+ shouldShowProgressSpinners,
15420
+ oxlintConcurrency: options.concurrency
14789
15421
  });
14790
15422
  const program = runInspect({
14791
15423
  directory,
@@ -14841,15 +15473,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14841
15473
  };
14842
15474
  const finalizeAndRender = (input) => Effect.gen(function* () {
14843
15475
  const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
14844
- const skippedChecks = [];
14845
- if (didLintFail) skippedChecks.push("lint");
14846
- if (didDeadCodeFail) skippedChecks.push("dead-code");
15476
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
15477
+ didLintFail,
15478
+ lintFailureReason,
15479
+ lintPartialFailures,
15480
+ didDeadCodeFail,
15481
+ deadCodeFailureReason
15482
+ });
14847
15483
  const hasSkippedChecks = skippedChecks.length > 0;
14848
15484
  const noScoreMessage = buildNoScoreMessage(options.noScore);
14849
- const skippedCheckReasons = {};
14850
- if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
14851
- else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
14852
- if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
14853
15485
  const buildResult = () => ({
14854
15486
  diagnostics: [...diagnostics],
14855
15487
  score,
@@ -15029,7 +15661,9 @@ const buildHandoffPayload = (input) => {
15029
15661
  topGroups.forEach(([ruleKey, ruleDiagnostics], index) => {
15030
15662
  const representative = ruleDiagnostics[0];
15031
15663
  const severityLabel = representative.severity === "error" ? "ERROR" : "WARN";
15032
- lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (×${ruleDiagnostics.length})`, ` ${representative.message}`, ` ${formatFixRecipeLine(representative)}`);
15664
+ lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (×${ruleDiagnostics.length})`, ` ${representative.message}`);
15665
+ const fixRecipeLine = formatFixRecipeLine(representative);
15666
+ if (fixRecipeLine) lines.push(` ${fixRecipeLine}`);
15033
15667
  const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
15034
15668
  for (const filePath of uniqueFiles.slice(0, 3)) {
15035
15669
  const firstSite = ruleDiagnostics.find((diagnostic) => diagnostic.filePath === filePath && diagnostic.line > 0);
@@ -16594,6 +17228,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
16594
17228
  for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
16595
17229
  };
16596
17230
  //#endregion
17231
+ //#region src/cli/utils/resolve-parallel-flag.ts
17232
+ /**
17233
+ * Translates the `--experimental-parallel [workers]` flag into a concrete
17234
+ * worker count for `InspectOptions.concurrency`:
17235
+ *
17236
+ * - flag absent (`undefined`) → `undefined` (defer to the ambient
17237
+ * default: serial unless `REACT_DOCTOR_PARALLEL` is set)
17238
+ * - bare flag / `auto` → auto-detect CPU cores
17239
+ * - `--experimental-parallel <n>` → `n` workers (clamped)
17240
+ * - `false` / `off` / `0` → serial (an explicit opt-out, so
17241
+ * it overrides an env-enabled default rather than deferring to it)
17242
+ * - an unparseable value → auto-detect cores
17243
+ *
17244
+ * Commander yields `true` for a bare flag, the raw string for an explicit
17245
+ * value, and `undefined` when the flag is omitted.
17246
+ */
17247
+ const resolveParallelFlag = (parallel) => {
17248
+ if (parallel === void 0) return void 0;
17249
+ if (parallel === true) return resolveScanConcurrency("auto");
17250
+ if (parallel === false) return 1;
17251
+ const normalized = parallel.trim().toLowerCase();
17252
+ if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
17253
+ if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
17254
+ const parsed = Number.parseInt(normalized, 10);
17255
+ if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
17256
+ return resolveScanConcurrency(parsed);
17257
+ };
17258
+ //#endregion
16597
17259
  //#region src/cli/utils/resolve-cli-inspect-options.ts
16598
17260
  /**
16599
17261
  * Translates CLI flags into the `InspectOptions` contract `inspect()`
@@ -16619,7 +17281,8 @@ const resolveCliInspectOptions = (flags, userConfig) => {
16619
17281
  noScore: flags.score === false || (userConfig?.noScore ?? false),
16620
17282
  isCi: isCiEnvironment(),
16621
17283
  silent: Boolean(flags.json),
16622
- outputSurface: flags.prComment ? "prComment" : "cli"
17284
+ outputSurface: flags.prComment ? "prComment" : "cli",
17285
+ concurrency: resolveParallelFlag(flags.experimentalParallel)
16623
17286
  };
16624
17287
  };
16625
17288
  //#endregion
@@ -16778,11 +17441,9 @@ const parseFileLineArgument = (rawArgument) => {
16778
17441
  //#endregion
16779
17442
  //#region src/cli/utils/select-projects.ts
16780
17443
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
17444
+ const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json"));
16781
17445
  let packages = listWorkspacePackages(rootDirectory);
16782
- if (packages.length === 0) {
16783
- if (!isMonorepoRoot(rootDirectory)) return [rootDirectory];
16784
- packages = discoverReactSubprojects(rootDirectory);
16785
- }
17446
+ if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) packages = discoverReactSubprojects(rootDirectory);
16786
17447
  if (packages.length === 0) return [rootDirectory];
16787
17448
  if (packages.length === 1) {
16788
17449
  cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages[0].name}`);
@@ -16931,7 +17592,7 @@ const inspectAction = async (directory, flags) => {
16931
17592
  });
16932
17593
  try {
16933
17594
  validateModeFlags(flags);
16934
- const scanTarget = resolveScanTarget(requestedDirectory);
17595
+ const scanTarget = resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
16935
17596
  const userConfig = scanTarget.userConfig;
16936
17597
  const resolvedDirectory = scanTarget.resolvedDirectory;
16937
17598
  setJsonReportDirectory(resolvedDirectory);
@@ -17171,7 +17832,7 @@ const ROOT_FLAG_SPEC = {
17171
17832
  "--project",
17172
17833
  "--why"
17173
17834
  ]),
17174
- longOptionsWithOptionalValues: new Set(["--diff"]),
17835
+ longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
17175
17836
  shortOptionsWithoutValues: new Set([
17176
17837
  "-h",
17177
17838
  "-v",
@@ -17263,7 +17924,7 @@ const stripUnknownCliFlags = (argv) => {
17263
17924
  process.on("SIGINT", exitGracefully);
17264
17925
  process.on("SIGTERM", exitGracefully);
17265
17926
  unrefStdin();
17266
- 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", `
17927
+ 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", `
17267
17928
  ${highlighter.dim("Configuration:")}
17268
17929
  Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
17269
17930
  CLI flags always override config values. See the README for the full schema.