react-doctor 0.2.14-dev.8b313ba → 0.2.14-dev.938376

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
  };
@@ -6266,7 +6291,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
6266
6291
  const SCORE_API_URL = "https://www.react.doctor/api/score";
6267
6292
  const ENTERPRISE_CONTACT_URL = "https://react.doctor/enterprise";
6268
6293
  const SHARE_BASE_URL = "https://react.doctor/share";
6269
- const PROMPTS_RULES_BASE_URL = "https://www.react.doctor/prompts/rules";
6294
+ const DOCS_URL = "https://www.react.doctor/docs";
6295
+ const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
6270
6296
  const FETCH_TIMEOUT_MS = 1e4;
6271
6297
  const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
6272
6298
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
@@ -6289,6 +6315,7 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
6289
6315
  ".oxlintrc.json"
6290
6316
  ];
6291
6317
  const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
6318
+ const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
6292
6319
  const SKILL_NAME = "react-doctor";
6293
6320
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
6294
6321
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
@@ -6301,6 +6328,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
6301
6328
  "Accessibility",
6302
6329
  "Maintainability"
6303
6330
  ];
6331
+ const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
6332
+ const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
6333
+ const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
6304
6334
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
6305
6335
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
6306
6336
  var InvalidGlobPatternError = class extends Error {
@@ -6420,10 +6450,11 @@ const restampSeverity = (diagnostic, override) => {
6420
6450
  */
6421
6451
  const buildRuleSeverityControls = (config) => {
6422
6452
  if (!config) return void 0;
6423
- if (config.rules === void 0 && config.categories === void 0) return void 0;
6453
+ if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
6424
6454
  return {
6425
6455
  ...config.rules !== void 0 ? { rules: config.rules } : {},
6426
- ...config.categories !== void 0 ? { categories: config.categories } : {}
6456
+ ...config.categories !== void 0 ? { categories: config.categories } : {},
6457
+ ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
6427
6458
  };
6428
6459
  };
6429
6460
  const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
@@ -6787,6 +6818,65 @@ const resolveRuleSeverityOverride = (input, controls) => {
6787
6818
  }
6788
6819
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
6789
6820
  };
6821
+ const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
6822
+ const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
6823
+ const findNearestPackageDirectory$1 = (filename) => {
6824
+ if (!filename) return null;
6825
+ const fromCache = cachedPackageDirectoryByFilename.get(filename);
6826
+ if (fromCache !== void 0) return fromCache;
6827
+ let currentDirectory = path.dirname(filename);
6828
+ while (true) {
6829
+ const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
6830
+ let hasPackageJson = false;
6831
+ try {
6832
+ hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
6833
+ } catch {
6834
+ hasPackageJson = false;
6835
+ }
6836
+ if (hasPackageJson) {
6837
+ cachedPackageDirectoryByFilename.set(filename, currentDirectory);
6838
+ return currentDirectory;
6839
+ }
6840
+ const parentDirectory = path.dirname(currentDirectory);
6841
+ if (parentDirectory === currentDirectory) {
6842
+ cachedPackageDirectoryByFilename.set(filename, null);
6843
+ return null;
6844
+ }
6845
+ currentDirectory = parentDirectory;
6846
+ }
6847
+ };
6848
+ const readManifest = (packageJsonPath) => {
6849
+ try {
6850
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
6851
+ if (typeof parsed === "object" && parsed !== null) return parsed;
6852
+ return null;
6853
+ } catch {
6854
+ return null;
6855
+ }
6856
+ };
6857
+ const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
6858
+ const classifyByDirectoryCohort = (packageDirectory) => {
6859
+ let current = packageDirectory;
6860
+ while (true) {
6861
+ if (path.basename(current) === "apps") return "app";
6862
+ const parent = path.dirname(current);
6863
+ if (parent === current) return null;
6864
+ current = parent;
6865
+ }
6866
+ };
6867
+ const classifyPackageRole = (filename) => {
6868
+ if (!filename) return "unknown";
6869
+ const packageDirectory = findNearestPackageDirectory$1(filename);
6870
+ if (!packageDirectory) return "unknown";
6871
+ const cached = cachedRoleByPackageDirectory.get(packageDirectory);
6872
+ if (cached !== void 0) return cached;
6873
+ const manifest = readManifest(path.join(packageDirectory, "package.json"));
6874
+ let result;
6875
+ if (manifest && hasPublishContract(manifest)) result = "library";
6876
+ else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
6877
+ cachedRoleByPackageDirectory.set(packageDirectory, result);
6878
+ return result;
6879
+ };
6790
6880
  /**
6791
6881
  * Resolves the absolute path to read for a diagnostic's `filePath`,
6792
6882
  * accounting for the various shapes oxlint emits:
@@ -6949,6 +7039,15 @@ const buildDiagnosticPipeline = (input) => {
6949
7039
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
6950
7040
  const fileLinesCache = /* @__PURE__ */ new Map();
6951
7041
  const testFileCache = /* @__PURE__ */ new Map();
7042
+ const libraryFileCache = /* @__PURE__ */ new Map();
7043
+ const isLibraryFile = (filePath) => {
7044
+ let cached = libraryFileCache.get(filePath);
7045
+ if (cached === void 0) {
7046
+ cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
7047
+ libraryFileCache.set(filePath, cached);
7048
+ }
7049
+ return cached;
7050
+ };
6952
7051
  const getFileLines = (filePath) => {
6953
7052
  const cached = fileLinesCache.get(filePath);
6954
7053
  if (cached !== void 0) return cached;
@@ -6975,6 +7074,10 @@ const buildDiagnosticPipeline = (input) => {
6975
7074
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
6976
7075
  return false;
6977
7076
  };
7077
+ const isAppOnlyRule = (ruleIdentifier) => {
7078
+ for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
7079
+ return false;
7080
+ };
6978
7081
  const isRnRawTextSuppressedByConfig = (diagnostic) => {
6979
7082
  if (diagnostic.rule !== "rn-no-raw-text") return false;
6980
7083
  if (diagnostic.line <= 0) return false;
@@ -6989,8 +7092,10 @@ const buildDiagnosticPipeline = (input) => {
6989
7092
  if (shouldAutoSuppress(diagnostic)) return null;
6990
7093
  let current = diagnostic;
6991
7094
  let explicitSeverityOverride;
7095
+ let explicitRuleOverride;
6992
7096
  if (severityControls) {
6993
7097
  const { ruleKey, category } = getDiagnosticRuleIdentity(current);
7098
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
6994
7099
  explicitSeverityOverride = resolveRuleSeverityOverride({
6995
7100
  ruleKey,
6996
7101
  category
@@ -6998,6 +7103,9 @@ const buildDiagnosticPipeline = (input) => {
6998
7103
  if (explicitSeverityOverride === "off") return null;
6999
7104
  if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
7000
7105
  }
7106
+ if (explicitRuleOverride === void 0) {
7107
+ if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
7108
+ }
7001
7109
  if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
7002
7110
  if (userConfig) {
7003
7111
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
@@ -7183,6 +7291,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
7183
7291
  }).pipe(Layer.provide(FetchHttpClient.layer));
7184
7292
  }).pipe(Effect.orDie));
7185
7293
  /**
7294
+ * Resolves a requested lint worker count to a clamped integer within
7295
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
7296
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
7297
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
7298
+ */
7299
+ const resolveScanConcurrency = (requested) => {
7300
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
7301
+ if (!Number.isFinite(desired) || desired < 1) return 1;
7302
+ return Math.max(1, Math.min(Math.floor(desired), 16));
7303
+ };
7304
+ /**
7186
7305
  * Per-batch oxlint wall-clock budget. Reads from the env var on
7187
7306
  * startup so the eval harness can raise the budget under sandbox
7188
7307
  * microVMs without recompiling react-doctor. Tests override via
@@ -7202,6 +7321,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
7202
7321
  * tests that exercise the cap behavior.
7203
7322
  */
7204
7323
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
7324
+ /**
7325
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
7326
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
7327
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
7328
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
7329
+ * CI callers that never touch the flag:
7330
+ *
7331
+ * - unset / `0` / `false` / `off` → `1` (serial)
7332
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
7333
+ * - a positive integer → that many workers (clamped)
7334
+ *
7335
+ * The resolved value is always within
7336
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
7337
+ */
7338
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
7339
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
7340
+ if (raw === void 0) return 1;
7341
+ const normalized = raw.trim().toLowerCase();
7342
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
7343
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
7344
+ const parsed = Number.parseInt(normalized, 10);
7345
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
7346
+ return resolveScanConcurrency(parsed);
7347
+ } }) {};
7205
7348
  const DIAGNOSTIC_SURFACES = [
7206
7349
  "cli",
7207
7350
  "prComment",
@@ -7362,16 +7505,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
7362
7505
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
7363
7506
  const loadConfigFromDirectory = (directory) => {
7364
7507
  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)}`);
7508
+ let sawBrokenConfigFile = false;
7509
+ if (isFile(configFilePath)) {
7510
+ try {
7511
+ const fileContent = fs.readFileSync(configFilePath, "utf-8");
7512
+ const parsed = JSON.parse(fileContent);
7513
+ if (isPlainObject(parsed)) return {
7514
+ status: "found",
7515
+ loaded: {
7516
+ config: validateConfigTypes(parsed),
7517
+ sourceDirectory: directory
7518
+ }
7519
+ };
7520
+ warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
7521
+ } catch (error) {
7522
+ warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
7523
+ }
7524
+ sawBrokenConfigFile = true;
7375
7525
  }
7376
7526
  const packageJsonPath = path.join(directory, "package.json");
7377
7527
  if (isFile(packageJsonPath)) try {
@@ -7380,34 +7530,38 @@ const loadConfigFromDirectory = (directory) => {
7380
7530
  if (isPlainObject(packageJson)) {
7381
7531
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
7382
7532
  if (isPlainObject(embeddedConfig)) return {
7383
- config: validateConfigTypes(embeddedConfig),
7384
- sourceDirectory: directory
7533
+ status: "found",
7534
+ loaded: {
7535
+ config: validateConfigTypes(embeddedConfig),
7536
+ sourceDirectory: directory
7537
+ }
7385
7538
  };
7386
7539
  }
7387
- } catch {
7388
- return null;
7389
- }
7390
- return null;
7540
+ } catch {}
7541
+ return {
7542
+ status: sawBrokenConfigFile ? "invalid" : "absent",
7543
+ loaded: null
7544
+ };
7391
7545
  };
7392
7546
  const cachedConfigs = /* @__PURE__ */ new Map();
7393
7547
  const loadConfigWithSource = (rootDirectory) => {
7394
7548
  const cached = cachedConfigs.get(rootDirectory);
7395
7549
  if (cached !== void 0) return cached;
7396
- const localConfig = loadConfigFromDirectory(rootDirectory);
7397
- if (localConfig) {
7398
- cachedConfigs.set(rootDirectory, localConfig);
7399
- return localConfig;
7550
+ const localResult = loadConfigFromDirectory(rootDirectory);
7551
+ if (localResult.status === "found") {
7552
+ cachedConfigs.set(rootDirectory, localResult.loaded);
7553
+ return localResult.loaded;
7400
7554
  }
7401
- if (isProjectBoundary(rootDirectory)) {
7555
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
7402
7556
  cachedConfigs.set(rootDirectory, null);
7403
7557
  return null;
7404
7558
  }
7405
7559
  let ancestorDirectory = path.dirname(rootDirectory);
7406
7560
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
7407
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
7408
- if (ancestorConfig) {
7409
- cachedConfigs.set(rootDirectory, ancestorConfig);
7410
- return ancestorConfig;
7561
+ const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
7562
+ if (ancestorResult.status === "found") {
7563
+ cachedConfigs.set(rootDirectory, ancestorResult.loaded);
7564
+ return ancestorResult.loaded;
7411
7565
  }
7412
7566
  if (isProjectBoundary(ancestorDirectory)) {
7413
7567
  cachedConfigs.set(rootDirectory, null);
@@ -7432,11 +7586,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
7432
7586
  }
7433
7587
  return resolvedRootDir;
7434
7588
  };
7435
- const resolveDiagnoseTarget = (directory) => {
7589
+ const resolveDiagnoseTarget = (directory, options = {}) => {
7436
7590
  if (isFile(path.join(directory, "package.json"))) return directory;
7437
7591
  const reactSubprojects = discoverReactSubprojects(directory);
7438
7592
  if (reactSubprojects.length === 0) return null;
7439
7593
  if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
7594
+ if (options.allowAmbiguous === true) return null;
7440
7595
  throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
7441
7596
  };
7442
7597
  /**
@@ -7450,7 +7605,8 @@ const resolveDiagnoseTarget = (directory) => {
7450
7605
  * project root, if configured.
7451
7606
  * 4. Walk into a nested React subproject when the requested
7452
7607
  * directory has no `package.json` of its own (raises
7453
- * `AmbiguousProjectError` when multiple candidates exist).
7608
+ * `AmbiguousProjectError` when multiple candidates exist unless
7609
+ * the caller opts into keeping the wrapper directory).
7454
7610
  *
7455
7611
  * Throws `ProjectNotFoundError` when neither the requested directory
7456
7612
  * nor any discoverable nested project has a `package.json`.
@@ -7462,14 +7618,14 @@ const resolveDiagnoseTarget = (directory) => {
7462
7618
  * via its own cache). Routing through `resolveScanTarget` keeps every
7463
7619
  * shell in agreement on what "the scan directory" means.
7464
7620
  */
7465
- const resolveScanTarget = (requestedDirectory) => {
7621
+ const resolveScanTarget = (requestedDirectory, options = {}) => {
7466
7622
  const absoluteRequested = path.resolve(requestedDirectory);
7467
7623
  const loadedConfig = loadConfigWithSource(absoluteRequested);
7468
7624
  const userConfig = loadedConfig?.config ?? null;
7469
7625
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
7470
7626
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
7471
7627
  const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
7472
- const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
7628
+ const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
7473
7629
  if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
7474
7630
  return {
7475
7631
  resolvedDirectory,
@@ -7479,6 +7635,359 @@ const resolveScanTarget = (requestedDirectory) => {
7479
7635
  didRedirectViaRootDir: redirectedDirectory !== null
7480
7636
  };
7481
7637
  };
7638
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
7639
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
7640
+ const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
7641
+ return {
7642
+ rootDirectory,
7643
+ packageJson,
7644
+ directDependencyNames: getDirectDependencyNames(packageJson),
7645
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
7646
+ };
7647
+ };
7648
+ const buildExpoDiagnostic = (input) => ({
7649
+ filePath: input.filePath ?? "package.json",
7650
+ plugin: "react-doctor",
7651
+ rule: input.rule,
7652
+ severity: input.severity ?? "warning",
7653
+ message: input.message,
7654
+ help: input.help,
7655
+ line: input.line ?? 0,
7656
+ column: input.column ?? 0,
7657
+ category: input.category ?? "Correctness"
7658
+ });
7659
+ const CRITICAL_OVERRIDE_NAMES = new Set([
7660
+ "@expo/cli",
7661
+ "@expo/config",
7662
+ "@expo/metro-config",
7663
+ "@expo/metro-runtime",
7664
+ "@expo/metro",
7665
+ "metro"
7666
+ ]);
7667
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
7668
+ const collectOverrideNames = (packageJson) => new Set([
7669
+ ...Object.keys(packageJson.overrides ?? {}),
7670
+ ...Object.keys(packageJson.resolutions ?? {}),
7671
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
7672
+ ]);
7673
+ const checkExpoDependencyOverrides = (context) => {
7674
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
7675
+ if (overriddenCriticalNames.length === 0) return [];
7676
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
7677
+ return [buildExpoDiagnostic({
7678
+ rule: "expo-no-conflicting-dependency-override",
7679
+ 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`,
7680
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
7681
+ })];
7682
+ };
7683
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
7684
+ const result = spawnSync("git", [
7685
+ "check-ignore",
7686
+ "-q",
7687
+ absolutePath
7688
+ ], {
7689
+ cwd: rootDirectory,
7690
+ stdio: [
7691
+ "ignore",
7692
+ "ignore",
7693
+ "ignore"
7694
+ ]
7695
+ });
7696
+ if (result.error) return null;
7697
+ if (result.status === 0) return true;
7698
+ if (result.status === 1) return false;
7699
+ return null;
7700
+ };
7701
+ const LOCAL_ENV_FILE_NAMES = [
7702
+ ".env.local",
7703
+ ".env.development.local",
7704
+ ".env.production.local",
7705
+ ".env.test.local"
7706
+ ];
7707
+ const checkExpoEnvLocalFiles = (context) => {
7708
+ const { rootDirectory } = context;
7709
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
7710
+ const filePath = path.join(rootDirectory, fileName);
7711
+ if (!isFile(filePath)) return false;
7712
+ return isPathGitIgnored(rootDirectory, filePath) === false;
7713
+ });
7714
+ if (committedEnvFiles.length === 0) return [];
7715
+ return [buildExpoDiagnostic({
7716
+ rule: "expo-env-local-not-gitignored",
7717
+ category: "Security",
7718
+ 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`,
7719
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
7720
+ })];
7721
+ };
7722
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
7723
+ 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";
7724
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
7725
+ const unimodulesEntry = (packageName) => ({
7726
+ packageName,
7727
+ rule: "expo-no-unimodules-packages",
7728
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
7729
+ help: UNIMODULES_HELP
7730
+ });
7731
+ const FLAGGED_DEPENDENCIES = [
7732
+ unimodulesEntry("@unimodules/core"),
7733
+ unimodulesEntry("@unimodules/react-native-adapter"),
7734
+ unimodulesEntry("react-native-unimodules"),
7735
+ {
7736
+ packageName: "expo-cli",
7737
+ rule: "expo-no-cli-dependencies",
7738
+ 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`",
7739
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
7740
+ },
7741
+ {
7742
+ packageName: "eas-cli",
7743
+ rule: "expo-no-cli-dependencies",
7744
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
7745
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
7746
+ },
7747
+ {
7748
+ packageName: "expo-modules-autolinking",
7749
+ rule: "expo-no-redundant-dependency",
7750
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
7751
+ help: "Remove `expo-modules-autolinking` from your package.json"
7752
+ },
7753
+ {
7754
+ packageName: "expo-dev-launcher",
7755
+ rule: "expo-no-redundant-dependency",
7756
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
7757
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
7758
+ },
7759
+ {
7760
+ packageName: "expo-dev-menu",
7761
+ rule: "expo-no-redundant-dependency",
7762
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
7763
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
7764
+ },
7765
+ {
7766
+ packageName: "expo-modules-core",
7767
+ rule: "expo-no-redundant-dependency",
7768
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
7769
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
7770
+ },
7771
+ {
7772
+ packageName: "@expo/metro-config",
7773
+ rule: "expo-no-redundant-dependency",
7774
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
7775
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
7776
+ },
7777
+ {
7778
+ packageName: "@types/react-native",
7779
+ rule: "expo-no-redundant-dependency",
7780
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
7781
+ help: "Remove `@types/react-native` from your package.json",
7782
+ minSdkMajor: 48
7783
+ },
7784
+ {
7785
+ packageName: "@expo/config-plugins",
7786
+ rule: "expo-no-redundant-dependency",
7787
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
7788
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
7789
+ minSdkMajor: 48
7790
+ },
7791
+ {
7792
+ packageName: "@expo/prebuild-config",
7793
+ rule: "expo-no-redundant-dependency",
7794
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
7795
+ help: "Remove `@expo/prebuild-config` from your package.json",
7796
+ minSdkMajor: 53
7797
+ },
7798
+ {
7799
+ packageName: "expo-permissions",
7800
+ rule: "expo-no-redundant-dependency",
7801
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
7802
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
7803
+ minSdkMajor: 50
7804
+ },
7805
+ {
7806
+ packageName: "expo-app-loading",
7807
+ rule: "expo-no-redundant-dependency",
7808
+ message: "\"expo-app-loading\" was removed in SDK 49",
7809
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
7810
+ minSdkMajor: 49
7811
+ },
7812
+ {
7813
+ packageName: "expo-firebase-analytics",
7814
+ rule: "expo-no-redundant-dependency",
7815
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
7816
+ help: FIREBASE_HELP,
7817
+ minSdkMajor: 48
7818
+ },
7819
+ {
7820
+ packageName: "expo-firebase-recaptcha",
7821
+ rule: "expo-no-redundant-dependency",
7822
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
7823
+ help: FIREBASE_HELP,
7824
+ minSdkMajor: 48
7825
+ },
7826
+ {
7827
+ packageName: "expo-firebase-core",
7828
+ rule: "expo-no-redundant-dependency",
7829
+ message: "\"expo-firebase-core\" was removed in SDK 48",
7830
+ help: FIREBASE_HELP,
7831
+ minSdkMajor: 48
7832
+ }
7833
+ ];
7834
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
7835
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
7836
+ if (flaggedDependency.minSdkMajor === void 0) return true;
7837
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
7838
+ }).map((flaggedDependency) => buildExpoDiagnostic({
7839
+ rule: flaggedDependency.rule,
7840
+ message: flaggedDependency.message,
7841
+ help: flaggedDependency.help
7842
+ }));
7843
+ const findLocalModuleNativeFiles = (rootDirectory) => {
7844
+ const modulesDirectory = path.join(rootDirectory, "modules");
7845
+ if (!isDirectory(modulesDirectory)) return [];
7846
+ const nativeFilePaths = [];
7847
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
7848
+ if (!moduleEntry.isDirectory()) continue;
7849
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
7850
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
7851
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
7852
+ const iosDirectory = path.join(moduleDirectory, "ios");
7853
+ if (isDirectory(iosDirectory)) {
7854
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
7855
+ }
7856
+ }
7857
+ return nativeFilePaths;
7858
+ };
7859
+ const checkExpoGitignore = (context) => {
7860
+ const { rootDirectory } = context;
7861
+ const diagnostics = [];
7862
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
7863
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
7864
+ rule: "expo-gitignore",
7865
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
7866
+ help: "Add `.expo/` to your .gitignore"
7867
+ }));
7868
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
7869
+ rule: "expo-gitignore",
7870
+ 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",
7871
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
7872
+ }));
7873
+ return diagnostics;
7874
+ };
7875
+ const LOCKFILE_NAMES = [
7876
+ "pnpm-lock.yaml",
7877
+ "yarn.lock",
7878
+ "package-lock.json",
7879
+ "bun.lockb",
7880
+ "bun.lock"
7881
+ ];
7882
+ const checkExpoLockfile = (context) => {
7883
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
7884
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
7885
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
7886
+ rule: "expo-lockfile",
7887
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
7888
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
7889
+ })];
7890
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
7891
+ rule: "expo-lockfile",
7892
+ 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`,
7893
+ help: "Delete the lock files for the package managers you are not using and keep only one"
7894
+ })];
7895
+ return [];
7896
+ };
7897
+ const METRO_CONFIG_FILE_NAMES = [
7898
+ "metro.config.js",
7899
+ "metro.config.cjs",
7900
+ "metro.config.mjs",
7901
+ "metro.config.ts"
7902
+ ];
7903
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
7904
+ "expo/metro-config",
7905
+ "@sentry/react-native/metro",
7906
+ "getSentryExpoConfig"
7907
+ ];
7908
+ const checkExpoMetroConfig = (context) => {
7909
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
7910
+ if (metroConfigPath === void 0) return [];
7911
+ let contents;
7912
+ try {
7913
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
7914
+ } catch {
7915
+ return [];
7916
+ }
7917
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
7918
+ return [buildExpoDiagnostic({
7919
+ rule: "expo-metro-config",
7920
+ filePath: path.basename(metroConfigPath),
7921
+ 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",
7922
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
7923
+ })];
7924
+ };
7925
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
7926
+ const checkExpoPackageJsonConflicts = (context) => {
7927
+ const { packageJson } = context;
7928
+ const diagnostics = [];
7929
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
7930
+ if (conflictingScriptNames.length > 0) {
7931
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
7932
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
7933
+ diagnostics.push(buildExpoDiagnostic({
7934
+ rule: "expo-package-json-conflict",
7935
+ 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" : ""}`,
7936
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
7937
+ }));
7938
+ }
7939
+ const packageName = packageJson.name;
7940
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
7941
+ rule: "expo-package-json-conflict",
7942
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
7943
+ help: "Rename your package so it no longer matches one of its dependencies"
7944
+ }));
7945
+ return diagnostics;
7946
+ };
7947
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
7948
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
7949
+ const checkExpoRouterReactNavigation = (context) => {
7950
+ const { expoSdkMajor } = context;
7951
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
7952
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
7953
+ if (!context.directDependencyNames.has("expo-router")) return [];
7954
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
7955
+ if (reactNavigationNames.length === 0) return [];
7956
+ return [buildExpoDiagnostic({
7957
+ rule: "expo-router-no-react-navigation",
7958
+ 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"}`,
7959
+ 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/"
7960
+ })];
7961
+ };
7962
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
7963
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
7964
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
7965
+ const checkExpoVectorIcons = (context) => {
7966
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
7967
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
7968
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
7969
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
7970
+ return [buildExpoDiagnostic({
7971
+ rule: "expo-vector-icons-conflict",
7972
+ 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",
7973
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
7974
+ })];
7975
+ };
7976
+ const checkExpoProject = (rootDirectory, project) => {
7977
+ if (project.expoVersion === null) return [];
7978
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
7979
+ return [
7980
+ ...checkExpoFlaggedDependencies(context),
7981
+ ...checkExpoDependencyOverrides(context),
7982
+ ...checkExpoRouterReactNavigation(context),
7983
+ ...checkExpoVectorIcons(context),
7984
+ ...checkExpoPackageJsonConflicts(context),
7985
+ ...checkExpoLockfile(context),
7986
+ ...checkExpoGitignore(context),
7987
+ ...checkExpoEnvLocalFiles(context),
7988
+ ...checkExpoMetroConfig(context)
7989
+ ];
7990
+ };
7482
7991
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
7483
7992
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
7484
7993
  const PACKAGE_JSON_FILE = "package.json";
@@ -8756,6 +9265,7 @@ const buildCapabilities = (project) => {
8756
9265
  const capabilities = /* @__PURE__ */ new Set();
8757
9266
  capabilities.add(project.framework);
8758
9267
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
9268
+ if (project.expoVersion !== null) capabilities.add("expo");
8759
9269
  const reactMajor = project.reactMajorVersion;
8760
9270
  if (reactMajor !== null) {
8761
9271
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -8927,10 +9437,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
8927
9437
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
8928
9438
  return fs.realpathSync(rootDirectory);
8929
9439
  };
9440
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
9441
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
9442
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
9443
+ };
8930
9444
  const applyRuleSeverityControls = (rules, severityControls) => {
8931
9445
  const enabledRules = {};
8932
9446
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
8933
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
9447
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
8934
9448
  if (severity === "off") continue;
8935
9449
  enabledRules[ruleKey] = severity;
8936
9450
  }
@@ -8972,7 +9486,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
8972
9486
  category: rule.category
8973
9487
  }, severityControls);
8974
9488
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
8975
- const severity = explicitSeverity ?? rule.severity;
9489
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
8976
9490
  if (severity === "off") continue;
8977
9491
  enabledReactDoctorRules[registryEntry.key] = severity;
8978
9492
  }
@@ -9029,6 +9543,44 @@ const dedupeDiagnostics = (diagnostics) => {
9029
9543
  }
9030
9544
  return uniqueDiagnostics;
9031
9545
  };
9546
+ /**
9547
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
9548
+ * once, returning results in input order. A pool of workers each pulls the
9549
+ * next not-yet-started index until the list drains — so a worker that
9550
+ * finishes a fast task immediately picks up the next one (greedy load
9551
+ * balancing), which matters when tasks have uneven durations (oxlint
9552
+ * batches do).
9553
+ *
9554
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
9555
+ * no further tasks are started, the already-in-flight tasks are awaited to
9556
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
9557
+ * rejects with that first error. This keeps the caller's fail-fast retry
9558
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
9559
+ * top of a still-running first one.
9560
+ */
9561
+ const mapWithConcurrency = async (items, concurrency, task) => {
9562
+ const results = new Array(items.length);
9563
+ if (items.length === 0) return results;
9564
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
9565
+ let nextIndex = 0;
9566
+ const errors = [];
9567
+ const runWorker = async () => {
9568
+ while (errors.length === 0) {
9569
+ const index = nextIndex;
9570
+ nextIndex += 1;
9571
+ if (index >= items.length) return;
9572
+ try {
9573
+ results[index] = await task(items[index], index);
9574
+ } catch (error) {
9575
+ errors.push(error);
9576
+ return;
9577
+ }
9578
+ }
9579
+ };
9580
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
9581
+ if (errors.length > 0) throw errors[0];
9582
+ return results;
9583
+ };
9032
9584
  const getPublicEnvPrefix = (framework) => {
9033
9585
  switch (framework) {
9034
9586
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -9711,6 +10263,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
9711
10263
  */
9712
10264
  const spawnLintBatches = async (input) => {
9713
10265
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
10266
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
9714
10267
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
9715
10268
  const allDiagnostics = [];
9716
10269
  const droppedFiles = [];
@@ -9730,23 +10283,31 @@ const spawnLintBatches = async (input) => {
9730
10283
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
9731
10284
  }
9732
10285
  };
10286
+ let startedFileCount = 0;
9733
10287
  let scannedFileCount = 0;
9734
- for (const batch of fileBatches) {
9735
- let batchFileIndex = 0;
9736
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
9737
- if (batchFileIndex < batch.length) {
9738
- batchFileIndex += 1;
9739
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
9740
- }
9741
- }, 50) : null;
9742
- try {
10288
+ let displayedFileCount = 0;
10289
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
10290
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
10291
+ if (displayedFileCount < ceiling) {
10292
+ displayedFileCount += 1;
10293
+ onFileProgress(displayedFileCount, totalFileCount);
10294
+ }
10295
+ }, 50) : null;
10296
+ progressTimer?.unref?.();
10297
+ try {
10298
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
10299
+ startedFileCount += batch.length;
9743
10300
  const batchDiagnostics = await spawnLintBatch(batch);
9744
- allDiagnostics.push(...batchDiagnostics);
9745
10301
  scannedFileCount += batch.length;
9746
- onFileProgress?.(scannedFileCount, totalFileCount);
9747
- } finally {
9748
- if (progressInterval !== null) clearInterval(progressInterval);
9749
- }
10302
+ if (onFileProgress) {
10303
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
10304
+ onFileProgress(displayedFileCount, totalFileCount);
10305
+ }
10306
+ return batchDiagnostics;
10307
+ });
10308
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
10309
+ } finally {
10310
+ if (progressTimer !== null) clearInterval(progressTimer);
9750
10311
  }
9751
10312
  if (droppedFiles.length > 0 && onPartialFailure) {
9752
10313
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -9873,7 +10434,8 @@ const runOxlint = async (options) => {
9873
10434
  onPartialFailure,
9874
10435
  onFileProgress: options.onFileProgress,
9875
10436
  spawnTimeoutMs,
9876
- outputMaxBytes
10437
+ outputMaxBytes,
10438
+ concurrency: options.concurrency
9877
10439
  });
9878
10440
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
9879
10441
  try {
@@ -9941,6 +10503,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
9941
10503
  const partialFailures = yield* LintPartialFailures;
9942
10504
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
9943
10505
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
10506
+ const concurrency = yield* OxlintConcurrency;
9944
10507
  const collectedFailures = [];
9945
10508
  const diagnostics = yield* Effect.tryPromise({
9946
10509
  try: () => runOxlint({
@@ -9959,7 +10522,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
9959
10522
  },
9960
10523
  onFileProgress: input.onFileProgress,
9961
10524
  spawnTimeoutMs,
9962
- outputMaxBytes
10525
+ outputMaxBytes,
10526
+ concurrency
9963
10527
  }),
9964
10528
  catch: ensureReactDoctorError
9965
10529
  });
@@ -10283,7 +10847,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10283
10847
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
10284
10848
  yield* beforeLint(project, lintIncludePaths ?? void 0);
10285
10849
  const isDiffMode = input.includePaths.length > 0;
10286
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
10850
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
10287
10851
  const transform = buildDiagnosticPipeline({
10288
10852
  rootDirectory: scanDirectory,
10289
10853
  userConfig: resolvedConfig.config,
@@ -10292,7 +10856,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10292
10856
  showWarnings
10293
10857
  });
10294
10858
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
10295
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
10859
+ const environmentDiagnostics = isDiffMode ? [] : [
10860
+ ...checkReducedMotion(scanDirectory),
10861
+ ...checkPnpmHardening(scanDirectory),
10862
+ ...checkExpoProject(scanDirectory, project)
10863
+ ];
10296
10864
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
10297
10865
  const lintFailure = yield* Ref.make({
10298
10866
  didFail: false,
@@ -10304,6 +10872,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10304
10872
  didFail: false,
10305
10873
  reason: null
10306
10874
  });
10875
+ const scanConcurrency = yield* OxlintConcurrency;
10876
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
10307
10877
  const scanProgress = yield* progressService.start("Scanning...");
10308
10878
  const scanStartTime = Date.now();
10309
10879
  let lastReportedTotalFileCount = 0;
@@ -10320,7 +10890,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10320
10890
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
10321
10891
  onFileProgress: (scannedFileCount, totalFileCount) => {
10322
10892
  lastReportedTotalFileCount = totalFileCount;
10323
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
10893
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
10324
10894
  }
10325
10895
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
10326
10896
  yield* Ref.set(lintFailure, {
@@ -10352,7 +10922,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10352
10922
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
10353
10923
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
10354
10924
  else if (input.suppressScanSummary) yield* scanProgress.stop();
10355
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
10925
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
10356
10926
  yield* reporterService.finalize;
10357
10927
  const finalDiagnostics = [
10358
10928
  ...envCollected,
@@ -10404,7 +10974,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10404
10974
  "inspect.isCi": input.isCi,
10405
10975
  "inspect.scoreSurface": input.scoreSurface ?? "score"
10406
10976
  } }));
10407
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
10408
10977
  const parseNodeVersion = (versionString) => {
10409
10978
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
10410
10979
  return {
@@ -10727,6 +11296,26 @@ const buildJsonReport = (input) => {
10727
11296
  };
10728
11297
  };
10729
11298
  /**
11299
+ * Single source of truth for the skipped-check accounting shared by the
11300
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
11301
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
11302
+ * failed lint / dead-code pass instead of a false "all clear", so the
11303
+ * branch logic lives here once.
11304
+ */
11305
+ const buildSkippedChecks = (input) => {
11306
+ const skippedChecks = [];
11307
+ if (input.didLintFail) skippedChecks.push("lint");
11308
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
11309
+ const skippedCheckReasons = {};
11310
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
11311
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
11312
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
11313
+ return {
11314
+ skippedChecks,
11315
+ skippedCheckReasons
11316
+ };
11317
+ };
11318
+ /**
10730
11319
  * Programmatic façade over `Git.diffSelection`. Async because the
10731
11320
  * Git service runs through Effect's `ChildProcess` (true subprocess
10732
11321
  * spawn, not `spawnSync`).
@@ -10831,12 +11420,32 @@ const highlighter = {
10831
11420
  bold: import_picocolors.default.bold
10832
11421
  };
10833
11422
  /**
10834
- * Canonical URL for a rule's reviewer-tested fix recipe, served at
10835
- * `https://www.react.doctor/prompts/rules/<plugin>/<rule>.md`. The
10836
- * `/doctor` playbook fetches it on demand so each fix follows the
10837
- * canonical recipe instead of being improvised per diagnostic.
11423
+ * Override picocolors' automatic color detection. picocolors decides
11424
+ * once, at import time, from `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY.
11425
+ * This lets the CLI honor an explicit `--color` / `--no-color` flag
11426
+ * (clig.dev, Output: "Disable color if the user requested it") by
11427
+ * swapping in a fresh set of formatters. Call it before any colored
11428
+ * output is produced. Every call site reads `highlighter.<method>` at
11429
+ * call time, so reassigning the properties propagates everywhere.
11430
+ */
11431
+ const setColorEnabled = (enabled) => {
11432
+ const colors = import_picocolors.default.createColors(enabled);
11433
+ highlighter.error = colors.red;
11434
+ highlighter.warn = colors.yellow;
11435
+ highlighter.info = colors.cyan;
11436
+ highlighter.success = colors.green;
11437
+ highlighter.dim = colors.dim;
11438
+ highlighter.gray = colors.gray;
11439
+ highlighter.bold = colors.bold;
11440
+ };
11441
+ /**
11442
+ * Canonical URL for a rule's documentation page — its reviewer-tested fix
11443
+ * recipe rendered for humans — served at
11444
+ * `https://www.react.doctor/docs/rules/<plugin>/<rule>`. The CLI links here
11445
+ * from its fix-recipe directive so each fix follows the canonical recipe
11446
+ * instead of being improvised per diagnostic.
10838
11447
  */
10839
- const buildRulePromptUrl = (plugin, rule) => `${PROMPTS_RULES_BASE_URL}/${plugin}/${rule}.md`;
11448
+ const buildRuleDocsUrl = (plugin, rule) => `${DOCS_RULES_BASE_URL}/${plugin}/${rule}`;
10840
11449
  const groupBy = (items, keyFn) => {
10841
11450
  const groups = /* @__PURE__ */ new Map();
10842
11451
  for (const item of items) {
@@ -10849,8 +11458,8 @@ const groupBy = (items, keyFn) => {
10849
11458
  };
10850
11459
  /**
10851
11460
  * Whether a diagnostic's rule has a published per-rule fix recipe at
10852
- * `${PROMPTS_RULES_BASE_URL}/react-doctor/<rule>.md`
10853
- * (see `buildRulePromptUrl`).
11461
+ * `${DOCS_RULES_BASE_URL}/react-doctor/<rule>`
11462
+ * (see `buildRuleDocsUrl`).
10854
11463
  *
10855
11464
  * Recipes are generated from react-doctor's own engine rules, so only
10856
11465
  * those resolve. Dead-code (`deslop`), the synthetic environment and
@@ -10862,6 +11471,46 @@ const groupBy = (items, keyFn) => {
10862
11471
  */
10863
11472
  const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
10864
11473
  //#endregion
11474
+ //#region src/cli/utils/constants.ts
11475
+ const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
11476
+ const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
11477
+ const SENTRY_DSN = "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920";
11478
+ //#endregion
11479
+ //#region src/cli/utils/version.ts
11480
+ const VERSION = "0.2.14-dev.0938376";
11481
+ //#endregion
11482
+ //#region src/instrument.ts
11483
+ let isInitialized = false;
11484
+ const shouldEnableSentry = () => {
11485
+ if (process.argv.includes("--no-score") || process.argv.includes("--no-telemetry")) return false;
11486
+ if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
11487
+ return true;
11488
+ };
11489
+ /**
11490
+ * Initializes the Sentry Node SDK for CLI crash reporting. Invoked as
11491
+ * the first statement of the CLI entry (`cli/index.ts`) so the SDK's
11492
+ * global `uncaughtException` / `unhandledRejection` handlers are armed
11493
+ * before any command runs.
11494
+ *
11495
+ * Exported as a function rather than a bare side-effecting import
11496
+ * because the package declares `"sideEffects": false`, which lets the
11497
+ * bundler tree-shake side-effect-only modules. An explicit call keeps
11498
+ * the initialization in the published `dist/cli.js`.
11499
+ *
11500
+ * Scoped to the CLI application only — the programmatic
11501
+ * `@react-doctor/api` library never initializes Sentry, so importing
11502
+ * `diagnose()` into a consumer app can't hijack their telemetry.
11503
+ */
11504
+ const initializeSentry = () => {
11505
+ if (isInitialized || !shouldEnableSentry()) return;
11506
+ isInitialized = true;
11507
+ Sentry.init({
11508
+ dsn: SENTRY_DSN,
11509
+ sendDefaultPii: true,
11510
+ release: VERSION
11511
+ });
11512
+ };
11513
+ //#endregion
10865
11514
  //#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
10866
11515
  const ANSI_BACKGROUND_OFFSET = 10;
10867
11516
  const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
@@ -13815,23 +14464,60 @@ const CI_ENVIRONMENT_VARIABLES = [
13815
14464
  "GITLAB_CI",
13816
14465
  "CIRCLECI"
13817
14466
  ];
13818
- const CODING_AGENT_ENVIRONMENT_VARIABLES = [
13819
- "CLAUDECODE",
13820
- "CLAUDE_CODE",
13821
- "CURSOR_AGENT",
13822
- "CODEX_CI",
13823
- "CODEX_SANDBOX",
13824
- "CODEX_SANDBOX_NETWORK_DISABLED",
13825
- "OPENCODE",
13826
- "GOOSE_TERMINAL",
13827
- "AGENT_SESSION_ID",
13828
- "AMP_THREAD_ID",
13829
- "AGENT_THREAD_ID"
14467
+ const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
14468
+ ["GITHUB_ACTIONS", "github-actions"],
14469
+ ["GITLAB_CI", "gitlab-ci"],
14470
+ ["CIRCLECI", "circleci"],
14471
+ ["BUILDKITE", "buildkite"],
14472
+ ["JENKINS_URL", "jenkins"],
14473
+ ["TF_BUILD", "azure-pipelines"],
14474
+ ["CODEBUILD_BUILD_ID", "aws-codebuild"],
14475
+ ["TEAMCITY_VERSION", "teamcity"],
14476
+ ["BITBUCKET_BUILD_NUMBER", "bitbucket"],
14477
+ ["TRAVIS", "travis"],
14478
+ ["DRONE", "drone"]
14479
+ ];
14480
+ const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
14481
+ ["CLAUDECODE", "claude-code"],
14482
+ ["CLAUDE_CODE", "claude-code"],
14483
+ ["CURSOR_AGENT", "cursor"],
14484
+ ["CODEX_CI", "codex"],
14485
+ ["CODEX_SANDBOX", "codex"],
14486
+ ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
14487
+ ["OPENCODE", "opencode"],
14488
+ ["GOOSE_TERMINAL", "goose"],
14489
+ ["AMP_THREAD_ID", "amp"]
13830
14490
  ];
14491
+ const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
13831
14492
  const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
13832
14493
  const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
13833
- const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
13834
- 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));
14494
+ [...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
14495
+ const FALSY_CI_FLAG_VALUES = new Set([
14496
+ "",
14497
+ "0",
14498
+ "false"
14499
+ ]);
14500
+ const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
14501
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
14502
+ const detectCiProvider = () => {
14503
+ for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
14504
+ return isCiFlagSet(process.env.CI) ? "unknown" : null;
14505
+ };
14506
+ const detectCodingAgentFromValue = () => {
14507
+ for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
14508
+ const value = process.env[environmentVariable]?.toLowerCase();
14509
+ if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
14510
+ }
14511
+ return null;
14512
+ };
14513
+ const detectCodingAgent = () => {
14514
+ for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
14515
+ const agentFromValue = detectCodingAgentFromValue();
14516
+ if (agentFromValue) return agentFromValue;
14517
+ if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
14518
+ return null;
14519
+ };
14520
+ const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
13835
14521
  const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
13836
14522
  //#endregion
13837
14523
  //#region src/cli/utils/is-non-interactive-environment.ts
@@ -13933,9 +14619,8 @@ const buildSpinnerProgressHandle = (text) => {
13933
14619
  * construction and post-scan rendering — layer wiring is its own
13934
14620
  * concern with its own contract.
13935
14621
  *
13936
- * Same shape as `core/src/run-inspect.tslayerInspectLive`
13937
- * (the default for `@react-doctor/api diagnose()`) with the
13938
- * differences specific to the CLI path:
14622
+ * Same service shape as `@react-doctor/apidiagnose()`'s
14623
+ * `buildDiagnoseLayer`, with the differences specific to the CLI path:
13939
14624
  *
13940
14625
  * - **Config**: when the caller passes `configOverride`, the
13941
14626
  * already-loaded config is provided via `Config.layerOf` instead
@@ -13961,7 +14646,8 @@ const buildRuntimeLayers = (input) => {
13961
14646
  resolvedDirectory: input.directory,
13962
14647
  configSourceDirectory: input.configSourceDirectory
13963
14648
  }) : Config.layerNode;
13964
- return Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
14649
+ const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
14650
+ return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
13965
14651
  };
13966
14652
  //#endregion
13967
14653
  //#region src/cli/utils/noop-console.ts
@@ -14048,8 +14734,10 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
14048
14734
  return priorityB - priorityA;
14049
14735
  };
14050
14736
  const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
14051
- const FETCH_FIX_RECIPE_LABEL = "Fetch & follow the canonical fix recipe before fixing";
14052
- const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FETCH_FIX_RECIPE_LABEL}: ${buildRulePromptUrl(diagnostic.plugin, diagnostic.rule)}` : null;
14737
+ const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
14738
+ const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
14739
+ const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
14740
+ const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
14053
14741
  //#endregion
14054
14742
  //#region src/cli/utils/box-text.ts
14055
14743
  const ESCAPE = String.fromCharCode(27);
@@ -14180,15 +14868,17 @@ const buildVerboseSiteMap = (diagnostics) => {
14180
14868
  return fileSites;
14181
14869
  };
14182
14870
  const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
14871
+ const formatTrailingSiteBadge = (count) => {
14872
+ const badge = formatSiteCountBadge(count);
14873
+ return badge.length > 0 ? ` ${highlighter.gray(badge)}` : "";
14874
+ };
14183
14875
  const categoryTopRuleKey = (categoryGroup) => categoryGroup.ruleGroups[0][0];
14184
14876
  const buildCategoryDiagnosticGroups = (diagnostics, rulePriority) => {
14185
- return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
14186
- return {
14187
- category,
14188
- diagnostics: categoryDiagnostics,
14189
- ruleGroups: sortRuleGroupsByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority)
14190
- };
14191
- }).toSorted((categoryGroupA, categoryGroupB) => {
14877
+ return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => ({
14878
+ category,
14879
+ diagnostics: categoryDiagnostics,
14880
+ ruleGroups: buildSortedRuleGroups(categoryDiagnostics, rulePriority)
14881
+ })).toSorted((categoryGroupA, categoryGroupB) => {
14192
14882
  const priorityDelta = compareByRulePriority(categoryTopRuleKey(categoryGroupA), categoryTopRuleKey(categoryGroupB), rulePriority);
14193
14883
  if (priorityDelta !== 0) return priorityDelta;
14194
14884
  return categoryGroupA.category.localeCompare(categoryGroupB.category);
@@ -14204,6 +14894,7 @@ const buildCompactCategoryLine = (categoryGroup) => {
14204
14894
  };
14205
14895
  const TOP_ERROR_DETAIL_INDENT = " ";
14206
14896
  const pickRepresentativeDiagnostic = (ruleDiagnostics) => ruleDiagnostics.find((diagnostic) => diagnostic.line > 0) ?? ruleDiagnostics[0];
14897
+ const isErrorRuleGroup = (ruleDiagnostics) => pickRepresentativeDiagnostic(ruleDiagnostics).severity === "error";
14207
14898
  const FRAME_CONTEXT_REACH_LINES = 3;
14208
14899
  const clusterNearbyDiagnostics = (diagnostics) => {
14209
14900
  const byFile = groupBy(diagnostics, (diagnostic) => diagnostic.filePath);
@@ -14235,17 +14926,17 @@ const formatClusterLocation = (cluster) => {
14235
14926
  if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
14236
14927
  return `${filePath}:${cluster.startLine}`;
14237
14928
  };
14238
- const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
14929
+ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
14239
14930
  const lead = cluster.diagnostics[0];
14240
14931
  const isMultiSite = cluster.diagnostics.length > 1;
14241
14932
  const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
14242
- const codeFrame = buildCodeFrame({
14933
+ const codeFrame = renderCodeFrame ? buildCodeFrame({
14243
14934
  filePath: lead.filePath,
14244
14935
  line: cluster.startLine,
14245
14936
  column: isMultiSite ? 0 : lead.column,
14246
14937
  endLine: isMultiSite ? cluster.endLine : void 0,
14247
14938
  rootDirectory: resolveSourceRoot(lead)
14248
- });
14939
+ }) : null;
14249
14940
  if (codeFrame) lines.push(indentMultilineText(boxText(codeFrame, 60), TOP_ERROR_DETAIL_INDENT));
14250
14941
  const seenHints = /* @__PURE__ */ new Set();
14251
14942
  for (const diagnostic of cluster.diagnostics) if (diagnostic.suppressionHint && !seenHints.has(diagnostic.suppressionHint)) {
@@ -14257,23 +14948,60 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
14257
14948
  const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite) => {
14258
14949
  const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
14259
14950
  const { severity } = representative;
14260
- const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
14261
- const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
14951
+ const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
14262
14952
  const headline = colorizeBySeverity(`${representative.category}: ${representative.title ?? ruleKey}`, severity);
14263
14953
  const lines = [` ${colorizeBySeverity(severity === "error" ? "✗" : "⚠", severity)} ${headline}${trailingBadge}`];
14264
14954
  if (!renderEverySite) for (const explanationLine of wrapTextToWidth(representative.message, 60, { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
14265
14955
  if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, 60, { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
14956
+ const renderCodeFrame = severity === "error";
14266
14957
  const sites = renderEverySite ? ruleDiagnostics : [representative];
14267
- for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot));
14958
+ for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
14959
+ return lines;
14960
+ };
14961
+ const WARNING_DETAIL_INDENT = " ";
14962
+ const computeRuleNameColumnWidth = (ruleKeys) => ruleKeys.reduce((widest, ruleKey) => Math.max(widest, ruleKey.length), 36);
14963
+ const padRuleNameToColumn = (ruleName, columnWidth) => ruleName.length >= columnWidth ? ruleName : ruleName + " ".repeat(columnWidth - ruleName.length);
14964
+ const buildWarningHeaderLine = (ruleKey, siteCount, ruleNameColumnWidth) => {
14965
+ const ruleName = formatSiteCountBadge(siteCount).length > 0 ? padRuleNameToColumn(ruleKey, ruleNameColumnWidth) : ruleKey;
14966
+ return ` ${highlighter.warn("⚠")} ${ruleName}${formatTrailingSiteBadge(siteCount)}`;
14967
+ };
14968
+ const buildWarningRuleBlock = (ruleKey, ruleDiagnostics, ruleNameColumnWidth, isAgentEnvironment) => {
14969
+ const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
14970
+ const lines = [buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth)];
14971
+ if (!isAgentEnvironment) {
14972
+ const learnMoreLine = formatLearnMoreLine(representative);
14973
+ if (learnMoreLine) lines.push(`${WARNING_DETAIL_INDENT}${highlighter.info(learnMoreLine)}`);
14974
+ }
14975
+ lines.push(highlighter.gray(indentMultilineText(representative.message, WARNING_DETAIL_INDENT)));
14976
+ if (representative.help) lines.push(highlighter.gray(indentMultilineText(`→ ${representative.help}`, WARNING_DETAIL_INDENT)));
14977
+ if (isAgentEnvironment) {
14978
+ const fixRecipeLine = formatFixRecipeLine(representative);
14979
+ if (fixRecipeLine) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${fixRecipeLine}`));
14980
+ }
14981
+ for (const [filePath, sites] of buildVerboseSiteMap(ruleDiagnostics)) {
14982
+ if (sites.length === 0) {
14983
+ lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}`));
14984
+ continue;
14985
+ }
14986
+ for (const site of sites) {
14987
+ lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}:${site.line}`));
14988
+ if (site.suppressionHint) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT} ↳ ${site.suppressionHint}`));
14989
+ }
14990
+ }
14268
14991
  return lines;
14269
14992
  };
14270
- const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => {
14271
- return sortRuleGroupsByImportance([...groupBy(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority).slice(0, limit);
14993
+ const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
14994
+ const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => selectErrorRuleGroups(diagnostics, rulePriority).slice(0, limit);
14995
+ const buildMoreRulesLine = (hiddenRuleCount, severityNoun, accent) => {
14996
+ const ruleNoun = hiddenRuleCount === 1 ? "rule" : "rules";
14997
+ return ` ${highlighter.bold(accent(`+${hiddenRuleCount} more ${ruleNoun}`))} ${highlighter.dim("— run")} ${highlighter.bold(highlighter.info("--verbose"))} ${highlighter.dim(`to view the rest of the ${severityNoun} and details about each`)}`;
14272
14998
  };
14273
14999
  const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
14274
15000
  const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
14275
- const topRuleGroups = selectTopErrorRuleGroups(diagnostics, 3, rulePriority);
15001
+ const errorRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority);
15002
+ const topRuleGroups = errorRuleGroups.slice(0, 3);
14276
15003
  if (topRuleGroups.length === 0) return [];
15004
+ const hiddenRuleCount = errorRuleGroups.length - topRuleGroups.length;
14277
15005
  const lines = [
14278
15006
  highlighter.dim(` ${"─".repeat(60)}`),
14279
15007
  ` ${highlighter.bold(`Top ${topRuleGroups.length} ${topRuleGroups.length === 1 ? "error" : "errors"} you should fix`)}`,
@@ -14283,6 +15011,23 @@ const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
14283
15011
  lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false));
14284
15012
  lines.push("");
14285
15013
  }
15014
+ if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "errors", highlighter.error));
15015
+ return lines;
15016
+ };
15017
+ const buildWarningsListLines = (diagnostics, rulePriority) => {
15018
+ const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
15019
+ if (warningDiagnostics.length === 0) return [];
15020
+ const sortedRuleGroups = buildSortedRuleGroups(warningDiagnostics, rulePriority);
15021
+ const shownRuleGroups = sortedRuleGroups.slice(0, 10);
15022
+ const hiddenRuleCount = sortedRuleGroups.length - shownRuleGroups.length;
15023
+ const ruleNameColumnWidth = computeRuleNameColumnWidth(shownRuleGroups.map(([ruleKey]) => ruleKey));
15024
+ const lines = [
15025
+ highlighter.dim(` ${"─".repeat(60)}`),
15026
+ ` ${highlighter.bold(`${warningDiagnostics.length} ${warningDiagnostics.length === 1 ? "warning" : "warnings"}`)}`,
15027
+ ""
15028
+ ];
15029
+ for (const [ruleKey, ruleDiagnostics] of shownRuleGroups) lines.push(buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth));
15030
+ if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "warnings", highlighter.warn));
14286
15031
  return lines;
14287
15032
  };
14288
15033
  const buildCategoryBreakdownLines = (diagnostics, rulePriority) => buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCompactCategoryLine);
@@ -14309,12 +15054,18 @@ const buildCountsSummaryLines = (diagnostics) => {
14309
15054
  * single Effect.forEach over Console.log so failures or fiber
14310
15055
  * interruption produce predictable partial output.
14311
15056
  */
14312
- const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority) => Effect.gen(function* () {
15057
+ const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false) => Effect.gen(function* () {
14313
15058
  const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
14314
15059
  let detailLines;
14315
15060
  if (!isVerbose) detailLines = buildTopErrorsLines(diagnostics, resolveSourceRoot, rulePriority);
14316
- else detailLines = sortRuleGroupsByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority).flatMap(([ruleKey, ruleDiagnostics]) => [...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true), ""]);
14317
- const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines);
15061
+ else {
15062
+ const sortedRuleGroups = buildSortedRuleGroups(diagnostics, rulePriority);
15063
+ const warningRuleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.filter(([, ruleDiagnostics]) => !isErrorRuleGroup(ruleDiagnostics)).map(([ruleKey]) => ruleKey));
15064
+ detailLines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => {
15065
+ return [...isErrorRuleGroup(ruleDiagnostics) ? buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true) : buildWarningRuleBlock(ruleKey, ruleDiagnostics, warningRuleNameColumnWidth, isAgentEnvironment), ""];
15066
+ });
15067
+ }
15068
+ const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines, isVerbose ? [] : buildWarningsListLines(diagnostics, rulePriority));
14318
15069
  for (const line of lines) yield* Console.log(line);
14319
15070
  });
14320
15071
  const formatElapsedTime = (elapsedMilliseconds) => {
@@ -14364,10 +15115,6 @@ const colorizeByScore = (text, score) => {
14364
15115
  return highlighter.error(text);
14365
15116
  };
14366
15117
  //#endregion
14367
- //#region src/cli/utils/constants.ts
14368
- const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
14369
- const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
14370
- //#endregion
14371
15118
  //#region src/cli/utils/render-score-header.ts
14372
15119
  const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
14373
15120
  const RAINBOW_GRADIENT_WIDTH = 80;
@@ -14560,8 +15307,7 @@ const printNoScoreHeader = (noScoreMessage) => Effect.gen(function* () {
14560
15307
  const writeDiagnosticsDirectory = (diagnostics) => {
14561
15308
  const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
14562
15309
  mkdirSync(outputDirectory, { recursive: true });
14563
- const sortedRuleGroups = sortRuleGroupsByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
14564
- for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
15310
+ for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(diagnostics)) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
14565
15311
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
14566
15312
  return outputDirectory;
14567
15313
  };
@@ -14581,7 +15327,14 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
14581
15327
  };
14582
15328
  const printVerboseTip = (diagnostics, isVerbose) => Effect.gen(function* () {
14583
15329
  if (isVerbose || diagnostics.length === 0) return;
14584
- yield* Console.log(highlighter.dim(` Tip: Run ${highlighter.info("npx react-doctor@latest --verbose")} to list every issue`));
15330
+ const command = highlighter.info("npx react-doctor@latest --verbose");
15331
+ const message = diagnostics.some((diagnostic) => diagnostic.severity === "warning") ? `Run ${command} to see each warning explained with its fix` : `Run ${command} to see each issue explained with its fix`;
15332
+ yield* Console.log(highlighter.dim(` Tip: ${message}`));
15333
+ });
15334
+ const printDocsNote = () => Effect.gen(function* () {
15335
+ yield* Console.log("");
15336
+ yield* Console.log(` ${highlighter.bold("Docs:")} ${highlighter.info(DOCS_URL)}`);
15337
+ yield* Console.log(highlighter.dim(" Set up CI/CD, suppress rules with a config file, and scan diffs or PRs."));
14585
15338
  });
14586
15339
  const printSummary = (input) => Effect.gen(function* () {
14587
15340
  if (input.scoreResult) {
@@ -14752,9 +15505,6 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
14752
15505
  });
14753
15506
  const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
14754
15507
  //#endregion
14755
- //#region src/cli/utils/version.ts
14756
- const VERSION = "0.2.14-dev.8b313ba";
14757
- //#endregion
14758
15508
  //#region src/inspect.ts
14759
15509
  const silentConsole = makeNoopConsole();
14760
15510
  const runConsole = (effect) => {
@@ -14779,11 +15529,12 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
14779
15529
  customRulesOnly: userConfig?.customRulesOnly ?? false,
14780
15530
  share: userConfig?.share ?? true,
14781
15531
  respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
14782
- warnings: inputOptions.warnings ?? userConfig?.warnings ?? false,
15532
+ warnings: inputOptions.warnings ?? userConfig?.warnings ?? true,
14783
15533
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
14784
15534
  ignoredTags: buildIgnoredTags(userConfig),
14785
15535
  outputSurface: inputOptions.outputSurface ?? "cli",
14786
- suppressRendering: inputOptions.suppressRendering ?? false
15536
+ suppressRendering: inputOptions.suppressRendering ?? false,
15537
+ concurrency: inputOptions.concurrency
14787
15538
  });
14788
15539
  const inspect = async (directory, inputOptions = {}) => {
14789
15540
  const startTime = performance.now();
@@ -14823,7 +15574,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14823
15574
  shouldSkipLint: !options.lint || lintBindingMissing,
14824
15575
  shouldRunDeadCode: options.deadCode,
14825
15576
  shouldComputeScore: !options.noScore,
14826
- shouldShowProgressSpinners
15577
+ shouldShowProgressSpinners,
15578
+ oxlintConcurrency: options.concurrency
14827
15579
  });
14828
15580
  const program = runInspect({
14829
15581
  directory,
@@ -14879,15 +15631,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14879
15631
  };
14880
15632
  const finalizeAndRender = (input) => Effect.gen(function* () {
14881
15633
  const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
14882
- const skippedChecks = [];
14883
- if (didLintFail) skippedChecks.push("lint");
14884
- if (didDeadCodeFail) skippedChecks.push("dead-code");
15634
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
15635
+ didLintFail,
15636
+ lintFailureReason,
15637
+ lintPartialFailures,
15638
+ didDeadCodeFail,
15639
+ deadCodeFailureReason
15640
+ });
14885
15641
  const hasSkippedChecks = skippedChecks.length > 0;
14886
15642
  const noScoreMessage = buildNoScoreMessage(options.noScore);
14887
- const skippedCheckReasons = {};
14888
- if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
14889
- else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
14890
- if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
14891
15643
  const buildResult = () => ({
14892
15644
  diagnostics: [...diagnostics],
14893
15645
  score,
@@ -14923,7 +15675,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
14923
15675
  return buildResult();
14924
15676
  }
14925
15677
  yield* Console.log("");
14926
- yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]));
15678
+ yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment());
14927
15679
  if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
14928
15680
  if (demotedDiagnosticCount > 0) {
14929
15681
  yield* Console.log(highlighter.gray(` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`));
@@ -14948,6 +15700,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
14948
15700
  yield* Console.warn(highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`));
14949
15701
  }
14950
15702
  yield* printVerboseTip([...surfaceDiagnostics], options.verbose);
15703
+ yield* printDocsNote();
14951
15704
  return buildResult();
14952
15705
  });
14953
15706
  //#endregion
@@ -15041,6 +15794,7 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
15041
15794
  yield* Console.error("");
15042
15795
  yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
15043
15796
  yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
15797
+ yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
15044
15798
  yield* Console.error("");
15045
15799
  yield* Console.error(highlighter.error(formatErrorForReport(error)));
15046
15800
  yield* Console.error("");
@@ -15058,7 +15812,7 @@ const handleError = (error, options = { shouldExit: true }) => {
15058
15812
  //#endregion
15059
15813
  //#region src/cli/utils/build-handoff-payload.ts
15060
15814
  const buildHandoffPayload = (input) => {
15061
- const topGroups = sortRuleGroupsByImportance([...groupBy([...input.diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)]).slice(0, 3);
15815
+ const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
15062
15816
  let diagnosticsDirectory = null;
15063
15817
  try {
15064
15818
  diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
@@ -16490,6 +17244,78 @@ const printBrandedHeader = Effect.gen(function* () {
16490
17244
  yield* Console.log("");
16491
17245
  });
16492
17246
  //#endregion
17247
+ //#region src/cli/utils/build-run-context.ts
17248
+ const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
17249
+ const detectOrigin = () => {
17250
+ if (process.env.GIT_DIR) return "git-hook";
17251
+ if (isCodingAgentEnvironment()) return "agent";
17252
+ if (isCiEnvironment()) return "ci";
17253
+ return "cli";
17254
+ };
17255
+ const detectCommand = (userArguments) => {
17256
+ for (const argument of userArguments) {
17257
+ if (argument === "--") break;
17258
+ if (argument.startsWith("-")) continue;
17259
+ return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
17260
+ }
17261
+ return "inspect";
17262
+ };
17263
+ /**
17264
+ * Snapshot of the current invocation, attached to Sentry events as the
17265
+ * `run` context to make crashes triage-able (which version, platform,
17266
+ * CI/agent, how it was invoked). Every field is cheap, synchronous, and
17267
+ * safe to read at any point — cwd reads fall back, env reads are
17268
+ * booleans — so it's rebuilt lazily at capture time when runtime-only
17269
+ * signals like `jsonMode` are finally known.
17270
+ */
17271
+ const buildRunContext = () => {
17272
+ const userArguments = process.argv.slice(2);
17273
+ return {
17274
+ version: VERSION,
17275
+ origin: detectOrigin(),
17276
+ command: detectCommand(userArguments),
17277
+ argv: userArguments.join(" "),
17278
+ cwd: process.cwd(),
17279
+ node: process.version,
17280
+ platform: process.platform,
17281
+ arch: process.arch,
17282
+ ci: isCiEnvironment(),
17283
+ ciProvider: detectCiProvider(),
17284
+ codingAgent: detectCodingAgent(),
17285
+ interactive: !isNonInteractiveEnvironment(),
17286
+ jsonMode: isJsonModeActive()
17287
+ };
17288
+ };
17289
+ //#endregion
17290
+ //#region src/cli/utils/report-error.ts
17291
+ /**
17292
+ * Sends an error to Sentry, enriched with a snapshot of the current run
17293
+ * (version, platform, CI/agent, invocation), and waits for delivery
17294
+ * before the caller exits. The CLI tears down the process synchronously
17295
+ * after rendering an error, so the awaited `flush` is what actually gets
17296
+ * the event off the machine (see the Sentry CLI/serverless flush
17297
+ * contract).
17298
+ *
17299
+ * Returns early when Sentry was never initialized (`--no-score`, tests,
17300
+ * or a missing DSN), and swallows any transport failure so telemetry can
17301
+ * never mask the user's original error.
17302
+ */
17303
+ const reportErrorToSentry = async (error) => {
17304
+ if (!Sentry.isInitialized()) return;
17305
+ try {
17306
+ const runContext = buildRunContext();
17307
+ Sentry.setContext("run", { ...runContext });
17308
+ Sentry.setTags({
17309
+ origin: runContext.origin,
17310
+ command: runContext.command,
17311
+ ciProvider: runContext.ciProvider,
17312
+ codingAgent: runContext.codingAgent
17313
+ });
17314
+ Sentry.captureException(error);
17315
+ await Sentry.flush(2e3);
17316
+ } catch {}
17317
+ };
17318
+ //#endregion
16493
17319
  //#region src/cli/utils/path-format.ts
16494
17320
  const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
16495
17321
  //#endregion
@@ -16557,7 +17383,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
16557
17383
  yield* Console.log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalScanElapsedMilliseconds)}`);
16558
17384
  if (surfaceDiagnostics.length > 0) {
16559
17385
  yield* Console.log("");
16560
- yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)));
17386
+ yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment());
16561
17387
  }
16562
17388
  const lowestScoredScan = findLowestScoredScan(completedScans);
16563
17389
  const aggregateScore = lowestScoredScan?.result.score ?? null;
@@ -16587,6 +17413,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
16587
17413
  for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
16588
17414
  yield* Console.log("");
16589
17415
  yield* printVerboseTip(surfaceDiagnostics, verbose);
17416
+ yield* printDocsNote();
16590
17417
  });
16591
17418
  //#endregion
16592
17419
  //#region src/cli/utils/prompt-install-setup.ts
@@ -16634,6 +17461,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
16634
17461
  for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
16635
17462
  };
16636
17463
  //#endregion
17464
+ //#region src/cli/utils/resolve-parallel-flag.ts
17465
+ /**
17466
+ * Translates the `--experimental-parallel [workers]` flag into a concrete
17467
+ * worker count for `InspectOptions.concurrency`:
17468
+ *
17469
+ * - flag absent (`undefined`) → `undefined` (defer to the ambient
17470
+ * default: serial unless `REACT_DOCTOR_PARALLEL` is set)
17471
+ * - bare flag / `auto` → auto-detect CPU cores
17472
+ * - `--experimental-parallel <n>` → `n` workers (clamped)
17473
+ * - `false` / `off` / `0` → serial (an explicit opt-out, so
17474
+ * it overrides an env-enabled default rather than deferring to it)
17475
+ * - an unparseable value → auto-detect cores
17476
+ *
17477
+ * Commander yields `true` for a bare flag, the raw string for an explicit
17478
+ * value, and `undefined` when the flag is omitted.
17479
+ */
17480
+ const resolveParallelFlag = (parallel) => {
17481
+ if (parallel === void 0) return void 0;
17482
+ if (parallel === true) return resolveScanConcurrency("auto");
17483
+ if (parallel === false) return 1;
17484
+ const normalized = parallel.trim().toLowerCase();
17485
+ if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
17486
+ if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
17487
+ const parsed = Number.parseInt(normalized, 10);
17488
+ if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
17489
+ return resolveScanConcurrency(parsed);
17490
+ };
17491
+ //#endregion
16637
17492
  //#region src/cli/utils/resolve-cli-inspect-options.ts
16638
17493
  /**
16639
17494
  * Translates CLI flags into the `InspectOptions` contract `inspect()`
@@ -16656,10 +17511,11 @@ const resolveCliInspectOptions = (flags, userConfig) => {
16656
17511
  respectInlineDisables: flags.respectInlineDisables,
16657
17512
  warnings: flags.warnings ?? (wantsWarningGate ? true : void 0),
16658
17513
  scoreOnly: flags.score === true,
16659
- noScore: flags.score === false || (userConfig?.noScore ?? false),
17514
+ noScore: flags.score === false || flags.telemetry === false || (userConfig?.noScore ?? false),
16660
17515
  isCi: isCiEnvironment(),
16661
17516
  silent: Boolean(flags.json),
16662
- outputSurface: flags.prComment ? "prComment" : "cli"
17517
+ outputSurface: flags.prComment ? "prComment" : "cli",
17518
+ concurrency: resolveParallelFlag(flags.experimentalParallel)
16663
17519
  };
16664
17520
  };
16665
17521
  //#endregion
@@ -16818,11 +17674,9 @@ const parseFileLineArgument = (rawArgument) => {
16818
17674
  //#endregion
16819
17675
  //#region src/cli/utils/select-projects.ts
16820
17676
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
17677
+ const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json"));
16821
17678
  let packages = listWorkspacePackages(rootDirectory);
16822
- if (packages.length === 0) {
16823
- if (!isMonorepoRoot(rootDirectory)) return [rootDirectory];
16824
- packages = discoverReactSubprojects(rootDirectory);
16825
- }
17679
+ if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) packages = discoverReactSubprojects(rootDirectory);
16826
17680
  if (packages.length === 0) return [rootDirectory];
16827
17681
  if (packages.length === 1) {
16828
17682
  cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages[0].name}`);
@@ -16926,6 +17780,7 @@ const validateModeFlags = (flags) => {
16926
17780
  if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
16927
17781
  if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
16928
17782
  if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
17783
+ if (flags.score && flags.telemetry === false) throw new Error("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
16929
17784
  if (flags.prComment && (flags.json || flags.score)) throw new Error("--pr-comment cannot be combined with --json or --score.");
16930
17785
  if (flags.annotations && flags.score) throw new Error("--annotations cannot be combined with --score.");
16931
17786
  if (flags.explain !== void 0 && flags.why !== void 0) throw new Error("Use --explain or --why, not both — they're aliases of the same flag.");
@@ -16971,7 +17826,7 @@ const inspectAction = async (directory, flags) => {
16971
17826
  });
16972
17827
  try {
16973
17828
  validateModeFlags(flags);
16974
- const scanTarget = resolveScanTarget(requestedDirectory);
17829
+ const scanTarget = resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
16975
17830
  const userConfig = scanTarget.userConfig;
16976
17831
  const resolvedDirectory = scanTarget.resolvedDirectory;
16977
17832
  setJsonReportDirectory(resolvedDirectory);
@@ -17145,6 +18000,7 @@ const inspectAction = async (directory, flags) => {
17145
18000
  })) printAgentInstallHint();
17146
18001
  }
17147
18002
  } catch (error) {
18003
+ await reportErrorToSentry(error);
17148
18004
  if (isJsonMode) {
17149
18005
  writeJsonErrorReport(error);
17150
18006
  process.exitCode = 1;
@@ -17166,10 +18022,61 @@ const installAction = async (options, command) => {
17166
18022
  projectRoot: options.cwd ?? process.cwd()
17167
18023
  });
17168
18024
  } catch (error) {
18025
+ await reportErrorToSentry(error);
17169
18026
  handleError(error);
17170
18027
  }
17171
18028
  };
17172
18029
  //#endregion
18030
+ //#region src/cli/commands/version.ts
18031
+ /**
18032
+ * oclif-style version line. 12-factor CLI Apps (#3, "What version am I
18033
+ * on?"): the `version` command is the primary place users grab debugging
18034
+ * info, so it carries the Node runtime and platform alongside the CLI
18035
+ * version. The `-v` / `-V` / `--version` flags stay terse (just the
18036
+ * number) so scripts can parse them.
18037
+ */
18038
+ const buildVersionString = () => `react-doctor/${VERSION} ${process.platform}-${process.arch} node-${process.version}`;
18039
+ const versionAction = () => {
18040
+ process.stdout.write(`${buildVersionString()}\n`);
18041
+ };
18042
+ //#endregion
18043
+ //#region src/cli/utils/apply-color-preference.ts
18044
+ /**
18045
+ * Resolve an explicit color preference from `--color` / `--no-color` or the
18046
+ * app-specific `REACT_DOCTOR_NO_COLOR` / `REACT_DOCTOR_FORCE_COLOR` env vars
18047
+ * (clig.dev Output; 12-factor #6), overriding picocolors' own
18048
+ * `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY detection. Flags win over env
18049
+ * vars; with neither set, picocolors' detection stands.
18050
+ *
18051
+ * A resolved preference is mirrored onto the standard `NO_COLOR` /
18052
+ * `FORCE_COLOR` env vars in addition to our picocolors highlighter, so
18053
+ * libraries with their own color stacks (the `ora` spinner, `prompts`)
18054
+ * honor it too rather than only the scan report. Scanning argv directly
18055
+ * (not Commander's parsed options) applies the preference before Commander
18056
+ * parses, so it reaches every later path. The scan stops at `--`.
18057
+ */
18058
+ const applyColorPreference = (argv, env = process.env) => {
18059
+ let enabled;
18060
+ for (const argument of argv) {
18061
+ if (argument === "--") break;
18062
+ if (argument === "--no-color") enabled = false;
18063
+ else if (argument === "--color") enabled = true;
18064
+ }
18065
+ if (enabled === void 0) {
18066
+ if (env.REACT_DOCTOR_NO_COLOR) enabled = false;
18067
+ else if (env.REACT_DOCTOR_FORCE_COLOR) enabled = true;
18068
+ }
18069
+ if (enabled === void 0) return;
18070
+ if (enabled) {
18071
+ env.FORCE_COLOR = "1";
18072
+ delete env.NO_COLOR;
18073
+ } else {
18074
+ env.NO_COLOR = "1";
18075
+ delete env.FORCE_COLOR;
18076
+ }
18077
+ setColorEnabled(enabled);
18078
+ };
18079
+ //#endregion
17173
18080
  //#region src/cli/utils/exit-gracefully.ts
17174
18081
  const exitGracefully = () => {
17175
18082
  try {
@@ -17179,21 +18086,54 @@ const exitGracefully = () => {
17179
18086
  process.exit(130);
17180
18087
  };
17181
18088
  //#endregion
18089
+ //#region src/cli/utils/normalize-help-command.ts
18090
+ /**
18091
+ * 12-factor CLI Apps (#1, "Great help is essential"): `mycli help` and
18092
+ * `mycli help <command>` must display help. Commander doesn't wire this
18093
+ * up once the root command has its own default action plus a positional
18094
+ * argument — it treats a leading `help` as the `[directory]` to scan,
18095
+ * which then errors with "No React project found in ./help".
18096
+ *
18097
+ * We rewrite the argv up front so the existing `--help` paths handle it:
18098
+ * `react-doctor help` -> `react-doctor --help`
18099
+ * `react-doctor help install` -> `react-doctor install --help`
18100
+ *
18101
+ * Only a *leading* `help` token is rewritten, so a flag value such as
18102
+ * `--project help` is never mistaken for the help command. The target is
18103
+ * the first non-flag token after `help`, so intervening flags like
18104
+ * `help --no-color install` still resolve to `install`. An unknown target
18105
+ * (`help bogus`) falls back to root help rather than erroring.
18106
+ */
18107
+ const normalizeHelpInvocation = (argv, knownCommands) => {
18108
+ const nodeArguments = argv.slice(0, 2);
18109
+ const userArguments = argv.slice(2);
18110
+ if (userArguments[0] !== "help") return [...argv];
18111
+ const target = userArguments.slice(1).find((argument) => !argument.startsWith("-"));
18112
+ if (target !== void 0 && knownCommands.includes(target)) return [
18113
+ ...nodeArguments,
18114
+ target,
18115
+ "--help"
18116
+ ];
18117
+ return [...nodeArguments, "--help"];
18118
+ };
18119
+ //#endregion
17182
18120
  //#region src/cli/utils/strip-unknown-cli-flags.ts
17183
- const NODE_ARGUMENT_COUNT = 2;
17184
18121
  const ROOT_FLAG_SPEC = {
17185
18122
  longOptionsWithoutValues: new Set([
17186
18123
  "--annotations",
18124
+ "--color",
17187
18125
  "--dead-code",
17188
18126
  "--full",
17189
18127
  "--help",
17190
18128
  "--json",
17191
18129
  "--json-compact",
17192
18130
  "--lint",
18131
+ "--no-color",
17193
18132
  "--no-dead-code",
17194
18133
  "--no-lint",
17195
18134
  "--no-respect-inline-disables",
17196
18135
  "--no-score",
18136
+ "--no-telemetry",
17197
18137
  "--no-warnings",
17198
18138
  "--pr-comment",
17199
18139
  "--respect-inline-disables",
@@ -17211,7 +18151,7 @@ const ROOT_FLAG_SPEC = {
17211
18151
  "--project",
17212
18152
  "--why"
17213
18153
  ]),
17214
- longOptionsWithOptionalValues: new Set(["--diff"]),
18154
+ longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
17215
18155
  shortOptionsWithoutValues: new Set([
17216
18156
  "-h",
17217
18157
  "-v",
@@ -17222,8 +18162,10 @@ const ROOT_FLAG_SPEC = {
17222
18162
  const INSTALL_FLAG_SPEC = {
17223
18163
  longOptionsWithoutValues: new Set([
17224
18164
  "--agent-hooks",
18165
+ "--color",
17225
18166
  "--dry-run",
17226
18167
  "--help",
18168
+ "--no-color",
17227
18169
  "--yes"
17228
18170
  ]),
17229
18171
  longOptionsWithRequiredValues: new Set(["--cwd"]),
@@ -17231,7 +18173,21 @@ const INSTALL_FLAG_SPEC = {
17231
18173
  shortOptionsWithoutValues: new Set(["-h", "-y"]),
17232
18174
  shortOptionsWithRequiredValues: new Set(["-c"])
17233
18175
  };
17234
- const COMMAND_FLAG_SPECS = new Map([["install", INSTALL_FLAG_SPEC], ["setup", INSTALL_FLAG_SPEC]]);
18176
+ const COMMAND_FLAG_SPECS = new Map([
18177
+ ["install", INSTALL_FLAG_SPEC],
18178
+ ["setup", INSTALL_FLAG_SPEC],
18179
+ ["version", {
18180
+ longOptionsWithoutValues: new Set([
18181
+ "--color",
18182
+ "--help",
18183
+ "--no-color"
18184
+ ]),
18185
+ longOptionsWithRequiredValues: /* @__PURE__ */ new Set(),
18186
+ longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
18187
+ shortOptionsWithoutValues: new Set(["-h"]),
18188
+ shortOptionsWithRequiredValues: /* @__PURE__ */ new Set()
18189
+ }]
18190
+ ]);
17235
18191
  const isFlagLike = (argument) => argument.startsWith("-") && argument !== "-";
17236
18192
  const getLongOptionName = (argument) => {
17237
18193
  const equalsIndex = argument.indexOf("=");
@@ -17285,8 +18241,8 @@ const stripUnknownFlags = (userArguments, flagSpec) => {
17285
18241
  return sanitizedArguments;
17286
18242
  };
17287
18243
  const stripUnknownCliFlags = (argv) => {
17288
- const nodeArguments = argv.slice(0, NODE_ARGUMENT_COUNT);
17289
- const userArguments = argv.slice(NODE_ARGUMENT_COUNT);
18244
+ const nodeArguments = argv.slice(0, 2);
18245
+ const userArguments = argv.slice(2);
17290
18246
  const commandIndex = findCommandIndex(userArguments);
17291
18247
  if (commandIndex === null) return [...nodeArguments, ...stripUnknownFlags(userArguments, ROOT_FLAG_SPEC)];
17292
18248
  const commandName = userArguments[commandIndex];
@@ -17300,23 +18256,66 @@ const stripUnknownCliFlags = (argv) => {
17300
18256
  };
17301
18257
  //#endregion
17302
18258
  //#region src/cli/index.ts
18259
+ initializeSentry();
17303
18260
  process.on("SIGINT", exitGracefully);
17304
18261
  process.on("SIGTERM", exitGracefully);
17305
18262
  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", `
18263
+ const formatExampleLines = (examples) => {
18264
+ const width = Math.max(...examples.map(([command]) => command.length));
18265
+ return examples.map(([command, description]) => ` $ ${command.padEnd(width)} ${highlighter.dim(`# ${description}`)}`).join("\n");
18266
+ };
18267
+ const renderRootHelpEpilog = () => `
18268
+ ${highlighter.dim("Examples:")}
18269
+ ${formatExampleLines([
18270
+ ["react-doctor", "scan the current project"],
18271
+ ["react-doctor ./apps/web", "scan a specific directory"],
18272
+ ["react-doctor --diff main", "scan only files changed vs. main"],
18273
+ ["react-doctor --staged", "scan staged files (pre-commit hook)"],
18274
+ ["react-doctor --fail-on warning", "exit non-zero on warnings (CI gate)"],
18275
+ ["react-doctor --json > report.json", "write a machine-readable report"],
18276
+ ["react-doctor --explain src/App.tsx:42", "explain why a rule fired there"],
18277
+ ["react-doctor install", "set up the agent skill and git hook"]
18278
+ ])}
18279
+
17307
18280
  ${highlighter.dim("Configuration:")}
17308
18281
  Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
17309
18282
  CLI flags always override config values. See the README for the full schema.
17310
18283
 
18284
+ ${highlighter.dim("Feedback & bug reports:")}
18285
+ ${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
18286
+
17311
18287
  ${highlighter.dim("Learn more:")}
17312
18288
  ${highlighter.info(CANONICAL_GITHUB_URL)}
17313
- `);
18289
+ `;
18290
+ const renderInstallHelpEpilog = () => `
18291
+ ${highlighter.dim("Examples:")}
18292
+ ${formatExampleLines([
18293
+ ["react-doctor install", "interactive setup"],
18294
+ ["react-doctor install --yes", "non-interactive; all detected agents"],
18295
+ ["react-doctor install --dry-run", "preview without writing files"],
18296
+ ["react-doctor install --agent-hooks", "also install native agent hooks"]
18297
+ ])}
18298
+
18299
+ ${highlighter.dim("Learn more:")}
18300
+ ${highlighter.info(CANONICAL_GITHUB_URL)}
18301
+ `;
18302
+ 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, the share URL, and crash reporting").option("--no-telemetry", "alias for --no-score (skip the score API, share URL, and crash reporting)").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 (default)").option("--no-warnings", "hide warning-severity diagnostics (errors only)").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderRootHelpEpilog);
17314
18303
  program.action(inspectAction);
17315
- program.command("install").alias("setup").description("Install the react-doctor skill into your coding agents and optional git hook").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor").option("-c, --cwd <cwd>", "working directory", process.cwd()).action(installAction);
18304
+ program.command("install").alias("setup").description("Install the react-doctor skill into your coding agents and optional git hook").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor").option("-c, --cwd <cwd>", "working directory", process.cwd()).option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderInstallHelpEpilog).action(installAction);
18305
+ program.command("version").description("show the version with Node and platform info").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").action(versionAction);
17316
18306
  process.stdout.on("error", (error) => {
17317
18307
  if (error.code === "EPIPE") process.exit(0);
17318
18308
  });
17319
- program.parseAsync(stripUnknownCliFlags(process.argv)).catch((error) => {
18309
+ const knownCommands = program.commands.flatMap((command) => [command.name(), ...command.aliases()]);
18310
+ const strippedArgv = stripUnknownCliFlags(process.argv);
18311
+ if (process.argv.includes("-V") && !strippedArgv.includes("-V")) {
18312
+ process.stdout.write(`${VERSION}\n`);
18313
+ process.exit(0);
18314
+ }
18315
+ applyColorPreference(strippedArgv);
18316
+ const argv = normalizeHelpInvocation(strippedArgv, knownCommands);
18317
+ program.parseAsync(argv).catch(async (error) => {
18318
+ await reportErrorToSentry(error);
17320
18319
  if (isJsonModeActive()) {
17321
18320
  writeJsonErrorReport(error);
17322
18321
  process.exit(1);