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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,3 +1,5 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="74dccde1-47a7-5718-88d7-ea8e5d7b463b")}catch(e){}}();
1
3
  import { createRequire } from "node:module";
2
4
  import { execFileSync, spawn, spawnSync } from "node:child_process";
3
5
  import * as Path from "node:path";
@@ -15,7 +17,10 @@ import * as Redacted from "effect/Redacted";
15
17
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
16
18
  import * as Otlp from "effect/unstable/observability/Otlp";
17
19
  import * as Context from "effect/Context";
20
+ import os, { tmpdir } from "node:os";
18
21
  import * as Console from "effect/Console";
22
+ import { parseJSON5 } from "confbox";
23
+ import { createJiti } from "jiti";
19
24
  import * as Fiber from "effect/Fiber";
20
25
  import * as Filter from "effect/Filter";
21
26
  import * as Option from "effect/Option";
@@ -27,10 +32,12 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
27
32
  import * as NodePath from "@effect/platform-node-shared/NodePath";
28
33
  import * as ChildProcess from "effect/unstable/process/ChildProcess";
29
34
  import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
30
- import os, { tmpdir } from "node:os";
31
35
  import * as ts from "typescript";
32
36
  import { gzipSync } from "node:zlib";
37
+ import * as Sentry from "@sentry/node";
33
38
  import { performance } from "node:perf_hooks";
39
+ import * as Tracer from "effect/Tracer";
40
+ import * as Exit from "effect/Exit";
34
41
  import { stripVTControlCharacters } from "node:util";
35
42
  import tty from "node:tty";
36
43
  import { codeFrameColumns } from "@babel/code-frame";
@@ -39,6 +46,8 @@ import basePrompts from "prompts";
39
46
  import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
40
47
  import { fileURLToPath } from "node:url";
41
48
  import Conf from "conf";
49
+ import { generateCode, loadFile, writeFile } from "magicast";
50
+ import { getConfigFromVariableDeclaration, getDefaultExportOptions } from "magicast/helpers";
42
51
  //#region \0rolldown/runtime.js
43
52
  var __create$1 = Object.create;
44
53
  var __defProp$1 = Object.defineProperty;
@@ -5892,29 +5901,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
5892
5901
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
5893
5902
  };
5894
5903
  };
5895
- const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
5896
- if (predicate(rootPackageJson)) return true;
5904
+ const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
5905
+ const rootValue = select(rootPackageJson);
5906
+ if (rootValue !== null) return rootValue;
5897
5907
  const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
5898
- if (patterns.length === 0) return false;
5908
+ if (patterns.length === 0) return null;
5899
5909
  const visitedDirectories = /* @__PURE__ */ new Set();
5900
5910
  for (const pattern of patterns) {
5901
- const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
5911
+ const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
5902
5912
  for (const workspaceDirectory of directories) {
5903
5913
  if (visitedDirectories.has(workspaceDirectory)) continue;
5904
5914
  visitedDirectories.add(workspaceDirectory);
5905
- if (predicate(readPackageJson$1(path.join(workspaceDirectory, "package.json")))) return true;
5915
+ const value = select(readPackageJson$1(path.join(workspaceDirectory, "package.json")));
5916
+ if (value !== null) return value;
5906
5917
  }
5907
5918
  }
5908
- return false;
5919
+ return null;
5909
5920
  };
5921
+ const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
5910
5922
  const NAMES = new Set([
5911
5923
  "react-native",
5912
5924
  "react-native-tvos",
5913
- "expo",
5914
- "expo-router",
5915
- "@expo/cli",
5916
- "@expo/metro-config",
5917
- "@expo/metro-runtime",
5925
+ ...new Set([
5926
+ "expo",
5927
+ "expo-router",
5928
+ "@expo/cli",
5929
+ "@expo/metro-config",
5930
+ "@expo/metro-runtime"
5931
+ ]),
5918
5932
  "react-native-windows",
5919
5933
  "react-native-macos"
5920
5934
  ]);
@@ -5938,6 +5952,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
5938
5952
  return false;
5939
5953
  };
5940
5954
  const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
5955
+ const getExpoDependencySpec = (packageJson) => {
5956
+ const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
5957
+ return typeof spec === "string" ? spec : null;
5958
+ };
5959
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
5941
5960
  const getPreactVersion = (packageJson) => {
5942
5961
  return {
5943
5962
  ...packageJson.peerDependencies,
@@ -6174,6 +6193,19 @@ const discoverProject = (directory) => {
6174
6193
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
6175
6194
  const sourceFileCount = countSourceFiles(directory);
6176
6195
  const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
6196
+ let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
6197
+ if (expoVersion !== null && isCatalogReference(expoVersion)) {
6198
+ const catalogName = extractCatalogName(expoVersion);
6199
+ let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
6200
+ if (!resolvedExpoVersion) {
6201
+ const monorepoRoot = findMonorepoRoot(directory);
6202
+ if (monorepoRoot) {
6203
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
6204
+ if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson$1(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
6205
+ }
6206
+ }
6207
+ expoVersion = resolvedExpoVersion ?? expoVersion;
6208
+ }
6177
6209
  const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
6178
6210
  const preactVersion = getPreactVersion(packageJson);
6179
6211
  const projectInfo = {
@@ -6191,6 +6223,7 @@ const discoverProject = (directory) => {
6191
6223
  preactVersion,
6192
6224
  preactMajorVersion: parseReactMajor(preactVersion),
6193
6225
  hasReactNativeWorkspace,
6226
+ expoVersion,
6194
6227
  hasReanimated,
6195
6228
  sourceFileCount
6196
6229
  };
@@ -6266,7 +6299,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
6266
6299
  const SCORE_API_URL = "https://www.react.doctor/api/score";
6267
6300
  const ENTERPRISE_CONTACT_URL = "https://react.doctor/enterprise";
6268
6301
  const SHARE_BASE_URL = "https://react.doctor/share";
6269
- const PROMPTS_RULES_BASE_URL = "https://www.react.doctor/prompts/rules";
6302
+ const DOCS_URL = "https://www.react.doctor/docs";
6303
+ const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
6270
6304
  const FETCH_TIMEOUT_MS = 1e4;
6271
6305
  const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
6272
6306
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
@@ -6284,11 +6318,19 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
6284
6318
  "tsconfig.json",
6285
6319
  "tsconfig.base.json",
6286
6320
  "package.json",
6287
- "react-doctor.config.json",
6321
+ "doctor.config.ts",
6322
+ "doctor.config.mts",
6323
+ "doctor.config.cts",
6324
+ "doctor.config.js",
6325
+ "doctor.config.mjs",
6326
+ "doctor.config.cjs",
6327
+ "doctor.config.json",
6328
+ "doctor.config.jsonc",
6288
6329
  "oxlint.json",
6289
6330
  ".oxlintrc.json"
6290
6331
  ];
6291
6332
  const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
6333
+ const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
6292
6334
  const SKILL_NAME = "react-doctor";
6293
6335
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
6294
6336
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
@@ -7264,6 +7306,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
7264
7306
  }).pipe(Layer.provide(FetchHttpClient.layer));
7265
7307
  }).pipe(Effect.orDie));
7266
7308
  /**
7309
+ * Resolves a requested lint worker count to a clamped integer within
7310
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
7311
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
7312
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
7313
+ */
7314
+ const resolveScanConcurrency = (requested) => {
7315
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
7316
+ if (!Number.isFinite(desired) || desired < 1) return 1;
7317
+ return Math.max(1, Math.min(Math.floor(desired), 16));
7318
+ };
7319
+ /**
7267
7320
  * Per-batch oxlint wall-clock budget. Reads from the env var on
7268
7321
  * startup so the eval harness can raise the budget under sandbox
7269
7322
  * microVMs without recompiling react-doctor. Tests override via
@@ -7283,6 +7336,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
7283
7336
  * tests that exercise the cap behavior.
7284
7337
  */
7285
7338
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
7339
+ /**
7340
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
7341
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
7342
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
7343
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
7344
+ * CI callers that never touch the flag:
7345
+ *
7346
+ * - unset / `0` / `false` / `off` → `1` (serial)
7347
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
7348
+ * - a positive integer → that many workers (clamped)
7349
+ *
7350
+ * The resolved value is always within
7351
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
7352
+ */
7353
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
7354
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
7355
+ if (raw === void 0) return 1;
7356
+ const normalized = raw.trim().toLowerCase();
7357
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
7358
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
7359
+ const parsed = Number.parseInt(normalized, 10);
7360
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
7361
+ return resolveScanConcurrency(parsed);
7362
+ } }) {};
7286
7363
  const DIAGNOSTIC_SURFACES = [
7287
7364
  "cli",
7288
7365
  "prComment",
@@ -7439,66 +7516,135 @@ const validateConfigTypes = (config) => {
7439
7516
  const warn = (message) => {
7440
7517
  Effect.runSync(Console.warn(message));
7441
7518
  };
7442
- const CONFIG_FILENAME = "react-doctor.config.json";
7443
- const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
7444
- const loadConfigFromDirectory = (directory) => {
7445
- const configFilePath = path.join(directory, CONFIG_FILENAME);
7446
- if (isFile(configFilePath)) try {
7447
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
7448
- const parsed = JSON.parse(fileContent);
7449
- if (isPlainObject(parsed)) return {
7450
- config: validateConfigTypes(parsed),
7451
- sourceDirectory: directory
7452
- };
7453
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
7454
- } catch (error) {
7455
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
7456
- }
7457
- const packageJsonPath = path.join(directory, "package.json");
7458
- if (isFile(packageJsonPath)) try {
7459
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
7460
- const packageJson = JSON.parse(fileContent);
7519
+ const CONFIG_BASENAME = "doctor.config";
7520
+ const CONFIG_EXTENSIONS = [
7521
+ "ts",
7522
+ "mts",
7523
+ "cts",
7524
+ "js",
7525
+ "mjs",
7526
+ "cjs",
7527
+ "json",
7528
+ "jsonc"
7529
+ ];
7530
+ const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
7531
+ const PACKAGE_JSON_FILENAME = "package.json";
7532
+ const PACKAGE_JSON_CONFIG_KEY$1 = "reactDoctor";
7533
+ const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
7534
+ const jiti = createJiti(import.meta.url);
7535
+ const formatError = (error) => error instanceof Error ? error.message : String(error);
7536
+ const loadModuleConfig = async (filePath) => {
7537
+ const imported = await jiti.import(filePath);
7538
+ return imported?.default ?? imported;
7539
+ };
7540
+ const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
7541
+ const readEmbeddedPackageJsonConfig = (directory) => {
7542
+ const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
7543
+ if (!isFile(packageJsonPath)) return null;
7544
+ try {
7545
+ const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
7461
7546
  if (isPlainObject(packageJson)) {
7462
- const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
7463
- if (isPlainObject(embeddedConfig)) return {
7464
- config: validateConfigTypes(embeddedConfig),
7465
- sourceDirectory: directory
7547
+ const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY$1];
7548
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
7549
+ }
7550
+ } catch {}
7551
+ return null;
7552
+ };
7553
+ const loadPackageJsonConfig = (directory) => {
7554
+ const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
7555
+ if (!embeddedConfig) return null;
7556
+ return {
7557
+ config: validateConfigTypes(embeddedConfig),
7558
+ sourceDirectory: directory,
7559
+ configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
7560
+ format: "package-json"
7561
+ };
7562
+ };
7563
+ const loadConfigFromDirectory = async (directory) => {
7564
+ let sawBrokenConfigFile = false;
7565
+ for (const extension of CONFIG_EXTENSIONS) {
7566
+ const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
7567
+ if (!isFile(filePath)) continue;
7568
+ const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
7569
+ try {
7570
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
7571
+ if (isPlainObject(parsed)) return {
7572
+ status: "found",
7573
+ loaded: {
7574
+ config: validateConfigTypes(parsed),
7575
+ sourceDirectory: directory,
7576
+ configFilePath: filePath,
7577
+ format: isDataFile ? "json" : "module"
7578
+ }
7466
7579
  };
7580
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
7581
+ sawBrokenConfigFile = true;
7582
+ } catch (error) {
7583
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
7584
+ sawBrokenConfigFile = true;
7467
7585
  }
7468
- } catch {
7469
- return null;
7470
7586
  }
7471
- return null;
7587
+ const packageJsonConfig = loadPackageJsonConfig(directory);
7588
+ if (packageJsonConfig) return {
7589
+ status: "found",
7590
+ loaded: packageJsonConfig
7591
+ };
7592
+ if (isFile(path.join(directory, LEGACY_CONFIG_FILENAME))) warn(`${LEGACY_CONFIG_FILENAME} is no longer read — rename it to ${CONFIG_BASENAME}.json (or author a ${CONFIG_BASENAME}.ts).`);
7593
+ return {
7594
+ status: sawBrokenConfigFile ? "invalid" : "absent",
7595
+ loaded: null
7596
+ };
7472
7597
  };
7473
7598
  const cachedConfigs = /* @__PURE__ */ new Map();
7474
- const loadConfigWithSource = (rootDirectory) => {
7475
- const cached = cachedConfigs.get(rootDirectory);
7476
- if (cached !== void 0) return cached;
7477
- const localConfig = loadConfigFromDirectory(rootDirectory);
7478
- if (localConfig) {
7479
- cachedConfigs.set(rootDirectory, localConfig);
7480
- return localConfig;
7481
- }
7482
- if (isProjectBoundary(rootDirectory)) {
7483
- cachedConfigs.set(rootDirectory, null);
7484
- return null;
7485
- }
7599
+ const clearConfigCache = () => {
7600
+ cachedConfigs.clear();
7601
+ };
7602
+ const loadConfigWalkingUp = async (rootDirectory) => {
7603
+ const localResult = await loadConfigFromDirectory(rootDirectory);
7604
+ if (localResult.status === "found") return localResult.loaded;
7605
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
7486
7606
  let ancestorDirectory = path.dirname(rootDirectory);
7487
7607
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
7488
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
7489
- if (ancestorConfig) {
7490
- cachedConfigs.set(rootDirectory, ancestorConfig);
7491
- return ancestorConfig;
7492
- }
7493
- if (isProjectBoundary(ancestorDirectory)) {
7494
- cachedConfigs.set(rootDirectory, null);
7495
- return null;
7496
- }
7608
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
7609
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
7610
+ if (isProjectBoundary(ancestorDirectory)) return null;
7497
7611
  ancestorDirectory = path.dirname(ancestorDirectory);
7498
7612
  }
7499
- cachedConfigs.set(rootDirectory, null);
7500
7613
  return null;
7501
7614
  };
7615
+ const loadConfigWithSource = (rootDirectory) => {
7616
+ const cached = cachedConfigs.get(rootDirectory);
7617
+ if (cached !== void 0) return cached;
7618
+ const loadPromise = loadConfigWalkingUp(rootDirectory);
7619
+ cachedConfigs.set(rootDirectory, loadPromise);
7620
+ return loadPromise;
7621
+ };
7622
+ const directoryHasCurrentConfig = (directory) => {
7623
+ for (const extension of CONFIG_EXTENSIONS) if (isFile(path.join(directory, `${CONFIG_BASENAME}.${extension}`))) return true;
7624
+ return readEmbeddedPackageJsonConfig(directory) !== null;
7625
+ };
7626
+ /**
7627
+ * Walks up from `rootDirectory` (same boundary semantics as
7628
+ * `loadConfigWithSource`) looking for a pre-migration
7629
+ * `react-doctor.config.json` that is no longer read. Returns the first one
7630
+ * found, or `null` when a current-format config supersedes it or none exists
7631
+ * before a project boundary. Detection only — the CLI performs the rename.
7632
+ */
7633
+ const findLegacyConfig = (rootDirectory) => {
7634
+ let directory = rootDirectory;
7635
+ while (true) {
7636
+ if (directoryHasCurrentConfig(directory)) return null;
7637
+ const legacyFilePath = path.join(directory, LEGACY_CONFIG_FILENAME);
7638
+ if (isFile(legacyFilePath)) return {
7639
+ legacyFilePath,
7640
+ directory
7641
+ };
7642
+ if (isProjectBoundary(directory)) return null;
7643
+ const parentDirectory = path.dirname(directory);
7644
+ if (parentDirectory === directory) return null;
7645
+ directory = parentDirectory;
7646
+ }
7647
+ };
7502
7648
  const resolveConfigRootDir = (config, configSourceDirectory) => {
7503
7649
  if (!config || !configSourceDirectory) return null;
7504
7650
  const rawRootDir = config.rootDir;
@@ -7526,8 +7672,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
7526
7672
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
7527
7673
  *
7528
7674
  * 1. Resolve the requested directory to absolute.
7529
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
7530
- * if present.
7675
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
7531
7676
  * 3. Honor `config.rootDir` to redirect the scan to a nested
7532
7677
  * project root, if configured.
7533
7678
  * 4. Walk into a nested React subproject when the requested
@@ -7545,9 +7690,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
7545
7690
  * via its own cache). Routing through `resolveScanTarget` keeps every
7546
7691
  * shell in agreement on what "the scan directory" means.
7547
7692
  */
7548
- const resolveScanTarget = (requestedDirectory, options = {}) => {
7693
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
7549
7694
  const absoluteRequested = path.resolve(requestedDirectory);
7550
- const loadedConfig = loadConfigWithSource(absoluteRequested);
7695
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
7551
7696
  const userConfig = loadedConfig?.config ?? null;
7552
7697
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
7553
7698
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
@@ -7562,6 +7707,359 @@ const resolveScanTarget = (requestedDirectory, options = {}) => {
7562
7707
  didRedirectViaRootDir: redirectedDirectory !== null
7563
7708
  };
7564
7709
  };
7710
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
7711
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
7712
+ const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
7713
+ return {
7714
+ rootDirectory,
7715
+ packageJson,
7716
+ directDependencyNames: getDirectDependencyNames(packageJson),
7717
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
7718
+ };
7719
+ };
7720
+ const buildExpoDiagnostic = (input) => ({
7721
+ filePath: input.filePath ?? "package.json",
7722
+ plugin: "react-doctor",
7723
+ rule: input.rule,
7724
+ severity: input.severity ?? "warning",
7725
+ message: input.message,
7726
+ help: input.help,
7727
+ line: input.line ?? 0,
7728
+ column: input.column ?? 0,
7729
+ category: input.category ?? "Correctness"
7730
+ });
7731
+ const CRITICAL_OVERRIDE_NAMES = new Set([
7732
+ "@expo/cli",
7733
+ "@expo/config",
7734
+ "@expo/metro-config",
7735
+ "@expo/metro-runtime",
7736
+ "@expo/metro",
7737
+ "metro"
7738
+ ]);
7739
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
7740
+ const collectOverrideNames = (packageJson) => new Set([
7741
+ ...Object.keys(packageJson.overrides ?? {}),
7742
+ ...Object.keys(packageJson.resolutions ?? {}),
7743
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
7744
+ ]);
7745
+ const checkExpoDependencyOverrides = (context) => {
7746
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
7747
+ if (overriddenCriticalNames.length === 0) return [];
7748
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
7749
+ return [buildExpoDiagnostic({
7750
+ rule: "expo-no-conflicting-dependency-override",
7751
+ 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`,
7752
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
7753
+ })];
7754
+ };
7755
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
7756
+ const result = spawnSync("git", [
7757
+ "check-ignore",
7758
+ "-q",
7759
+ absolutePath
7760
+ ], {
7761
+ cwd: rootDirectory,
7762
+ stdio: [
7763
+ "ignore",
7764
+ "ignore",
7765
+ "ignore"
7766
+ ]
7767
+ });
7768
+ if (result.error) return null;
7769
+ if (result.status === 0) return true;
7770
+ if (result.status === 1) return false;
7771
+ return null;
7772
+ };
7773
+ const LOCAL_ENV_FILE_NAMES = [
7774
+ ".env.local",
7775
+ ".env.development.local",
7776
+ ".env.production.local",
7777
+ ".env.test.local"
7778
+ ];
7779
+ const checkExpoEnvLocalFiles = (context) => {
7780
+ const { rootDirectory } = context;
7781
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
7782
+ const filePath = path.join(rootDirectory, fileName);
7783
+ if (!isFile(filePath)) return false;
7784
+ return isPathGitIgnored(rootDirectory, filePath) === false;
7785
+ });
7786
+ if (committedEnvFiles.length === 0) return [];
7787
+ return [buildExpoDiagnostic({
7788
+ rule: "expo-env-local-not-gitignored",
7789
+ category: "Security",
7790
+ 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`,
7791
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
7792
+ })];
7793
+ };
7794
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
7795
+ 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";
7796
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
7797
+ const unimodulesEntry = (packageName) => ({
7798
+ packageName,
7799
+ rule: "expo-no-unimodules-packages",
7800
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
7801
+ help: UNIMODULES_HELP
7802
+ });
7803
+ const FLAGGED_DEPENDENCIES = [
7804
+ unimodulesEntry("@unimodules/core"),
7805
+ unimodulesEntry("@unimodules/react-native-adapter"),
7806
+ unimodulesEntry("react-native-unimodules"),
7807
+ {
7808
+ packageName: "expo-cli",
7809
+ rule: "expo-no-cli-dependencies",
7810
+ 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`",
7811
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
7812
+ },
7813
+ {
7814
+ packageName: "eas-cli",
7815
+ rule: "expo-no-cli-dependencies",
7816
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
7817
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
7818
+ },
7819
+ {
7820
+ packageName: "expo-modules-autolinking",
7821
+ rule: "expo-no-redundant-dependency",
7822
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
7823
+ help: "Remove `expo-modules-autolinking` from your package.json"
7824
+ },
7825
+ {
7826
+ packageName: "expo-dev-launcher",
7827
+ rule: "expo-no-redundant-dependency",
7828
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
7829
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
7830
+ },
7831
+ {
7832
+ packageName: "expo-dev-menu",
7833
+ rule: "expo-no-redundant-dependency",
7834
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
7835
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
7836
+ },
7837
+ {
7838
+ packageName: "expo-modules-core",
7839
+ rule: "expo-no-redundant-dependency",
7840
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
7841
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
7842
+ },
7843
+ {
7844
+ packageName: "@expo/metro-config",
7845
+ rule: "expo-no-redundant-dependency",
7846
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
7847
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
7848
+ },
7849
+ {
7850
+ packageName: "@types/react-native",
7851
+ rule: "expo-no-redundant-dependency",
7852
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
7853
+ help: "Remove `@types/react-native` from your package.json",
7854
+ minSdkMajor: 48
7855
+ },
7856
+ {
7857
+ packageName: "@expo/config-plugins",
7858
+ rule: "expo-no-redundant-dependency",
7859
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
7860
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
7861
+ minSdkMajor: 48
7862
+ },
7863
+ {
7864
+ packageName: "@expo/prebuild-config",
7865
+ rule: "expo-no-redundant-dependency",
7866
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
7867
+ help: "Remove `@expo/prebuild-config` from your package.json",
7868
+ minSdkMajor: 53
7869
+ },
7870
+ {
7871
+ packageName: "expo-permissions",
7872
+ rule: "expo-no-redundant-dependency",
7873
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
7874
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
7875
+ minSdkMajor: 50
7876
+ },
7877
+ {
7878
+ packageName: "expo-app-loading",
7879
+ rule: "expo-no-redundant-dependency",
7880
+ message: "\"expo-app-loading\" was removed in SDK 49",
7881
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
7882
+ minSdkMajor: 49
7883
+ },
7884
+ {
7885
+ packageName: "expo-firebase-analytics",
7886
+ rule: "expo-no-redundant-dependency",
7887
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
7888
+ help: FIREBASE_HELP,
7889
+ minSdkMajor: 48
7890
+ },
7891
+ {
7892
+ packageName: "expo-firebase-recaptcha",
7893
+ rule: "expo-no-redundant-dependency",
7894
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
7895
+ help: FIREBASE_HELP,
7896
+ minSdkMajor: 48
7897
+ },
7898
+ {
7899
+ packageName: "expo-firebase-core",
7900
+ rule: "expo-no-redundant-dependency",
7901
+ message: "\"expo-firebase-core\" was removed in SDK 48",
7902
+ help: FIREBASE_HELP,
7903
+ minSdkMajor: 48
7904
+ }
7905
+ ];
7906
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
7907
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
7908
+ if (flaggedDependency.minSdkMajor === void 0) return true;
7909
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
7910
+ }).map((flaggedDependency) => buildExpoDiagnostic({
7911
+ rule: flaggedDependency.rule,
7912
+ message: flaggedDependency.message,
7913
+ help: flaggedDependency.help
7914
+ }));
7915
+ const findLocalModuleNativeFiles = (rootDirectory) => {
7916
+ const modulesDirectory = path.join(rootDirectory, "modules");
7917
+ if (!isDirectory(modulesDirectory)) return [];
7918
+ const nativeFilePaths = [];
7919
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
7920
+ if (!moduleEntry.isDirectory()) continue;
7921
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
7922
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
7923
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
7924
+ const iosDirectory = path.join(moduleDirectory, "ios");
7925
+ if (isDirectory(iosDirectory)) {
7926
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
7927
+ }
7928
+ }
7929
+ return nativeFilePaths;
7930
+ };
7931
+ const checkExpoGitignore = (context) => {
7932
+ const { rootDirectory } = context;
7933
+ const diagnostics = [];
7934
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
7935
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
7936
+ rule: "expo-gitignore",
7937
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
7938
+ help: "Add `.expo/` to your .gitignore"
7939
+ }));
7940
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
7941
+ rule: "expo-gitignore",
7942
+ 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",
7943
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
7944
+ }));
7945
+ return diagnostics;
7946
+ };
7947
+ const LOCKFILE_NAMES = [
7948
+ "pnpm-lock.yaml",
7949
+ "yarn.lock",
7950
+ "package-lock.json",
7951
+ "bun.lockb",
7952
+ "bun.lock"
7953
+ ];
7954
+ const checkExpoLockfile = (context) => {
7955
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
7956
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
7957
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
7958
+ rule: "expo-lockfile",
7959
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
7960
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
7961
+ })];
7962
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
7963
+ rule: "expo-lockfile",
7964
+ 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`,
7965
+ help: "Delete the lock files for the package managers you are not using and keep only one"
7966
+ })];
7967
+ return [];
7968
+ };
7969
+ const METRO_CONFIG_FILE_NAMES = [
7970
+ "metro.config.js",
7971
+ "metro.config.cjs",
7972
+ "metro.config.mjs",
7973
+ "metro.config.ts"
7974
+ ];
7975
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
7976
+ "expo/metro-config",
7977
+ "@sentry/react-native/metro",
7978
+ "getSentryExpoConfig"
7979
+ ];
7980
+ const checkExpoMetroConfig = (context) => {
7981
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
7982
+ if (metroConfigPath === void 0) return [];
7983
+ let contents;
7984
+ try {
7985
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
7986
+ } catch {
7987
+ return [];
7988
+ }
7989
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
7990
+ return [buildExpoDiagnostic({
7991
+ rule: "expo-metro-config",
7992
+ filePath: path.basename(metroConfigPath),
7993
+ 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",
7994
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
7995
+ })];
7996
+ };
7997
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
7998
+ const checkExpoPackageJsonConflicts = (context) => {
7999
+ const { packageJson } = context;
8000
+ const diagnostics = [];
8001
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
8002
+ if (conflictingScriptNames.length > 0) {
8003
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
8004
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
8005
+ diagnostics.push(buildExpoDiagnostic({
8006
+ rule: "expo-package-json-conflict",
8007
+ 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" : ""}`,
8008
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
8009
+ }));
8010
+ }
8011
+ const packageName = packageJson.name;
8012
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
8013
+ rule: "expo-package-json-conflict",
8014
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
8015
+ help: "Rename your package so it no longer matches one of its dependencies"
8016
+ }));
8017
+ return diagnostics;
8018
+ };
8019
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
8020
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
8021
+ const checkExpoRouterReactNavigation = (context) => {
8022
+ const { expoSdkMajor } = context;
8023
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
8024
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
8025
+ if (!context.directDependencyNames.has("expo-router")) return [];
8026
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
8027
+ if (reactNavigationNames.length === 0) return [];
8028
+ return [buildExpoDiagnostic({
8029
+ rule: "expo-router-no-react-navigation",
8030
+ 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"}`,
8031
+ 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/"
8032
+ })];
8033
+ };
8034
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
8035
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
8036
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
8037
+ const checkExpoVectorIcons = (context) => {
8038
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
8039
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
8040
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
8041
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
8042
+ return [buildExpoDiagnostic({
8043
+ rule: "expo-vector-icons-conflict",
8044
+ 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",
8045
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
8046
+ })];
8047
+ };
8048
+ const checkExpoProject = (rootDirectory, project) => {
8049
+ if (project.expoVersion === null) return [];
8050
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
8051
+ return [
8052
+ ...checkExpoFlaggedDependencies(context),
8053
+ ...checkExpoDependencyOverrides(context),
8054
+ ...checkExpoRouterReactNavigation(context),
8055
+ ...checkExpoVectorIcons(context),
8056
+ ...checkExpoPackageJsonConflicts(context),
8057
+ ...checkExpoLockfile(context),
8058
+ ...checkExpoGitignore(context),
8059
+ ...checkExpoEnvLocalFiles(context),
8060
+ ...checkExpoMetroConfig(context)
8061
+ ];
8062
+ };
7565
8063
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
7566
8064
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
7567
8065
  const PACKAGE_JSON_FILE = "package.json";
@@ -8241,8 +8739,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
8241
8739
  const cache = yield* Cache.make({
8242
8740
  capacity: 16,
8243
8741
  timeToLive: CONFIG_CACHE_TTL_MS,
8244
- lookup: (directory) => Effect.sync(() => {
8245
- const loaded = loadConfigWithSource(directory);
8742
+ lookup: (directory) => Effect.promise(async () => {
8743
+ const loaded = await loadConfigWithSource(directory);
8246
8744
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
8247
8745
  return {
8248
8746
  config: loaded?.config ?? null,
@@ -8839,6 +9337,7 @@ const buildCapabilities = (project) => {
8839
9337
  const capabilities = /* @__PURE__ */ new Set();
8840
9338
  capabilities.add(project.framework);
8841
9339
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
9340
+ if (project.expoVersion !== null) capabilities.add("expo");
8842
9341
  const reactMajor = project.reactMajorVersion;
8843
9342
  if (reactMajor !== null) {
8844
9343
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -9116,6 +9615,44 @@ const dedupeDiagnostics = (diagnostics) => {
9116
9615
  }
9117
9616
  return uniqueDiagnostics;
9118
9617
  };
9618
+ /**
9619
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
9620
+ * once, returning results in input order. A pool of workers each pulls the
9621
+ * next not-yet-started index until the list drains — so a worker that
9622
+ * finishes a fast task immediately picks up the next one (greedy load
9623
+ * balancing), which matters when tasks have uneven durations (oxlint
9624
+ * batches do).
9625
+ *
9626
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
9627
+ * no further tasks are started, the already-in-flight tasks are awaited to
9628
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
9629
+ * rejects with that first error. This keeps the caller's fail-fast retry
9630
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
9631
+ * top of a still-running first one.
9632
+ */
9633
+ const mapWithConcurrency = async (items, concurrency, task) => {
9634
+ const results = new Array(items.length);
9635
+ if (items.length === 0) return results;
9636
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
9637
+ let nextIndex = 0;
9638
+ const errors = [];
9639
+ const runWorker = async () => {
9640
+ while (errors.length === 0) {
9641
+ const index = nextIndex;
9642
+ nextIndex += 1;
9643
+ if (index >= items.length) return;
9644
+ try {
9645
+ results[index] = await task(items[index], index);
9646
+ } catch (error) {
9647
+ errors.push(error);
9648
+ return;
9649
+ }
9650
+ }
9651
+ };
9652
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
9653
+ if (errors.length > 0) throw errors[0];
9654
+ return results;
9655
+ };
9119
9656
  const getPublicEnvPrefix = (framework) => {
9120
9657
  switch (framework) {
9121
9658
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -9798,6 +10335,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
9798
10335
  */
9799
10336
  const spawnLintBatches = async (input) => {
9800
10337
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
10338
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
9801
10339
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
9802
10340
  const allDiagnostics = [];
9803
10341
  const droppedFiles = [];
@@ -9817,23 +10355,31 @@ const spawnLintBatches = async (input) => {
9817
10355
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
9818
10356
  }
9819
10357
  };
10358
+ let startedFileCount = 0;
9820
10359
  let scannedFileCount = 0;
9821
- for (const batch of fileBatches) {
9822
- let batchFileIndex = 0;
9823
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
9824
- if (batchFileIndex < batch.length) {
9825
- batchFileIndex += 1;
9826
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
9827
- }
9828
- }, 50) : null;
9829
- try {
10360
+ let displayedFileCount = 0;
10361
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
10362
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
10363
+ if (displayedFileCount < ceiling) {
10364
+ displayedFileCount += 1;
10365
+ onFileProgress(displayedFileCount, totalFileCount);
10366
+ }
10367
+ }, 50) : null;
10368
+ progressTimer?.unref?.();
10369
+ try {
10370
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
10371
+ startedFileCount += batch.length;
9830
10372
  const batchDiagnostics = await spawnLintBatch(batch);
9831
- allDiagnostics.push(...batchDiagnostics);
9832
10373
  scannedFileCount += batch.length;
9833
- onFileProgress?.(scannedFileCount, totalFileCount);
9834
- } finally {
9835
- if (progressInterval !== null) clearInterval(progressInterval);
9836
- }
10374
+ if (onFileProgress) {
10375
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
10376
+ onFileProgress(displayedFileCount, totalFileCount);
10377
+ }
10378
+ return batchDiagnostics;
10379
+ });
10380
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
10381
+ } finally {
10382
+ if (progressTimer !== null) clearInterval(progressTimer);
9837
10383
  }
9838
10384
  if (droppedFiles.length > 0 && onPartialFailure) {
9839
10385
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -9960,7 +10506,8 @@ const runOxlint = async (options) => {
9960
10506
  onPartialFailure,
9961
10507
  onFileProgress: options.onFileProgress,
9962
10508
  spawnTimeoutMs,
9963
- outputMaxBytes
10509
+ outputMaxBytes,
10510
+ concurrency: options.concurrency
9964
10511
  });
9965
10512
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
9966
10513
  try {
@@ -10028,6 +10575,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
10028
10575
  const partialFailures = yield* LintPartialFailures;
10029
10576
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
10030
10577
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
10578
+ const concurrency = yield* OxlintConcurrency;
10031
10579
  const collectedFailures = [];
10032
10580
  const diagnostics = yield* Effect.tryPromise({
10033
10581
  try: () => runOxlint({
@@ -10046,7 +10594,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
10046
10594
  },
10047
10595
  onFileProgress: input.onFileProgress,
10048
10596
  spawnTimeoutMs,
10049
- outputMaxBytes
10597
+ outputMaxBytes,
10598
+ concurrency
10050
10599
  }),
10051
10600
  catch: ensureReactDoctorError
10052
10601
  });
@@ -10370,7 +10919,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10370
10919
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
10371
10920
  yield* beforeLint(project, lintIncludePaths ?? void 0);
10372
10921
  const isDiffMode = input.includePaths.length > 0;
10373
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
10922
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
10374
10923
  const transform = buildDiagnosticPipeline({
10375
10924
  rootDirectory: scanDirectory,
10376
10925
  userConfig: resolvedConfig.config,
@@ -10379,7 +10928,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10379
10928
  showWarnings
10380
10929
  });
10381
10930
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
10382
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
10931
+ const environmentDiagnostics = isDiffMode ? [] : [
10932
+ ...checkReducedMotion(scanDirectory),
10933
+ ...checkPnpmHardening(scanDirectory),
10934
+ ...checkExpoProject(scanDirectory, project)
10935
+ ];
10383
10936
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
10384
10937
  const lintFailure = yield* Ref.make({
10385
10938
  didFail: false,
@@ -10391,6 +10944,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10391
10944
  didFail: false,
10392
10945
  reason: null
10393
10946
  });
10947
+ const scanConcurrency = yield* OxlintConcurrency;
10948
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
10394
10949
  const scanProgress = yield* progressService.start("Scanning...");
10395
10950
  const scanStartTime = Date.now();
10396
10951
  let lastReportedTotalFileCount = 0;
@@ -10407,7 +10962,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10407
10962
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
10408
10963
  onFileProgress: (scannedFileCount, totalFileCount) => {
10409
10964
  lastReportedTotalFileCount = totalFileCount;
10410
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
10965
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
10411
10966
  }
10412
10967
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
10413
10968
  yield* Ref.set(lintFailure, {
@@ -10439,7 +10994,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10439
10994
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
10440
10995
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
10441
10996
  else if (input.suppressScanSummary) yield* scanProgress.stop();
10442
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
10997
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
10443
10998
  yield* reporterService.finalize;
10444
10999
  const finalDiagnostics = [
10445
11000
  ...envCollected,
@@ -10491,7 +11046,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10491
11046
  "inspect.isCi": input.isCi,
10492
11047
  "inspect.scoreSurface": input.scoreSurface ?? "score"
10493
11048
  } }));
10494
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
10495
11049
  const parseNodeVersion = (versionString) => {
10496
11050
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
10497
11051
  return {
@@ -10814,6 +11368,26 @@ const buildJsonReport = (input) => {
10814
11368
  };
10815
11369
  };
10816
11370
  /**
11371
+ * Single source of truth for the skipped-check accounting shared by the
11372
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
11373
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
11374
+ * failed lint / dead-code pass instead of a false "all clear", so the
11375
+ * branch logic lives here once.
11376
+ */
11377
+ const buildSkippedChecks = (input) => {
11378
+ const skippedChecks = [];
11379
+ if (input.didLintFail) skippedChecks.push("lint");
11380
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
11381
+ const skippedCheckReasons = {};
11382
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
11383
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
11384
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
11385
+ return {
11386
+ skippedChecks,
11387
+ skippedCheckReasons
11388
+ };
11389
+ };
11390
+ /**
10817
11391
  * Programmatic façade over `Git.diffSelection`. Async because the
10818
11392
  * Git service runs through Effect's `ChildProcess` (true subprocess
10819
11393
  * spawn, not `spawnSync`).
@@ -10918,12 +11492,32 @@ const highlighter = {
10918
11492
  bold: import_picocolors.default.bold
10919
11493
  };
10920
11494
  /**
10921
- * Canonical URL for a rule's reviewer-tested fix recipe, served at
10922
- * `https://www.react.doctor/prompts/rules/<plugin>/<rule>.md`. The
10923
- * `/doctor` playbook fetches it on demand so each fix follows the
10924
- * canonical recipe instead of being improvised per diagnostic.
11495
+ * Override picocolors' automatic color detection. picocolors decides
11496
+ * once, at import time, from `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY.
11497
+ * This lets the CLI honor an explicit `--color` / `--no-color` flag
11498
+ * (clig.dev, Output: "Disable color if the user requested it") by
11499
+ * swapping in a fresh set of formatters. Call it before any colored
11500
+ * output is produced. Every call site reads `highlighter.<method>` at
11501
+ * call time, so reassigning the properties propagates everywhere.
11502
+ */
11503
+ const setColorEnabled = (enabled) => {
11504
+ const colors = import_picocolors.default.createColors(enabled);
11505
+ highlighter.error = colors.red;
11506
+ highlighter.warn = colors.yellow;
11507
+ highlighter.info = colors.cyan;
11508
+ highlighter.success = colors.green;
11509
+ highlighter.dim = colors.dim;
11510
+ highlighter.gray = colors.gray;
11511
+ highlighter.bold = colors.bold;
11512
+ };
11513
+ /**
11514
+ * Canonical URL for a rule's documentation page — its reviewer-tested fix
11515
+ * recipe rendered for humans — served at
11516
+ * `https://www.react.doctor/docs/rules/<plugin>/<rule>`. The CLI links here
11517
+ * from its fix-recipe directive so each fix follows the canonical recipe
11518
+ * instead of being improvised per diagnostic.
10925
11519
  */
10926
- const buildRulePromptUrl = (plugin, rule) => `${PROMPTS_RULES_BASE_URL}/${plugin}/${rule}.md`;
11520
+ const buildRuleDocsUrl = (plugin, rule) => `${DOCS_RULES_BASE_URL}/${plugin}/${rule}`;
10927
11521
  const groupBy = (items, keyFn) => {
10928
11522
  const groups = /* @__PURE__ */ new Map();
10929
11523
  for (const item of items) {
@@ -10936,8 +11530,8 @@ const groupBy = (items, keyFn) => {
10936
11530
  };
10937
11531
  /**
10938
11532
  * Whether a diagnostic's rule has a published per-rule fix recipe at
10939
- * `${PROMPTS_RULES_BASE_URL}/react-doctor/<rule>.md`
10940
- * (see `buildRulePromptUrl`).
11533
+ * `${DOCS_RULES_BASE_URL}/react-doctor/<rule>`
11534
+ * (see `buildRuleDocsUrl`).
10941
11535
  *
10942
11536
  * Recipes are generated from react-doctor's own engine rules, so only
10943
11537
  * those resolve. Dead-code (`deslop`), the synthetic environment and
@@ -10949,67 +11543,712 @@ const groupBy = (items, keyFn) => {
10949
11543
  */
10950
11544
  const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
10951
11545
  //#endregion
10952
- //#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
10953
- const ANSI_BACKGROUND_OFFSET = 10;
10954
- const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
10955
- const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
10956
- const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
10957
- const styles$1 = {
10958
- modifier: {
10959
- reset: [0, 0],
10960
- bold: [1, 22],
10961
- dim: [2, 22],
10962
- italic: [3, 23],
10963
- underline: [4, 24],
10964
- overline: [53, 55],
10965
- inverse: [7, 27],
10966
- hidden: [8, 28],
10967
- strikethrough: [9, 29]
10968
- },
10969
- color: {
10970
- black: [30, 39],
10971
- red: [31, 39],
10972
- green: [32, 39],
10973
- yellow: [33, 39],
10974
- blue: [34, 39],
10975
- magenta: [35, 39],
10976
- cyan: [36, 39],
10977
- white: [37, 39],
10978
- blackBright: [90, 39],
10979
- gray: [90, 39],
10980
- grey: [90, 39],
10981
- redBright: [91, 39],
10982
- greenBright: [92, 39],
10983
- yellowBright: [93, 39],
10984
- blueBright: [94, 39],
10985
- magentaBright: [95, 39],
10986
- cyanBright: [96, 39],
10987
- whiteBright: [97, 39]
10988
- },
10989
- bgColor: {
10990
- bgBlack: [40, 49],
10991
- bgRed: [41, 49],
10992
- bgGreen: [42, 49],
10993
- bgYellow: [43, 49],
10994
- bgBlue: [44, 49],
10995
- bgMagenta: [45, 49],
10996
- bgCyan: [46, 49],
10997
- bgWhite: [47, 49],
10998
- bgBlackBright: [100, 49],
10999
- bgGray: [100, 49],
11000
- bgGrey: [100, 49],
11001
- bgRedBright: [101, 49],
11002
- bgGreenBright: [102, 49],
11003
- bgYellowBright: [103, 49],
11004
- bgBlueBright: [104, 49],
11005
- bgMagentaBright: [105, 49],
11006
- bgCyanBright: [106, 49],
11007
- bgWhiteBright: [107, 49]
11546
+ //#region src/cli/utils/is-ci-environment.ts
11547
+ const CI_ENVIRONMENT_VARIABLES = [
11548
+ "GITHUB_ACTIONS",
11549
+ "GITLAB_CI",
11550
+ "CIRCLECI"
11551
+ ];
11552
+ const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
11553
+ ["GITHUB_ACTIONS", "github-actions"],
11554
+ ["GITLAB_CI", "gitlab-ci"],
11555
+ ["CIRCLECI", "circleci"],
11556
+ ["BUILDKITE", "buildkite"],
11557
+ ["JENKINS_URL", "jenkins"],
11558
+ ["TF_BUILD", "azure-pipelines"],
11559
+ ["CODEBUILD_BUILD_ID", "aws-codebuild"],
11560
+ ["TEAMCITY_VERSION", "teamcity"],
11561
+ ["BITBUCKET_BUILD_NUMBER", "bitbucket"],
11562
+ ["TRAVIS", "travis"],
11563
+ ["DRONE", "drone"]
11564
+ ];
11565
+ const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
11566
+ ["CLAUDECODE", "claude-code"],
11567
+ ["CLAUDE_CODE", "claude-code"],
11568
+ ["CURSOR_AGENT", "cursor"],
11569
+ ["CODEX_CI", "codex"],
11570
+ ["CODEX_SANDBOX", "codex"],
11571
+ ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
11572
+ ["OPENCODE", "opencode"],
11573
+ ["GOOSE_TERMINAL", "goose"],
11574
+ ["AMP_THREAD_ID", "amp"]
11575
+ ];
11576
+ const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
11577
+ const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
11578
+ const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
11579
+ [...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
11580
+ const FALSY_CI_FLAG_VALUES = new Set([
11581
+ "",
11582
+ "0",
11583
+ "false"
11584
+ ]);
11585
+ const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
11586
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
11587
+ const detectCiProvider = () => {
11588
+ for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
11589
+ return isCiFlagSet(process.env.CI) ? "unknown" : null;
11590
+ };
11591
+ const detectCodingAgentFromValue = () => {
11592
+ for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
11593
+ const value = process.env[environmentVariable]?.toLowerCase();
11594
+ if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
11008
11595
  }
11596
+ return null;
11009
11597
  };
11010
- Object.keys(styles$1.modifier);
11011
- const foregroundColorNames = Object.keys(styles$1.color);
11012
- const backgroundColorNames = Object.keys(styles$1.bgColor);
11598
+ const detectCodingAgent = () => {
11599
+ for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
11600
+ const agentFromValue = detectCodingAgentFromValue();
11601
+ if (agentFromValue) return agentFromValue;
11602
+ if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
11603
+ return null;
11604
+ };
11605
+ const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
11606
+ const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
11607
+ //#endregion
11608
+ //#region src/cli/utils/is-non-interactive-environment.ts
11609
+ const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
11610
+ "CI",
11611
+ "GITHUB_ACTIONS",
11612
+ "GITLAB_CI",
11613
+ "BUILDKITE",
11614
+ "JENKINS_URL",
11615
+ "TF_BUILD",
11616
+ "CODEBUILD_BUILD_ID",
11617
+ "TEAMCITY_VERSION",
11618
+ "BITBUCKET_BUILD_NUMBER",
11619
+ "CIRCLECI",
11620
+ "TRAVIS",
11621
+ "DRONE",
11622
+ "GIT_DIR"
11623
+ ];
11624
+ const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
11625
+ //#endregion
11626
+ //#region src/cli/utils/constants.ts
11627
+ const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
11628
+ const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
11629
+ const SENTRY_FLUSH_TIMEOUT_MS = 2e3;
11630
+ const NANOSECONDS_PER_SECOND = 1000000000n;
11631
+ //#endregion
11632
+ //#region src/cli/utils/noop-console.ts
11633
+ /**
11634
+ * A concrete `Console.Console` whose methods are all no-ops.
11635
+ *
11636
+ * Used by `--silent` (provided via
11637
+ * `Effect.provideService(Console.Console, makeNoopConsole())`) and by
11638
+ * `enableJsonMode` (assigned over the relevant slots on
11639
+ * `globalThis.console` so imperative legacy callsites that aren't
11640
+ * Effect-typed also fall silent). Sourcing both from a single concrete
11641
+ * object keeps "what is a no-op console" answered in one place; the
11642
+ * earlier `new Proxy({} as Console.Console, { get: () => () => undefined })`
11643
+ * combined a cast with a Proxy to do the same thing implicitly.
11644
+ *
11645
+ * The interface mirrors Effect v4's `Console.Console` shape exactly so
11646
+ * `Effect.provideService(Console.Console, makeNoopConsole())` requires
11647
+ * no cast.
11648
+ */
11649
+ const makeNoopConsole = () => ({
11650
+ assert: () => {},
11651
+ clear: () => {},
11652
+ count: () => {},
11653
+ countReset: () => {},
11654
+ debug: () => {},
11655
+ dir: () => {},
11656
+ dirxml: () => {},
11657
+ error: () => {},
11658
+ group: () => {},
11659
+ groupCollapsed: () => {},
11660
+ groupEnd: () => {},
11661
+ info: () => {},
11662
+ log: () => {},
11663
+ table: () => {},
11664
+ time: () => {},
11665
+ timeEnd: () => {},
11666
+ timeLog: () => {},
11667
+ trace: () => {},
11668
+ warn: () => {}
11669
+ });
11670
+ //#endregion
11671
+ //#region src/cli/utils/version.ts
11672
+ const VERSION = "0.2.14-dev.e9e71bb";
11673
+ //#endregion
11674
+ //#region src/cli/utils/json-mode.ts
11675
+ let context = null;
11676
+ /**
11677
+ * JSON mode writes the report payload to stdout; any incidental log
11678
+ * line printed by an Effect program would corrupt the JSON. Effect's
11679
+ * `Console` module resolves to `globalThis.console` by default (see
11680
+ * `effect/internal/effect.ts` → `ConsoleRef`), so copying the methods
11681
+ * from `makeNoopConsole()` onto the global is enough to silence every
11682
+ * `yield* Console.log(...)` and `cliLogger.*` call sourced from
11683
+ * react-doctor or its services.
11684
+ *
11685
+ * We use the same `makeNoopConsole()` source as the `--silent` path
11686
+ * (which provides the Effect Console via
11687
+ * `Effect.provideService(Console.Console, makeNoopConsole())`) — one
11688
+ * canonical "no-op console" definition shared by the two silent
11689
+ * mechanisms. The two routes still differ in how they install the
11690
+ * noop: silent mode swaps the Effect Console reference inside the
11691
+ * program; JSON mode patches the global because the surrounding CLI
11692
+ * command body is still imperative. Both will collapse into the
11693
+ * Effect-typed route once the command body finishes its migration.
11694
+ *
11695
+ * JSON mode is one-shot per CLI invocation, so we never restore.
11696
+ */
11697
+ const installSilentConsole = () => {
11698
+ const noopConsole = makeNoopConsole();
11699
+ const target = globalThis.console;
11700
+ const source = noopConsole;
11701
+ for (const key of [
11702
+ "log",
11703
+ "error",
11704
+ "warn",
11705
+ "info",
11706
+ "debug",
11707
+ "trace"
11708
+ ]) target[key] = source[key];
11709
+ };
11710
+ const enableJsonMode = ({ compact, directory }) => {
11711
+ context = {
11712
+ compact,
11713
+ directory,
11714
+ startTime: performance.now(),
11715
+ mode: "full"
11716
+ };
11717
+ installSilentConsole();
11718
+ };
11719
+ const isJsonModeActive = () => context !== null;
11720
+ const setJsonReportDirectory = (directory) => {
11721
+ if (context) context.directory = directory;
11722
+ };
11723
+ const setJsonReportMode = (mode) => {
11724
+ if (context) context.mode = mode;
11725
+ };
11726
+ const writeJsonReport = (report) => {
11727
+ const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
11728
+ process.stdout.write(`${serialized}\n`);
11729
+ };
11730
+ const writeJsonErrorReport = (error) => {
11731
+ if (!context) return;
11732
+ try {
11733
+ writeJsonReport(buildJsonReportError({
11734
+ version: VERSION,
11735
+ directory: context.directory,
11736
+ error,
11737
+ elapsedMilliseconds: performance.now() - context.startTime,
11738
+ mode: context.mode
11739
+ }));
11740
+ } catch {
11741
+ process.stdout.write(INTERNAL_ERROR_JSON_FALLBACK);
11742
+ }
11743
+ };
11744
+ //#endregion
11745
+ //#region src/cli/utils/scrub-sensitive-text.ts
11746
+ const HOME_DIRECTORY = os.homedir();
11747
+ const USER_HOME_PATTERNS = [/[A-Za-z]:[\\/]Users[\\/][^\\/]+/gi, /(?:\/Users\/|\/home\/)[^/\\]+/gi];
11748
+ /**
11749
+ * Replaces the user's home directory (and generic `/Users|/home|C:\Users\<name>`
11750
+ * roots) with `~` so absolute paths can't be tied back to an individual. Keeps
11751
+ * the path's relative structure intact, which stays useful for debugging while
11752
+ * dropping the personally-identifying prefix. Idempotent — re-running on an
11753
+ * already-scrubbed `~/...` path is a no-op.
11754
+ */
11755
+ const scrubSensitivePaths = (text) => {
11756
+ let scrubbed = text;
11757
+ if (HOME_DIRECTORY.length > 1) scrubbed = scrubbed.split(HOME_DIRECTORY).join("~");
11758
+ for (const pattern of USER_HOME_PATTERNS) scrubbed = scrubbed.replace(pattern, "~");
11759
+ return scrubbed;
11760
+ };
11761
+ //#endregion
11762
+ //#region src/cli/utils/build-run-context.ts
11763
+ const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
11764
+ const detectInvokedVia = () => {
11765
+ const userAgent = process.env.npm_config_user_agent;
11766
+ if (!userAgent) return "unknown";
11767
+ return userAgent.split("/", 1)[0]?.trim() || "unknown";
11768
+ };
11769
+ const detectNodeMajor = () => {
11770
+ const major = Number.parseInt(process.versions.node.split(".", 1)[0] ?? "", 10);
11771
+ return Number.isNaN(major) ? 0 : major;
11772
+ };
11773
+ const detectOrigin = () => {
11774
+ if (process.env.GIT_DIR) return "git-hook";
11775
+ if (isCodingAgentEnvironment()) return "agent";
11776
+ if (isCiEnvironment()) return "ci";
11777
+ return "cli";
11778
+ };
11779
+ const detectCommand = (userArguments) => {
11780
+ for (const argument of userArguments) {
11781
+ if (argument === "--") break;
11782
+ if (argument.startsWith("-")) continue;
11783
+ return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
11784
+ }
11785
+ return "inspect";
11786
+ };
11787
+ /**
11788
+ * Snapshot of the current invocation, attached to Sentry events as the
11789
+ * `run` context to make crashes triage-able (which version, platform,
11790
+ * CI/agent, how it was invoked). Every field is cheap, synchronous, and
11791
+ * safe to read at any point — cwd reads fall back, env reads are
11792
+ * booleans — so it's rebuilt lazily at capture time when runtime-only
11793
+ * signals like `jsonMode` are finally known.
11794
+ */
11795
+ const buildRunContext = () => {
11796
+ const userArguments = process.argv.slice(2);
11797
+ return {
11798
+ version: VERSION,
11799
+ origin: detectOrigin(),
11800
+ command: detectCommand(userArguments),
11801
+ argv: scrubSensitivePaths(userArguments.join(" ")),
11802
+ cwd: scrubSensitivePaths(process.cwd()),
11803
+ node: process.version,
11804
+ nodeMajor: detectNodeMajor(),
11805
+ platform: process.platform,
11806
+ arch: process.arch,
11807
+ ci: isCiEnvironment(),
11808
+ ciProvider: detectCiProvider(),
11809
+ codingAgent: detectCodingAgent(),
11810
+ interactive: !isNonInteractiveEnvironment(),
11811
+ jsonMode: isJsonModeActive(),
11812
+ invokedVia: detectInvokedVia()
11813
+ };
11814
+ };
11815
+ //#endregion
11816
+ //#region src/cli/utils/build-sentry-project-context.ts
11817
+ /**
11818
+ * Projects the {@link ProjectInfo} we already detect during a scan into the
11819
+ * Sentry scope shape: a handful of searchable `project.*` tags plus the
11820
+ * anonymous project *shape* as a `project` context block. Lets crash/transaction
11821
+ * triage answer "which kind of project hit this?" (framework, React/Expo
11822
+ * version, TypeScript, size) without sending source code — and deliberately
11823
+ * omits `projectName` and `rootDirectory`, the two identifying fields, so the
11824
+ * project can't be tied back to a specific company/repo.
11825
+ */
11826
+ const buildSentryProjectContext = (projectInfo) => ({
11827
+ tags: {
11828
+ "project.framework": projectInfo.framework,
11829
+ "project.reactMajor": projectInfo.reactMajorVersion,
11830
+ "project.typescript": projectInfo.hasTypeScript,
11831
+ "project.reactCompiler": projectInfo.hasReactCompiler,
11832
+ "project.expo": projectInfo.expoVersion !== null,
11833
+ "project.reactNative": projectInfo.hasReactNativeWorkspace
11834
+ },
11835
+ context: {
11836
+ framework: projectInfo.framework,
11837
+ reactVersion: projectInfo.reactVersion,
11838
+ reactMajorVersion: projectInfo.reactMajorVersion,
11839
+ hasTypeScript: projectInfo.hasTypeScript,
11840
+ hasReactCompiler: projectInfo.hasReactCompiler,
11841
+ hasTanStackQuery: projectInfo.hasTanStackQuery,
11842
+ tailwindVersion: projectInfo.tailwindVersion,
11843
+ zodVersion: projectInfo.zodVersion,
11844
+ preactVersion: projectInfo.preactVersion,
11845
+ hasReactNativeWorkspace: projectInfo.hasReactNativeWorkspace,
11846
+ expoVersion: projectInfo.expoVersion,
11847
+ hasReanimated: projectInfo.hasReanimated,
11848
+ sourceFileCount: projectInfo.sourceFileCount
11849
+ }
11850
+ });
11851
+ let currentProjectInfo = null;
11852
+ const setSentryProjectInfo = (projectInfo) => {
11853
+ currentProjectInfo = projectInfo;
11854
+ };
11855
+ const getSentryProjectInfo = () => currentProjectInfo;
11856
+ //#endregion
11857
+ //#region src/cli/utils/build-sentry-scope.ts
11858
+ /**
11859
+ * Projects a {@link RunContext} snapshot (plus the current run's
11860
+ * {@link getSentryProjectInfo project info}, when a scan has discovered it) into
11861
+ * the Sentry scope shape — the searchable `tags` that make crashes/transactions
11862
+ * filterable (which command, origin, CI provider, coding agent, Node major,
11863
+ * package manager, project framework/React major) plus the full `run` and
11864
+ * `project` context blocks for deep triage.
11865
+ *
11866
+ * Shared by `instrument.ts` (seeded as `initialScope` so *every* event,
11867
+ * including performance transactions, carries it) and `report-error.ts` (a
11868
+ * capture-time refresh, since runtime-only signals like `jsonMode` and the
11869
+ * scanned project are only known once a command has begun).
11870
+ */
11871
+ const buildSentryScope = (runContext = buildRunContext()) => {
11872
+ const tags = {
11873
+ origin: runContext.origin,
11874
+ command: runContext.command,
11875
+ ci: runContext.ci,
11876
+ ciProvider: runContext.ciProvider,
11877
+ codingAgent: runContext.codingAgent,
11878
+ interactive: runContext.interactive,
11879
+ jsonMode: runContext.jsonMode,
11880
+ invokedVia: runContext.invokedVia,
11881
+ nodeMajor: runContext.nodeMajor
11882
+ };
11883
+ const contexts = { run: { ...runContext } };
11884
+ const projectInfo = getSentryProjectInfo();
11885
+ if (projectInfo) {
11886
+ const project = buildSentryProjectContext(projectInfo);
11887
+ Object.assign(tags, project.tags);
11888
+ contexts.project = project.context;
11889
+ }
11890
+ return {
11891
+ tags,
11892
+ contexts
11893
+ };
11894
+ };
11895
+ //#endregion
11896
+ //#region src/cli/utils/scrub-sentry-event.ts
11897
+ const anonymizeText = (text) => redactSensitiveText(scrubSensitivePaths(text));
11898
+ /**
11899
+ * Recursively rewrites every string within an arbitrary value (object / array /
11900
+ * primitive) through {@link anonymizeText}, mutating in place. Used to sweep the
11901
+ * unstructured corners of an event (contexts, extra, tags, breadcrumb data,
11902
+ * span attributes) where a path or secret could hide.
11903
+ */
11904
+ const anonymizeInPlace = (value) => {
11905
+ if (Array.isArray(value)) {
11906
+ for (let index = 0; index < value.length; index += 1) {
11907
+ const item = value[index];
11908
+ if (typeof item === "string") value[index] = anonymizeText(item);
11909
+ else anonymizeInPlace(item);
11910
+ }
11911
+ return;
11912
+ }
11913
+ if (!isPlainObject(value)) return;
11914
+ for (const key of Object.keys(value)) {
11915
+ const inner = value[key];
11916
+ if (typeof inner === "string") value[key] = anonymizeText(inner);
11917
+ else anonymizeInPlace(inner);
11918
+ }
11919
+ };
11920
+ /**
11921
+ * Anonymizes a Sentry event (error or transaction) before it leaves the
11922
+ * machine. Strips identity the SDK attaches automatically — the IP-bearing
11923
+ * `user`, the `server_name`, and the device `name` (all hostnames) — drops
11924
+ * captured local variables (unbounded, un-anonymizable user data), and scrubs
11925
+ * home-directory paths + known secrets/emails from every remaining string:
11926
+ * messages, stack frames, breadcrumbs, contexts/extra/tags, and span
11927
+ * attributes (e.g. the `inspect.directory` path on the bridged `runInspect`
11928
+ * span).
11929
+ *
11930
+ * Wired into both `beforeSend` and `beforeSendTransaction`. If scrubbing ever
11931
+ * throws, the event is dropped (`null`) rather than risk sending un-anonymized
11932
+ * data — telemetry is best-effort, privacy is not.
11933
+ */
11934
+ const scrubSentryEvent = (event) => {
11935
+ try {
11936
+ delete event.server_name;
11937
+ delete event.user;
11938
+ const device = event.contexts?.device;
11939
+ if (device) delete device.name;
11940
+ if (event.contexts) anonymizeInPlace(event.contexts);
11941
+ if (event.extra) anonymizeInPlace(event.extra);
11942
+ if (event.tags) anonymizeInPlace(event.tags);
11943
+ if (typeof event.message === "string") event.message = anonymizeText(event.message);
11944
+ for (const breadcrumb of event.breadcrumbs ?? []) {
11945
+ if (typeof breadcrumb.message === "string") breadcrumb.message = anonymizeText(breadcrumb.message);
11946
+ if (breadcrumb.data) anonymizeInPlace(breadcrumb.data);
11947
+ }
11948
+ for (const exception of event.exception?.values ?? []) {
11949
+ if (typeof exception.value === "string") exception.value = anonymizeText(exception.value);
11950
+ for (const frame of exception.stacktrace?.frames ?? []) {
11951
+ delete frame.vars;
11952
+ if (typeof frame.filename === "string") frame.filename = scrubSensitivePaths(frame.filename);
11953
+ if (typeof frame.abs_path === "string") frame.abs_path = scrubSensitivePaths(frame.abs_path);
11954
+ if (typeof frame.module === "string") frame.module = scrubSensitivePaths(frame.module);
11955
+ }
11956
+ }
11957
+ for (const span of event.spans ?? []) {
11958
+ if (typeof span.description === "string") span.description = anonymizeText(span.description);
11959
+ if (span.data) anonymizeInPlace(span.data);
11960
+ }
11961
+ return event;
11962
+ } catch {
11963
+ return null;
11964
+ }
11965
+ };
11966
+ //#endregion
11967
+ //#region src/instrument.ts
11968
+ let isInitialized = false;
11969
+ let resolvedTracesSampleRate = 0;
11970
+ const shouldEnableSentry = () => {
11971
+ if (process.argv.includes("--no-score") || process.argv.includes("--no-telemetry")) return false;
11972
+ if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
11973
+ return true;
11974
+ };
11975
+ const isEnvFlagEnabled = (value) => value === "1" || value?.toLowerCase() === "true";
11976
+ /**
11977
+ * A version is a "dev" build when it's the unbuilt placeholder (`0.0.0`) or
11978
+ * carries a prerelease suffix (e.g. the `-dev.<sha>` snapshots published from
11979
+ * CI). Everything else is a real, tagged release.
11980
+ */
11981
+ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
11982
+ /**
11983
+ * Sentry release identifier. `react-doctor@<version>` keeps it unique within
11984
+ * the org and — crucially — matches the value `scripts/sentry-sourcemaps.mjs`
11985
+ * uploads source-map artifacts under, so stack frames symbolicate. Honors the
11986
+ * standard `SENTRY_RELEASE` override.
11987
+ */
11988
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.2.14-dev.e9e71bb`;
11989
+ /**
11990
+ * Deployment environment shown in Sentry's environment filter. Defaults to
11991
+ * `production` for tagged releases and `development` for dev/unbuilt versions,
11992
+ * overridable via the standard `SENTRY_ENVIRONMENT` env var.
11993
+ */
11994
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.2.14-dev.e9e71bb") ? "development" : "production");
11995
+ /**
11996
+ * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
11997
+ * (set to `0` to disable tracing) and falls back to
11998
+ * {@link SENTRY_DEFAULT_TRACES_SAMPLE_RATE}. Invalid / out-of-range values fall
11999
+ * back to the default rather than silently disabling tracing.
12000
+ */
12001
+ const resolveTracesSampleRate = () => {
12002
+ const raw = process.env.SENTRY_TRACES_SAMPLE_RATE;
12003
+ if (raw === void 0 || raw.trim() === "") return 1;
12004
+ const parsed = Number(raw);
12005
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) return 1;
12006
+ return parsed;
12007
+ };
12008
+ /**
12009
+ * Whether performance traces will actually be recorded — Sentry is live and the
12010
+ * resolved sample rate is above zero. Used to gate the per-run root span and
12011
+ * the Effect→Sentry tracer bridge so they're true no-ops when tracing is off.
12012
+ */
12013
+ const isSentryTracingEnabled = () => Sentry.isInitialized() && resolvedTracesSampleRate > 0;
12014
+ /**
12015
+ * Flushes queued Sentry events (errors + transactions) before the CLI exits, so
12016
+ * the success-path transaction is delivered. A no-op when Sentry was never
12017
+ * initialized, and it swallows transport failures so telemetry can never mask
12018
+ * the user's result.
12019
+ */
12020
+ const flushSentry = async () => {
12021
+ if (!Sentry.isInitialized()) return;
12022
+ try {
12023
+ await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
12024
+ } catch {}
12025
+ };
12026
+ /**
12027
+ * Initializes the Sentry Node SDK for CLI crash reporting and performance
12028
+ * tracing. Invoked as the first statement of the CLI entry (`cli/index.ts`) so
12029
+ * the SDK's global `uncaughtException` / `unhandledRejection` handlers and OTel
12030
+ * auto-instrumentation are armed before any command runs.
12031
+ *
12032
+ * Exported as a function rather than a bare side-effecting import because the
12033
+ * package declares `"sideEffects": false`, which lets the bundler tree-shake
12034
+ * side-effect-only modules. An explicit call keeps the initialization in the
12035
+ * published `dist/cli.js`.
12036
+ *
12037
+ * Scoped to the CLI application only — the programmatic `@react-doctor/api`
12038
+ * library never initializes Sentry, so importing `diagnose()` into a consumer
12039
+ * app can't hijack their telemetry.
12040
+ *
12041
+ * Configuration is environment-overridable for self-hosting and tuning:
12042
+ * `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`,
12043
+ * `SENTRY_TRACES_SAMPLE_RATE` (`0` disables tracing), and `SENTRY_DEBUG`.
12044
+ */
12045
+ const initializeSentry = () => {
12046
+ if (isInitialized || !shouldEnableSentry()) return;
12047
+ isInitialized = true;
12048
+ resolvedTracesSampleRate = resolveTracesSampleRate();
12049
+ const { tags, contexts } = buildSentryScope();
12050
+ Sentry.init({
12051
+ dsn: process.env.SENTRY_DSN || "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920",
12052
+ release: resolveSentryRelease(),
12053
+ environment: resolveSentryEnvironment(),
12054
+ sendDefaultPii: false,
12055
+ tracesSampleRate: resolvedTracesSampleRate,
12056
+ debug: isEnvFlagEnabled(process.env.SENTRY_DEBUG),
12057
+ initialScope: {
12058
+ tags,
12059
+ contexts
12060
+ },
12061
+ beforeSend: (event) => scrubSentryEvent(event),
12062
+ beforeSendTransaction: (event) => scrubSentryEvent(event)
12063
+ });
12064
+ };
12065
+ //#endregion
12066
+ //#region src/cli/utils/sentry-tracer.ts
12067
+ const toHrTime = (epochNanoseconds) => [Number(epochNanoseconds / NANOSECONDS_PER_SECOND), Number(epochNanoseconds % NANOSECONDS_PER_SECOND)];
12068
+ const SPAN_KIND_TO_OTEL = {
12069
+ internal: 0,
12070
+ server: 1,
12071
+ client: 2,
12072
+ producer: 3,
12073
+ consumer: 4
12074
+ };
12075
+ const toSentryAttributeValue = (value) => {
12076
+ if (value === null || value === void 0) return void 0;
12077
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
12078
+ return String(value);
12079
+ };
12080
+ const normalizeAttributes = (attributes) => {
12081
+ const normalized = {};
12082
+ if (!attributes) return normalized;
12083
+ for (const [key, value] of Object.entries(attributes)) normalized[key] = toSentryAttributeValue(value);
12084
+ return normalized;
12085
+ };
12086
+ const isSentryBackedSpan = (span) => span._tag === "Span" && "sentrySpan" in span;
12087
+ const spanContextFor = (span) => isSentryBackedSpan(span) ? span.sentrySpan.spanContext() : {
12088
+ traceId: span.traceId,
12089
+ spanId: span.spanId,
12090
+ traceFlags: span.sampled ? 1 : 0
12091
+ };
12092
+ /**
12093
+ * Builds an Effect {@link Tracer.Tracer} that materializes every Effect span
12094
+ * (`Effect.withSpan(...)` / `Effect.fn("Service.method")`) as a child Sentry
12095
+ * span, producing one unified per-run trace in Sentry. The CLI already
12096
+ * instruments `runInspect` and each core service method, so this bridge lights
12097
+ * all of that up in Sentry for free.
12098
+ *
12099
+ * `rootSpan` is the active per-run transaction; Effect spans without an Effect
12100
+ * parent attach to it, so nesting is correct even if async-context propagation
12101
+ * is interrupted by Effect's fiber scheduler. Provided to a program via
12102
+ * `Effect.withTracer(...)`.
12103
+ */
12104
+ const makeSentryTracer = (rootSpan, startInactiveSpan = Sentry.startInactiveSpan) => Tracer.make({ span: (options) => {
12105
+ const parentSpan = Option.isSome(options.parent) && isSentryBackedSpan(options.parent.value) ? options.parent.value.sentrySpan : rootSpan;
12106
+ const sentrySpan = startInactiveSpan({
12107
+ name: options.name,
12108
+ startTime: toHrTime(options.startTime),
12109
+ parentSpan,
12110
+ kind: SPAN_KIND_TO_OTEL[options.kind]
12111
+ });
12112
+ const { traceId, spanId } = sentrySpan.spanContext();
12113
+ const attributes = /* @__PURE__ */ new Map();
12114
+ let status = {
12115
+ _tag: "Started",
12116
+ startTime: options.startTime
12117
+ };
12118
+ return {
12119
+ _tag: "Span",
12120
+ sentrySpan,
12121
+ name: options.name,
12122
+ spanId,
12123
+ traceId,
12124
+ parent: options.parent,
12125
+ annotations: options.annotations,
12126
+ links: options.links,
12127
+ sampled: options.sampled,
12128
+ kind: options.kind,
12129
+ get status() {
12130
+ return status;
12131
+ },
12132
+ get attributes() {
12133
+ return attributes;
12134
+ },
12135
+ end: (endTime, exit) => {
12136
+ status = {
12137
+ _tag: "Ended",
12138
+ startTime: options.startTime,
12139
+ endTime,
12140
+ exit
12141
+ };
12142
+ sentrySpan.setStatus({ code: Exit.isSuccess(exit) ? 1 : 2 });
12143
+ sentrySpan.end(toHrTime(endTime));
12144
+ },
12145
+ attribute: (key, value) => {
12146
+ attributes.set(key, value);
12147
+ sentrySpan.setAttribute(key, toSentryAttributeValue(value));
12148
+ },
12149
+ event: (name, startTime, eventAttributes) => {
12150
+ sentrySpan.addEvent(name, normalizeAttributes(eventAttributes), toHrTime(startTime));
12151
+ },
12152
+ addLinks: (links) => {
12153
+ for (const link of links) sentrySpan.addLink({
12154
+ context: spanContextFor(link.span),
12155
+ attributes: normalizeAttributes(link.attributes)
12156
+ });
12157
+ }
12158
+ };
12159
+ } });
12160
+ //#endregion
12161
+ //#region src/cli/utils/apply-observability.ts
12162
+ const isOtlpExportConfigured = () => Boolean(process.env.REACT_DOCTOR_OTLP_ENDPOINT) && Boolean(process.env.REACT_DOCTOR_OTLP_AUTH_HEADER);
12163
+ const externalSpanFrom = (sentrySpan) => {
12164
+ const { traceId, spanId, traceFlags } = sentrySpan.spanContext();
12165
+ return Tracer.externalSpan({
12166
+ traceId,
12167
+ spanId,
12168
+ sampled: (traceFlags & 1) === 1
12169
+ });
12170
+ };
12171
+ /**
12172
+ * Installs the tracing backend for the inspect program. Effect's tracer is a
12173
+ * single reference, so the backends are mutually exclusive — we pick by
12174
+ * precedence:
12175
+ *
12176
+ * 1. **User OTLP backend** (`REACT_DOCTOR_OTLP_*` set) wins; we additionally
12177
+ * parent the Effect trace under the active Sentry trace via an
12178
+ * `ExternalSpan` so a trace exported to the user's backend shares its
12179
+ * `trace_id` with the corresponding Sentry trace.
12180
+ * 2. **Sentry tracing active** (and no user OTLP): route Effect's existing
12181
+ * span instrumentation straight into Sentry as one unified per-run trace.
12182
+ * 3. **Neither**: provide the (no-op) OTLP layer, leaving Effect's native
12183
+ * in-memory tracer — identical to the prior default behavior.
12184
+ */
12185
+ const applyObservability = (program, rootSentrySpan) => {
12186
+ if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(Effect.provideService(Tracer.ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(Effect.provide(layerOtlp));
12187
+ if (rootSentrySpan) return program.pipe(Effect.withTracer(makeSentryTracer(rootSentrySpan)));
12188
+ return program.pipe(Effect.provide(layerOtlp));
12189
+ };
12190
+ //#endregion
12191
+ //#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
12192
+ const ANSI_BACKGROUND_OFFSET = 10;
12193
+ const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
12194
+ const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
12195
+ const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
12196
+ const styles$1 = {
12197
+ modifier: {
12198
+ reset: [0, 0],
12199
+ bold: [1, 22],
12200
+ dim: [2, 22],
12201
+ italic: [3, 23],
12202
+ underline: [4, 24],
12203
+ overline: [53, 55],
12204
+ inverse: [7, 27],
12205
+ hidden: [8, 28],
12206
+ strikethrough: [9, 29]
12207
+ },
12208
+ color: {
12209
+ black: [30, 39],
12210
+ red: [31, 39],
12211
+ green: [32, 39],
12212
+ yellow: [33, 39],
12213
+ blue: [34, 39],
12214
+ magenta: [35, 39],
12215
+ cyan: [36, 39],
12216
+ white: [37, 39],
12217
+ blackBright: [90, 39],
12218
+ gray: [90, 39],
12219
+ grey: [90, 39],
12220
+ redBright: [91, 39],
12221
+ greenBright: [92, 39],
12222
+ yellowBright: [93, 39],
12223
+ blueBright: [94, 39],
12224
+ magentaBright: [95, 39],
12225
+ cyanBright: [96, 39],
12226
+ whiteBright: [97, 39]
12227
+ },
12228
+ bgColor: {
12229
+ bgBlack: [40, 49],
12230
+ bgRed: [41, 49],
12231
+ bgGreen: [42, 49],
12232
+ bgYellow: [43, 49],
12233
+ bgBlue: [44, 49],
12234
+ bgMagenta: [45, 49],
12235
+ bgCyan: [46, 49],
12236
+ bgWhite: [47, 49],
12237
+ bgBlackBright: [100, 49],
12238
+ bgGray: [100, 49],
12239
+ bgGrey: [100, 49],
12240
+ bgRedBright: [101, 49],
12241
+ bgGreenBright: [102, 49],
12242
+ bgYellowBright: [103, 49],
12243
+ bgBlueBright: [104, 49],
12244
+ bgMagentaBright: [105, 49],
12245
+ bgCyanBright: [106, 49],
12246
+ bgWhiteBright: [107, 49]
12247
+ }
12248
+ };
12249
+ Object.keys(styles$1.modifier);
12250
+ const foregroundColorNames = Object.keys(styles$1.color);
12251
+ const backgroundColorNames = Object.keys(styles$1.bgColor);
11013
12252
  [...foregroundColorNames, ...backgroundColorNames];
11014
12253
  function assembleStyles() {
11015
12254
  const codes = /* @__PURE__ */ new Map();
@@ -13896,49 +15135,6 @@ function ora(options) {
13896
15135
  return new Ora(options);
13897
15136
  }
13898
15137
  //#endregion
13899
- //#region src/cli/utils/is-ci-environment.ts
13900
- const CI_ENVIRONMENT_VARIABLES = [
13901
- "GITHUB_ACTIONS",
13902
- "GITLAB_CI",
13903
- "CIRCLECI"
13904
- ];
13905
- const CODING_AGENT_ENVIRONMENT_VARIABLES = [
13906
- "CLAUDECODE",
13907
- "CLAUDE_CODE",
13908
- "CURSOR_AGENT",
13909
- "CODEX_CI",
13910
- "CODEX_SANDBOX",
13911
- "CODEX_SANDBOX_NETWORK_DISABLED",
13912
- "OPENCODE",
13913
- "GOOSE_TERMINAL",
13914
- "AGENT_SESSION_ID",
13915
- "AMP_THREAD_ID",
13916
- "AGENT_THREAD_ID"
13917
- ];
13918
- const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
13919
- const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
13920
- const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
13921
- 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));
13922
- const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
13923
- //#endregion
13924
- //#region src/cli/utils/is-non-interactive-environment.ts
13925
- const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
13926
- "CI",
13927
- "GITHUB_ACTIONS",
13928
- "GITLAB_CI",
13929
- "BUILDKITE",
13930
- "JENKINS_URL",
13931
- "TF_BUILD",
13932
- "CODEBUILD_BUILD_ID",
13933
- "TEAMCITY_VERSION",
13934
- "BITBUCKET_BUILD_NUMBER",
13935
- "CIRCLECI",
13936
- "TRAVIS",
13937
- "DRONE",
13938
- "GIT_DIR"
13939
- ];
13940
- const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
13941
- //#endregion
13942
15138
  //#region src/cli/utils/is-spinner-interactive.ts
13943
15139
  const isSpinnerInteractive = (stream = process.stderr) => {
13944
15140
  if (stream.isTTY !== true) return false;
@@ -14020,9 +15216,8 @@ const buildSpinnerProgressHandle = (text) => {
14020
15216
  * construction and post-scan rendering — layer wiring is its own
14021
15217
  * concern with its own contract.
14022
15218
  *
14023
- * Same shape as `core/src/run-inspect.tslayerInspectLive`
14024
- * (the default for `@react-doctor/api diagnose()`) with the
14025
- * differences specific to the CLI path:
15219
+ * Same service shape as `@react-doctor/apidiagnose()`'s
15220
+ * `buildDiagnoseLayer`, with the differences specific to the CLI path:
14026
15221
  *
14027
15222
  * - **Config**: when the caller passes `configOverride`, the
14028
15223
  * already-loaded config is provided via `Config.layerOf` instead
@@ -14048,47 +15243,93 @@ const buildRuntimeLayers = (input) => {
14048
15243
  resolvedDirectory: input.directory,
14049
15244
  configSourceDirectory: input.configSourceDirectory
14050
15245
  }) : Config.layerNode;
14051
- return Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
15246
+ const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
15247
+ return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
14052
15248
  };
14053
15249
  //#endregion
14054
- //#region src/cli/utils/noop-console.ts
15250
+ //#region src/cli/utils/active-run-trace.ts
15251
+ let activeRunTrace = null;
15252
+ const setActiveRunTrace = (trace) => {
15253
+ activeRunTrace = trace;
15254
+ };
15255
+ const getActiveRunTrace = () => activeRunTrace;
15256
+ //#endregion
15257
+ //#region src/cli/utils/to-span-attributes.ts
14055
15258
  /**
14056
- * A concrete `Console.Console` whose methods are all no-ops.
15259
+ * Converts a Sentry tag map (which permits `null` to denote an absent signal)
15260
+ * into Sentry/OTel span attributes, which only accept primitives. `null` values
15261
+ * are dropped rather than coerced, so an absent signal doesn't become a
15262
+ * misleading `"null"` attribute.
15263
+ */
15264
+ const toSpanAttributes = (tags) => {
15265
+ const attributes = {};
15266
+ for (const [key, value] of Object.entries(tags)) if (value !== null) attributes[key] = value;
15267
+ return attributes;
15268
+ };
15269
+ //#endregion
15270
+ //#region src/cli/utils/with-sentry-run-span.ts
15271
+ /**
15272
+ * Clears the module-level run-scoped Sentry state — the current scanned project
15273
+ * and the active run trace. `inspect()` calls this at the start of every run and
15274
+ * again after a clean one (it's invoked once per project in a workspace scan),
15275
+ * so a prior or just-finished scan can't attach its project tags / trace to a
15276
+ * later run or to a non-scan error (e.g. inspectAction's post-loop
15277
+ * finalize/handoff steps). A thrown scan error skips the post-run reset, leaving
15278
+ * the state for the command catch to attribute and link the crash. Safe to call
15279
+ * when Sentry is off (the refs are read only when an event is built).
15280
+ */
15281
+ const resetSentryRunState = () => {
15282
+ setSentryProjectInfo(null);
15283
+ setActiveRunTrace(null);
15284
+ };
15285
+ /**
15286
+ * Runs an inspect invocation inside a Sentry root span (transaction) so each
15287
+ * `react-doctor` run is a first-class trace with timing and the run snapshot as
15288
+ * attributes. The span is handed to `run` so the Effect→Sentry tracer bridge
15289
+ * can parent its spans under it.
14057
15290
  *
14058
- * Used by `--silent` (provided via
14059
- * `Effect.provideService(Console.Console, makeNoopConsole())`) and by
14060
- * `enableJsonMode` (assigned over the relevant slots on
14061
- * `globalThis.console` so imperative legacy callsites that aren't
14062
- * Effect-typed also fall silent). Sourcing both from a single concrete
14063
- * object keeps "what is a no-op console" answered in one place; the
14064
- * earlier `new Proxy({} as Console.Console, { get: () => () => undefined })`
14065
- * combined a cast with a Proxy to do the same thing implicitly.
15291
+ * A no-op pass-through when Sentry performance tracing is off (Sentry disabled,
15292
+ * `--no-score`, tests, or `SENTRY_TRACES_SAMPLE_RATE=0`) `run` receives
15293
+ * `undefined` and no transaction is created, so there's no added exit latency.
14066
15294
  *
14067
- * The interface mirrors Effect v4's `Console.Console` shape exactly so
14068
- * `Effect.provideService(Console.Console, makeNoopConsole())` requires
14069
- * no cast.
15295
+ * While the span runs, its trace context is recorded as the active run trace so
15296
+ * `reportErrorToSentry` can attach a crash thrown during the scan back to this
15297
+ * transaction's trace (errors surface in the command catch, after the span has
15298
+ * ended). `inspect()` owns clearing it (and the scanned project): it resets the
15299
+ * state right after a clean run and at the start of the next one, so the trace
15300
+ * is never attached to a non-scan error; on a thrown error the state is left in
15301
+ * place for the command catch, then the process exits.
14070
15302
  */
14071
- const makeNoopConsole = () => ({
14072
- assert: () => {},
14073
- clear: () => {},
14074
- count: () => {},
14075
- countReset: () => {},
14076
- debug: () => {},
14077
- dir: () => {},
14078
- dirxml: () => {},
14079
- error: () => {},
14080
- group: () => {},
14081
- groupCollapsed: () => {},
14082
- groupEnd: () => {},
14083
- info: () => {},
14084
- log: () => {},
14085
- table: () => {},
14086
- time: () => {},
14087
- timeEnd: () => {},
14088
- timeLog: () => {},
14089
- trace: () => {},
14090
- warn: () => {}
14091
- });
15303
+ const withSentryRunSpan = (run) => {
15304
+ if (!isSentryTracingEnabled()) return run(void 0);
15305
+ const { tags } = buildSentryScope();
15306
+ const command = typeof tags.command === "string" ? tags.command : "inspect";
15307
+ return Sentry.startSpan({
15308
+ name: `react-doctor ${command}`,
15309
+ op: "cli.inspect",
15310
+ attributes: toSpanAttributes(tags)
15311
+ }, (rootSpan) => {
15312
+ const spanContext = rootSpan.spanContext();
15313
+ setActiveRunTrace({
15314
+ traceId: spanContext.traceId,
15315
+ spanId: spanContext.spanId,
15316
+ sampled: (spanContext.traceFlags & 1) === 1
15317
+ });
15318
+ return run(rootSpan);
15319
+ });
15320
+ };
15321
+ /**
15322
+ * Records the scanned project (discovered in the `beforeLint` hook) for Sentry:
15323
+ * remembers it for the lazy error-capture path (`buildSentryScope` folds it into
15324
+ * exception events) and, when tracing is live, sets it as attributes on the
15325
+ * run's root span so the transaction/trace carries the project shape too.
15326
+ * Always cheap — the span attribute set is skipped when `rootSpan` is absent
15327
+ * (tracing off), and storing the info is a plain assignment.
15328
+ */
15329
+ const recordSentryProjectContext = (projectInfo, rootSpan) => {
15330
+ setSentryProjectInfo(projectInfo);
15331
+ rootSpan?.setAttributes(toSpanAttributes(buildSentryProjectContext(projectInfo).tags));
15332
+ };
14092
15333
  //#endregion
14093
15334
  //#region src/cli/utils/build-no-score-message.ts
14094
15335
  const ENTERPRISE_CONTACT_HINT = `Want something custom to your company? Contact us at ${ENTERPRISE_CONTACT_URL}.`;
@@ -14135,8 +15376,10 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
14135
15376
  return priorityB - priorityA;
14136
15377
  };
14137
15378
  const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
14138
- const FETCH_FIX_RECIPE_LABEL = "Fetch & follow the canonical fix recipe before fixing";
14139
- const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FETCH_FIX_RECIPE_LABEL}: ${buildRulePromptUrl(diagnostic.plugin, diagnostic.rule)}` : null;
15379
+ const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
15380
+ const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
15381
+ const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
15382
+ const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
14140
15383
  //#endregion
14141
15384
  //#region src/cli/utils/box-text.ts
14142
15385
  const ESCAPE = String.fromCharCode(27);
@@ -14267,15 +15510,17 @@ const buildVerboseSiteMap = (diagnostics) => {
14267
15510
  return fileSites;
14268
15511
  };
14269
15512
  const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
15513
+ const formatTrailingSiteBadge = (count) => {
15514
+ const badge = formatSiteCountBadge(count);
15515
+ return badge.length > 0 ? ` ${highlighter.gray(badge)}` : "";
15516
+ };
14270
15517
  const categoryTopRuleKey = (categoryGroup) => categoryGroup.ruleGroups[0][0];
14271
15518
  const buildCategoryDiagnosticGroups = (diagnostics, rulePriority) => {
14272
- return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
14273
- return {
14274
- category,
14275
- diagnostics: categoryDiagnostics,
14276
- ruleGroups: sortRuleGroupsByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority)
14277
- };
14278
- }).toSorted((categoryGroupA, categoryGroupB) => {
15519
+ return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => ({
15520
+ category,
15521
+ diagnostics: categoryDiagnostics,
15522
+ ruleGroups: buildSortedRuleGroups(categoryDiagnostics, rulePriority)
15523
+ })).toSorted((categoryGroupA, categoryGroupB) => {
14279
15524
  const priorityDelta = compareByRulePriority(categoryTopRuleKey(categoryGroupA), categoryTopRuleKey(categoryGroupB), rulePriority);
14280
15525
  if (priorityDelta !== 0) return priorityDelta;
14281
15526
  return categoryGroupA.category.localeCompare(categoryGroupB.category);
@@ -14291,6 +15536,7 @@ const buildCompactCategoryLine = (categoryGroup) => {
14291
15536
  };
14292
15537
  const TOP_ERROR_DETAIL_INDENT = " ";
14293
15538
  const pickRepresentativeDiagnostic = (ruleDiagnostics) => ruleDiagnostics.find((diagnostic) => diagnostic.line > 0) ?? ruleDiagnostics[0];
15539
+ const isErrorRuleGroup = (ruleDiagnostics) => pickRepresentativeDiagnostic(ruleDiagnostics).severity === "error";
14294
15540
  const FRAME_CONTEXT_REACH_LINES = 3;
14295
15541
  const clusterNearbyDiagnostics = (diagnostics) => {
14296
15542
  const byFile = groupBy(diagnostics, (diagnostic) => diagnostic.filePath);
@@ -14322,17 +15568,17 @@ const formatClusterLocation = (cluster) => {
14322
15568
  if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
14323
15569
  return `${filePath}:${cluster.startLine}`;
14324
15570
  };
14325
- const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
15571
+ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
14326
15572
  const lead = cluster.diagnostics[0];
14327
15573
  const isMultiSite = cluster.diagnostics.length > 1;
14328
15574
  const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
14329
- const codeFrame = buildCodeFrame({
15575
+ const codeFrame = renderCodeFrame ? buildCodeFrame({
14330
15576
  filePath: lead.filePath,
14331
15577
  line: cluster.startLine,
14332
15578
  column: isMultiSite ? 0 : lead.column,
14333
15579
  endLine: isMultiSite ? cluster.endLine : void 0,
14334
15580
  rootDirectory: resolveSourceRoot(lead)
14335
- });
15581
+ }) : null;
14336
15582
  if (codeFrame) lines.push(indentMultilineText(boxText(codeFrame, 60), TOP_ERROR_DETAIL_INDENT));
14337
15583
  const seenHints = /* @__PURE__ */ new Set();
14338
15584
  for (const diagnostic of cluster.diagnostics) if (diagnostic.suppressionHint && !seenHints.has(diagnostic.suppressionHint)) {
@@ -14344,23 +15590,60 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
14344
15590
  const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite) => {
14345
15591
  const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
14346
15592
  const { severity } = representative;
14347
- const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
14348
- const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
15593
+ const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
14349
15594
  const headline = colorizeBySeverity(`${representative.category}: ${representative.title ?? ruleKey}`, severity);
14350
15595
  const lines = [` ${colorizeBySeverity(severity === "error" ? "✗" : "⚠", severity)} ${headline}${trailingBadge}`];
14351
15596
  if (!renderEverySite) for (const explanationLine of wrapTextToWidth(representative.message, 60, { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
14352
15597
  if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, 60, { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
15598
+ const renderCodeFrame = severity === "error";
14353
15599
  const sites = renderEverySite ? ruleDiagnostics : [representative];
14354
- for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot));
15600
+ for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
15601
+ return lines;
15602
+ };
15603
+ const WARNING_DETAIL_INDENT = " ";
15604
+ const computeRuleNameColumnWidth = (ruleKeys) => ruleKeys.reduce((widest, ruleKey) => Math.max(widest, ruleKey.length), 36);
15605
+ const padRuleNameToColumn = (ruleName, columnWidth) => ruleName.length >= columnWidth ? ruleName : ruleName + " ".repeat(columnWidth - ruleName.length);
15606
+ const buildWarningHeaderLine = (ruleKey, siteCount, ruleNameColumnWidth) => {
15607
+ const ruleName = formatSiteCountBadge(siteCount).length > 0 ? padRuleNameToColumn(ruleKey, ruleNameColumnWidth) : ruleKey;
15608
+ return ` ${highlighter.warn("⚠")} ${ruleName}${formatTrailingSiteBadge(siteCount)}`;
15609
+ };
15610
+ const buildWarningRuleBlock = (ruleKey, ruleDiagnostics, ruleNameColumnWidth, isAgentEnvironment) => {
15611
+ const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
15612
+ const lines = [buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth)];
15613
+ if (!isAgentEnvironment) {
15614
+ const learnMoreLine = formatLearnMoreLine(representative);
15615
+ if (learnMoreLine) lines.push(`${WARNING_DETAIL_INDENT}${highlighter.info(learnMoreLine)}`);
15616
+ }
15617
+ lines.push(highlighter.gray(indentMultilineText(representative.message, WARNING_DETAIL_INDENT)));
15618
+ if (representative.help) lines.push(highlighter.gray(indentMultilineText(`→ ${representative.help}`, WARNING_DETAIL_INDENT)));
15619
+ if (isAgentEnvironment) {
15620
+ const fixRecipeLine = formatFixRecipeLine(representative);
15621
+ if (fixRecipeLine) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${fixRecipeLine}`));
15622
+ }
15623
+ for (const [filePath, sites] of buildVerboseSiteMap(ruleDiagnostics)) {
15624
+ if (sites.length === 0) {
15625
+ lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}`));
15626
+ continue;
15627
+ }
15628
+ for (const site of sites) {
15629
+ lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}:${site.line}`));
15630
+ if (site.suppressionHint) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT} ↳ ${site.suppressionHint}`));
15631
+ }
15632
+ }
14355
15633
  return lines;
14356
15634
  };
14357
- const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => {
14358
- return sortRuleGroupsByImportance([...groupBy(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority).slice(0, limit);
15635
+ const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
15636
+ const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => selectErrorRuleGroups(diagnostics, rulePriority).slice(0, limit);
15637
+ const buildMoreRulesLine = (hiddenRuleCount, severityNoun, accent) => {
15638
+ const ruleNoun = hiddenRuleCount === 1 ? "rule" : "rules";
15639
+ 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`)}`;
14359
15640
  };
14360
15641
  const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
14361
15642
  const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
14362
- const topRuleGroups = selectTopErrorRuleGroups(diagnostics, 3, rulePriority);
15643
+ const errorRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority);
15644
+ const topRuleGroups = errorRuleGroups.slice(0, 3);
14363
15645
  if (topRuleGroups.length === 0) return [];
15646
+ const hiddenRuleCount = errorRuleGroups.length - topRuleGroups.length;
14364
15647
  const lines = [
14365
15648
  highlighter.dim(` ${"─".repeat(60)}`),
14366
15649
  ` ${highlighter.bold(`Top ${topRuleGroups.length} ${topRuleGroups.length === 1 ? "error" : "errors"} you should fix`)}`,
@@ -14370,6 +15653,23 @@ const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
14370
15653
  lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false));
14371
15654
  lines.push("");
14372
15655
  }
15656
+ if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "errors", highlighter.error));
15657
+ return lines;
15658
+ };
15659
+ const buildWarningsListLines = (diagnostics, rulePriority) => {
15660
+ const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
15661
+ if (warningDiagnostics.length === 0) return [];
15662
+ const sortedRuleGroups = buildSortedRuleGroups(warningDiagnostics, rulePriority);
15663
+ const shownRuleGroups = sortedRuleGroups.slice(0, 10);
15664
+ const hiddenRuleCount = sortedRuleGroups.length - shownRuleGroups.length;
15665
+ const ruleNameColumnWidth = computeRuleNameColumnWidth(shownRuleGroups.map(([ruleKey]) => ruleKey));
15666
+ const lines = [
15667
+ highlighter.dim(` ${"─".repeat(60)}`),
15668
+ ` ${highlighter.bold(`${warningDiagnostics.length} ${warningDiagnostics.length === 1 ? "warning" : "warnings"}`)}`,
15669
+ ""
15670
+ ];
15671
+ for (const [ruleKey, ruleDiagnostics] of shownRuleGroups) lines.push(buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth));
15672
+ if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "warnings", highlighter.warn));
14373
15673
  return lines;
14374
15674
  };
14375
15675
  const buildCategoryBreakdownLines = (diagnostics, rulePriority) => buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCompactCategoryLine);
@@ -14396,12 +15696,18 @@ const buildCountsSummaryLines = (diagnostics) => {
14396
15696
  * single Effect.forEach over Console.log so failures or fiber
14397
15697
  * interruption produce predictable partial output.
14398
15698
  */
14399
- const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority) => Effect.gen(function* () {
15699
+ const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false) => Effect.gen(function* () {
14400
15700
  const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
14401
15701
  let detailLines;
14402
15702
  if (!isVerbose) detailLines = buildTopErrorsLines(diagnostics, resolveSourceRoot, rulePriority);
14403
- else detailLines = sortRuleGroupsByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority).flatMap(([ruleKey, ruleDiagnostics]) => [...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true), ""]);
14404
- const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines);
15703
+ else {
15704
+ const sortedRuleGroups = buildSortedRuleGroups(diagnostics, rulePriority);
15705
+ const warningRuleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.filter(([, ruleDiagnostics]) => !isErrorRuleGroup(ruleDiagnostics)).map(([ruleKey]) => ruleKey));
15706
+ detailLines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => {
15707
+ return [...isErrorRuleGroup(ruleDiagnostics) ? buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true) : buildWarningRuleBlock(ruleKey, ruleDiagnostics, warningRuleNameColumnWidth, isAgentEnvironment), ""];
15708
+ });
15709
+ }
15710
+ const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines, isVerbose ? [] : buildWarningsListLines(diagnostics, rulePriority));
14405
15711
  for (const line of lines) yield* Console.log(line);
14406
15712
  });
14407
15713
  const formatElapsedTime = (elapsedMilliseconds) => {
@@ -14451,10 +15757,6 @@ const colorizeByScore = (text, score) => {
14451
15757
  return highlighter.error(text);
14452
15758
  };
14453
15759
  //#endregion
14454
- //#region src/cli/utils/constants.ts
14455
- const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
14456
- const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
14457
- //#endregion
14458
15760
  //#region src/cli/utils/render-score-header.ts
14459
15761
  const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
14460
15762
  const RAINBOW_GRADIENT_WIDTH = 80;
@@ -14647,8 +15949,7 @@ const printNoScoreHeader = (noScoreMessage) => Effect.gen(function* () {
14647
15949
  const writeDiagnosticsDirectory = (diagnostics) => {
14648
15950
  const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
14649
15951
  mkdirSync(outputDirectory, { recursive: true });
14650
- const sortedRuleGroups = sortRuleGroupsByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
14651
- for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
15952
+ for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(diagnostics)) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
14652
15953
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
14653
15954
  return outputDirectory;
14654
15955
  };
@@ -14668,7 +15969,14 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
14668
15969
  };
14669
15970
  const printVerboseTip = (diagnostics, isVerbose) => Effect.gen(function* () {
14670
15971
  if (isVerbose || diagnostics.length === 0) return;
14671
- yield* Console.log(highlighter.dim(` Tip: Run ${highlighter.info("npx react-doctor@latest --verbose")} to list every issue`));
15972
+ const command = highlighter.info("npx react-doctor@latest --verbose");
15973
+ 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`;
15974
+ yield* Console.log(highlighter.dim(` Tip: ${message}`));
15975
+ });
15976
+ const printDocsNote = () => Effect.gen(function* () {
15977
+ yield* Console.log("");
15978
+ yield* Console.log(` ${highlighter.bold("Docs:")} ${highlighter.info(DOCS_URL)}`);
15979
+ yield* Console.log(highlighter.dim(" Set up CI/CD, suppress rules with a config file, and scan diffs or PRs."));
14672
15980
  });
14673
15981
  const printSummary = (input) => Effect.gen(function* () {
14674
15982
  if (input.scoreResult) {
@@ -14839,9 +16147,6 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
14839
16147
  });
14840
16148
  const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
14841
16149
  //#endregion
14842
- //#region src/cli/utils/version.ts
14843
- const VERSION = "0.2.14-dev.daef23c";
14844
- //#endregion
14845
16150
  //#region src/inspect.ts
14846
16151
  const silentConsole = makeNoopConsole();
14847
16152
  const runConsole = (effect) => {
@@ -14866,14 +16171,16 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
14866
16171
  customRulesOnly: userConfig?.customRulesOnly ?? false,
14867
16172
  share: userConfig?.share ?? true,
14868
16173
  respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
14869
- warnings: inputOptions.warnings ?? userConfig?.warnings ?? false,
16174
+ warnings: inputOptions.warnings ?? userConfig?.warnings ?? true,
14870
16175
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
14871
16176
  ignoredTags: buildIgnoredTags(userConfig),
14872
16177
  outputSurface: inputOptions.outputSurface ?? "cli",
14873
- suppressRendering: inputOptions.suppressRendering ?? false
16178
+ suppressRendering: inputOptions.suppressRendering ?? false,
16179
+ concurrency: inputOptions.concurrency
14874
16180
  });
14875
16181
  const inspect = async (directory, inputOptions = {}) => {
14876
16182
  const startTime = performance.now();
16183
+ resetSentryRunState();
14877
16184
  const hasConfigOverride = inputOptions.configOverride !== void 0;
14878
16185
  let scanDirectory;
14879
16186
  let userConfig;
@@ -14883,7 +16190,7 @@ const inspect = async (directory, inputOptions = {}) => {
14883
16190
  userConfig = inputOptions.configOverride ?? null;
14884
16191
  configSourceDirectory = null;
14885
16192
  } else {
14886
- const scanTarget = resolveScanTarget(directory);
16193
+ const scanTarget = await resolveScanTarget(directory);
14887
16194
  scanDirectory = scanTarget.resolvedDirectory;
14888
16195
  userConfig = scanTarget.userConfig;
14889
16196
  configSourceDirectory = scanTarget.configSourceDirectory;
@@ -14892,12 +16199,14 @@ const inspect = async (directory, inputOptions = {}) => {
14892
16199
  const wasSpinnerSilent = isSpinnerSilent();
14893
16200
  if (options.silent) setSpinnerSilent(true);
14894
16201
  try {
14895
- return await runInspectWithRuntime(scanDirectory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime);
16202
+ const result = await withSentryRunSpan((rootSentrySpan) => runInspectWithRuntime(scanDirectory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, rootSentrySpan));
16203
+ resetSentryRunState();
16204
+ return result;
14896
16205
  } finally {
14897
16206
  if (options.silent) setSpinnerSilent(wasSpinnerSilent);
14898
16207
  }
14899
16208
  };
14900
- const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime) => {
16209
+ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, rootSentrySpan) => {
14901
16210
  const isDiffMode = options.includePaths.length > 0;
14902
16211
  const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
14903
16212
  const lintBindingMissing = options.lint && !resolvedNodeBinaryPath;
@@ -14910,7 +16219,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14910
16219
  shouldSkipLint: !options.lint || lintBindingMissing,
14911
16220
  shouldRunDeadCode: options.deadCode,
14912
16221
  shouldComputeScore: !options.noScore,
14913
- shouldShowProgressSpinners
16222
+ shouldShowProgressSpinners,
16223
+ oxlintConcurrency: options.concurrency
14914
16224
  });
14915
16225
  const program = runInspect({
14916
16226
  directory,
@@ -14927,6 +16237,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14927
16237
  resolveLocalGithubViewerPermission: !options.noScore,
14928
16238
  suppressScanSummary: options.suppressRendering
14929
16239
  }, { beforeLint: (projectInfo, lintIncludePaths) => Effect.gen(function* () {
16240
+ recordSentryProjectContext(projectInfo, rootSentrySpan);
14930
16241
  if (options.scoreOnly || options.suppressRendering) return;
14931
16242
  const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
14932
16243
  yield* printProjectDetection({
@@ -14937,7 +16248,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14937
16248
  lintSourceFileCount
14938
16249
  });
14939
16250
  }) });
14940
- const programWithLayers = options.silent ? program.pipe(Effect.provide(layers), Effect.provideService(Console.Console, silentConsole), Effect.provide(layerOtlp)) : program.pipe(Effect.provide(layers), Effect.provide(layerOtlp));
16251
+ const programWithLayers = applyObservability(options.silent ? program.pipe(Effect.provide(layers), Effect.provideService(Console.Console, silentConsole)) : program.pipe(Effect.provide(layers)), rootSentrySpan);
14941
16252
  const output = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
14942
16253
  const didLintFail = lintBindingMissing || output.didLintFail;
14943
16254
  const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
@@ -14966,15 +16277,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
14966
16277
  };
14967
16278
  const finalizeAndRender = (input) => Effect.gen(function* () {
14968
16279
  const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
14969
- const skippedChecks = [];
14970
- if (didLintFail) skippedChecks.push("lint");
14971
- if (didDeadCodeFail) skippedChecks.push("dead-code");
16280
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
16281
+ didLintFail,
16282
+ lintFailureReason,
16283
+ lintPartialFailures,
16284
+ didDeadCodeFail,
16285
+ deadCodeFailureReason
16286
+ });
14972
16287
  const hasSkippedChecks = skippedChecks.length > 0;
14973
16288
  const noScoreMessage = buildNoScoreMessage(options.noScore);
14974
- const skippedCheckReasons = {};
14975
- if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
14976
- else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
14977
- if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
14978
16289
  const buildResult = () => ({
14979
16290
  diagnostics: [...diagnostics],
14980
16291
  score,
@@ -15010,7 +16321,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
15010
16321
  return buildResult();
15011
16322
  }
15012
16323
  yield* Console.log("");
15013
- yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]));
16324
+ yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment());
15014
16325
  if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
15015
16326
  if (demotedDiagnosticCount > 0) {
15016
16327
  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.`));
@@ -15035,6 +16346,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
15035
16346
  yield* Console.warn(highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`));
15036
16347
  }
15037
16348
  yield* printVerboseTip([...surfaceDiagnostics], options.verbose);
16349
+ yield* printDocsNote();
15038
16350
  return buildResult();
15039
16351
  });
15040
16352
  //#endregion
@@ -15079,7 +16391,7 @@ const getErrorReportContext = () => ({
15079
16391
  isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE])
15080
16392
  });
15081
16393
  const formatConfiguredState = (isConfigured) => isConfigured ? "yes" : "no";
15082
- const buildErrorIssueBody = (error, context) => {
16394
+ const buildErrorIssueBody = (error, context, sentryEventId) => {
15083
16395
  const formattedError = formatErrorForReport(error) || "(empty error)";
15084
16396
  const isOtlpExporterEnabled = context.isOtlpEndpointConfigured && context.isOtlpAuthHeaderConfigured;
15085
16397
  return [
@@ -15096,6 +16408,7 @@ const buildErrorIssueBody = (error, context) => {
15096
16408
  `- platform: ${context.platform} ${context.architecture}`,
15097
16409
  `- cwd: ${context.cwd}`,
15098
16410
  `- command: ${context.command}`,
16411
+ ...sentryEventId ? [`- Sentry reference: ${sentryEventId}`] : [],
15099
16412
  "",
15100
16413
  "## OpenTelemetry",
15101
16414
  "",
@@ -15109,12 +16422,12 @@ const buildErrorIssueBody = (error, context) => {
15109
16422
  "Please add reproduction steps and any relevant repository details."
15110
16423
  ].join("\n");
15111
16424
  };
15112
- const buildErrorIssueUrl = (error) => {
16425
+ const buildErrorIssueUrl = (error, sentryEventId) => {
15113
16426
  const formattedError = formatSingleLine(formatErrorForReport(error));
15114
16427
  const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
15115
16428
  issueUrl.searchParams.set("title", formattedError ? `CLI error: ${formattedError}` : "CLI error");
15116
16429
  issueUrl.searchParams.set("labels", "bug");
15117
- issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext()));
16430
+ issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext(), sentryEventId));
15118
16431
  return issueUrl.toString();
15119
16432
  };
15120
16433
  /**
@@ -15124,10 +16437,12 @@ const buildErrorIssueUrl = (error) => {
15124
16437
  * red-highlighted (matches the historical `consoleLogger.error`
15125
16438
  * contract) so the user sees a clearly distinguished error block.
15126
16439
  */
15127
- const handleErrorEffect = (error) => Effect.gen(function* () {
16440
+ const handleErrorEffect = (error, sentryEventId) => Effect.gen(function* () {
15128
16441
  yield* Console.error("");
15129
16442
  yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
15130
- yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
16443
+ yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error, sentryEventId)}`));
16444
+ yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
16445
+ if (sentryEventId) yield* Console.error(highlighter.error(`Reference (mention this when reporting): ${sentryEventId}`));
15131
16446
  yield* Console.error("");
15132
16447
  yield* Console.error(highlighter.error(formatErrorForReport(error)));
15133
16448
  yield* Console.error("");
@@ -15137,15 +16452,15 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
15137
16452
  * aren't yet Effect-typed). Bridges via `Effect.runSync` so the
15138
16453
  * underlying Console writes happen exactly like the Effect path.
15139
16454
  */
15140
- const handleError = (error, options = { shouldExit: true }) => {
15141
- Effect.runSync(handleErrorEffect(error));
16455
+ const handleError = (error, options = {}) => {
16456
+ Effect.runSync(handleErrorEffect(error, options.sentryEventId));
15142
16457
  if (options.shouldExit !== false) process.exit(1);
15143
16458
  process.exitCode = 1;
15144
16459
  };
15145
16460
  //#endregion
15146
16461
  //#region src/cli/utils/build-handoff-payload.ts
15147
16462
  const buildHandoffPayload = (input) => {
15148
- const topGroups = sortRuleGroupsByImportance([...groupBy([...input.diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)]).slice(0, 3);
16463
+ const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
15149
16464
  let diagnosticsDirectory = null;
15150
16465
  try {
15151
16466
  diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
@@ -15388,7 +16703,7 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
15388
16703
  const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
15389
16704
  const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
15390
16705
  const CURSOR_HOOKS_SCHEMA_VERSION = 1;
15391
- const JSON_INDENT_SPACES = 2;
16706
+ const JSON_INDENT_SPACES$1 = 2;
15392
16707
  const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
15393
16708
  const readJsonFile = (filePath, fallback) => {
15394
16709
  if (!existsSync(filePath)) return fallback;
@@ -15398,7 +16713,7 @@ const readJsonFile = (filePath, fallback) => {
15398
16713
  };
15399
16714
  const writeJsonFile = (filePath, value) => {
15400
16715
  mkdirSync(path.dirname(filePath), { recursive: true });
15401
- writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES)}\n`);
16716
+ writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
15402
16717
  };
15403
16718
  const writeHookScript = (filePath) => {
15404
16719
  mkdirSync(path.dirname(filePath), { recursive: true });
@@ -16164,6 +17479,29 @@ const getSkillSourceDirectory = () => {
16164
17479
  const distDirectory = path.dirname(fileURLToPath(import.meta.url));
16165
17480
  return path.join(distDirectory, "skills", SKILL_NAME);
16166
17481
  };
17482
+ const findBundledSiblingSkills = (primarySkillDir) => {
17483
+ const skillsParent = path.dirname(primarySkillDir);
17484
+ if (!existsSync(skillsParent)) return [];
17485
+ const resolvedPrimary = path.resolve(primarySkillDir);
17486
+ return readdirSync(skillsParent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => ({
17487
+ name: entry.name,
17488
+ source: path.join(skillsParent, entry.name)
17489
+ })).filter((sibling) => path.resolve(sibling.source) !== resolvedPrimary && existsSync(path.join(sibling.source, SKILL_MANIFEST_FILE)));
17490
+ };
17491
+ const installBundledSiblingSkills = async (primarySkillDir, agents, projectRoot) => {
17492
+ const installedSkillNames = [];
17493
+ for (const sibling of findBundledSiblingSkills(primarySkillDir)) {
17494
+ const result = await installSkillsFromSource({
17495
+ source: sibling.source,
17496
+ agents: [...agents],
17497
+ cwd: projectRoot,
17498
+ mode: "copy"
17499
+ });
17500
+ if (result.failed.length > 0) throw new Error(result.failed.map((failure) => `${getSkillAgentConfig(failure.agent).displayName}: ${failure.error}`).join("\n"));
17501
+ if (result.skills.length > 0) installedSkillNames.push(sibling.name);
17502
+ }
17503
+ return installedSkillNames;
17504
+ };
16167
17505
  const canInstallNativeAgentHooks = (agents) => agents.some((agent) => agent === "claude-code" || agent === "cursor");
16168
17506
  const buildWorkflowContent = () => [
16169
17507
  "name: React Doctor",
@@ -16270,6 +17608,7 @@ const runInstallReactDoctor = async (options = {}) => {
16270
17608
  cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
16271
17609
  for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
16272
17610
  cliLogger.dim(` Source: ${sourceDir}`);
17611
+ for (const sibling of findBundledSiblingSkills(sourceDir)) cliLogger.dim(` Also installs skill: ${sibling.name}`);
16273
17612
  cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
16274
17613
  cliLogger.dim(" Dev dependency: react-doctor");
16275
17614
  if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
@@ -16292,6 +17631,12 @@ const runInstallReactDoctor = async (options = {}) => {
16292
17631
  installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
16293
17632
  throw error;
16294
17633
  }
17634
+ try {
17635
+ const installedSiblingSkills = await installBundledSiblingSkills(sourceDir, selectedAgents, projectRoot);
17636
+ if (installedSiblingSkills.length > 0) cliLogger.dim(` Also installed the ${installedSiblingSkills.join(", ")} skill.`);
17637
+ } catch {
17638
+ cliLogger.dim(" Skipped bundled sibling skills (install error).");
17639
+ }
16295
17640
  await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
16296
17641
  if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
16297
17642
  const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
@@ -16451,101 +17796,94 @@ const handoffToAgent = async (input) => {
16451
17796
  projectName: input.projectName
16452
17797
  });
16453
17798
  if (handoffTarget === PRINT_CHOICE) {
16454
- printPayload(payload);
16455
- return;
16456
- }
16457
- if (handoffTarget === CLIPBOARD_CHOICE) {
16458
- if (await copyToClipboard(payload)) cliLogger.log("Copied the prompt to your clipboard.");
16459
- else printPayload(payload);
16460
- return;
16461
- }
16462
- const agentId = handoffTarget;
16463
- const displayName = getSkillAgentConfig(agentId).displayName;
16464
- const skillSpinner = spinner(`Installing the /react-doctor skill for ${displayName}...`).start();
16465
- try {
16466
- if (await installReactDoctorSkillForAgent(agentId, input.rootDirectory)) skillSpinner.succeed(`Installed the /react-doctor skill for ${displayName}.`);
16467
- else skillSpinner.stop();
16468
- } catch {
16469
- skillSpinner.stop();
16470
- }
16471
- cliLogger.log(highlighter.dim(`Handing off to ${displayName}...`));
16472
- try {
16473
- await launchCliAgent(agentId, payload, input.rootDirectory);
16474
- } catch {
16475
- cliLogger.warn(`Couldn't launch ${CLI_AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
16476
- printPayload(payload);
16477
- }
16478
- };
16479
- //#endregion
16480
- //#region src/cli/utils/json-mode.ts
16481
- let context = null;
16482
- /**
16483
- * JSON mode writes the report payload to stdout; any incidental log
16484
- * line printed by an Effect program would corrupt the JSON. Effect's
16485
- * `Console` module resolves to `globalThis.console` by default (see
16486
- * `effect/internal/effect.ts` → `ConsoleRef`), so copying the methods
16487
- * from `makeNoopConsole()` onto the global is enough to silence every
16488
- * `yield* Console.log(...)` and `cliLogger.*` call sourced from
16489
- * react-doctor or its services.
16490
- *
16491
- * We use the same `makeNoopConsole()` source as the `--silent` path
16492
- * (which provides the Effect Console via
16493
- * `Effect.provideService(Console.Console, makeNoopConsole())`) — one
16494
- * canonical "no-op console" definition shared by the two silent
16495
- * mechanisms. The two routes still differ in how they install the
16496
- * noop: silent mode swaps the Effect Console reference inside the
16497
- * program; JSON mode patches the global because the surrounding CLI
16498
- * command body is still imperative. Both will collapse into the
16499
- * Effect-typed route once the command body finishes its migration.
16500
- *
16501
- * JSON mode is one-shot per CLI invocation, so we never restore.
16502
- */
16503
- const installSilentConsole = () => {
16504
- const noopConsole = makeNoopConsole();
16505
- const target = globalThis.console;
16506
- const source = noopConsole;
16507
- for (const key of [
16508
- "log",
16509
- "error",
16510
- "warn",
16511
- "info",
16512
- "debug",
16513
- "trace"
16514
- ]) target[key] = source[key];
16515
- };
16516
- const enableJsonMode = ({ compact, directory }) => {
16517
- context = {
16518
- compact,
16519
- directory,
16520
- startTime: performance.now(),
16521
- mode: "full"
16522
- };
16523
- installSilentConsole();
16524
- };
16525
- const isJsonModeActive = () => context !== null;
16526
- const setJsonReportDirectory = (directory) => {
16527
- if (context) context.directory = directory;
16528
- };
16529
- const setJsonReportMode = (mode) => {
16530
- if (context) context.mode = mode;
16531
- };
16532
- const writeJsonReport = (report) => {
16533
- const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
16534
- process.stdout.write(`${serialized}\n`);
17799
+ printPayload(payload);
17800
+ return;
17801
+ }
17802
+ if (handoffTarget === CLIPBOARD_CHOICE) {
17803
+ if (await copyToClipboard(payload)) cliLogger.log("Copied the prompt to your clipboard.");
17804
+ else printPayload(payload);
17805
+ return;
17806
+ }
17807
+ const agentId = handoffTarget;
17808
+ const displayName = getSkillAgentConfig(agentId).displayName;
17809
+ const skillSpinner = spinner(`Installing the /react-doctor skill for ${displayName}...`).start();
17810
+ try {
17811
+ if (await installReactDoctorSkillForAgent(agentId, input.rootDirectory)) skillSpinner.succeed(`Installed the /react-doctor skill for ${displayName}.`);
17812
+ else skillSpinner.stop();
17813
+ } catch {
17814
+ skillSpinner.stop();
17815
+ }
17816
+ cliLogger.log(highlighter.dim(`Handing off to ${displayName}...`));
17817
+ try {
17818
+ await launchCliAgent(agentId, payload, input.rootDirectory);
17819
+ } catch {
17820
+ cliLogger.warn(`Couldn't launch ${CLI_AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
17821
+ printPayload(payload);
17822
+ }
16535
17823
  };
16536
- const writeJsonErrorReport = (error) => {
16537
- if (!context) return;
17824
+ //#endregion
17825
+ //#region src/cli/utils/read-object-file.ts
17826
+ /**
17827
+ * Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
17828
+ * unparseable, or not an object. JSON5 parsing tolerates comments and
17829
+ * trailing commas so hand-edited config files round-trip.
17830
+ */
17831
+ const readObjectFile = (filePath) => {
16538
17832
  try {
16539
- writeJsonReport(buildJsonReportError({
16540
- version: VERSION,
16541
- directory: context.directory,
16542
- error,
16543
- elapsedMilliseconds: performance.now() - context.startTime,
16544
- mode: context.mode
16545
- }));
17833
+ const parsed = parseJSON5(readFileSync(filePath, "utf-8"));
17834
+ return isPlainObject(parsed) ? parsed : null;
16546
17835
  } catch {
16547
- process.stdout.write(INTERNAL_ERROR_JSON_FALLBACK);
17836
+ return null;
17837
+ }
17838
+ };
17839
+ //#endregion
17840
+ //#region src/cli/utils/serialize-ts-object-literal.ts
17841
+ const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
17842
+ const INDENT_UNIT = " ";
17843
+ const serializeKey = (key) => SAFE_IDENTIFIER_PATTERN.test(key) ? key : JSON.stringify(key);
17844
+ /**
17845
+ * Serializes a JSON-compatible value as an idiomatic TypeScript literal:
17846
+ * identifier-shaped object keys stay unquoted, two-space indented, no blank
17847
+ * lines. Intended for JSON-sourced config values (string / number / boolean /
17848
+ * null / array / plain object); any other type falls back to its JSON form.
17849
+ */
17850
+ const serializeTsObjectLiteral = (value, depth = 0) => {
17851
+ const indent = INDENT_UNIT.repeat(depth);
17852
+ const childIndent = INDENT_UNIT.repeat(depth + 1);
17853
+ if (Array.isArray(value)) {
17854
+ if (value.length === 0) return "[]";
17855
+ return `[\n${value.map((item) => `${childIndent}${serializeTsObjectLiteral(item, depth + 1)}`).join(",\n")}\n${indent}]`;
17856
+ }
17857
+ if (isPlainObject(value)) {
17858
+ const keys = Object.keys(value);
17859
+ if (keys.length === 0) return "{}";
17860
+ return `{\n${keys.map((key) => `${childIndent}${serializeKey(key)}: ${serializeTsObjectLiteral(value[key], depth + 1)}`).join(",\n")}\n${indent}}`;
16548
17861
  }
17862
+ return JSON.stringify(value);
17863
+ };
17864
+ //#endregion
17865
+ //#region src/cli/utils/migrate-legacy-config.ts
17866
+ const MIGRATED_CONFIG_FILENAME = "doctor.config.ts";
17867
+ /**
17868
+ * Renames a pre-migration `react-doctor.config.json` to a typed
17869
+ * `doctor.config.ts`, preserving the user's settings as the default export.
17870
+ * `$schema` is dropped — the `ReactDoctorConfig` type supersedes it for
17871
+ * editor autocomplete. Returns the new file's absolute path, or `null` when
17872
+ * the legacy file can't be parsed as an object (left untouched so the user
17873
+ * can resolve it by hand).
17874
+ */
17875
+ const migrateLegacyConfig = (legacy) => {
17876
+ const parsed = readObjectFile(legacy.legacyFilePath);
17877
+ if (!parsed) return null;
17878
+ const config = { ...parsed };
17879
+ delete config.$schema;
17880
+ const targetPath = path.join(legacy.directory, MIGRATED_CONFIG_FILENAME);
17881
+ writeFileSync(targetPath, `import type { ReactDoctorConfig } from "react-doctor/api";
17882
+
17883
+ export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
17884
+ `);
17885
+ rmSync(legacy.legacyFilePath, { force: true });
17886
+ return targetPath;
16549
17887
  };
16550
17888
  //#endregion
16551
17889
  //#region src/cli/utils/annotation-encoding.ts
@@ -16577,6 +17915,45 @@ const printBrandedHeader = Effect.gen(function* () {
16577
17915
  yield* Console.log("");
16578
17916
  });
16579
17917
  //#endregion
17918
+ //#region src/cli/utils/report-error.ts
17919
+ /**
17920
+ * Sends an error to Sentry — enriched with a fresh snapshot of the current run
17921
+ * (version, platform, CI/agent, invocation, scanned project) and, when a run
17922
+ * transaction is in flight, linked to its trace via the scope's propagation
17923
+ * context so the crash and its transaction share a `trace_id` — then waits for
17924
+ * delivery before the caller exits. The CLI tears down synchronously after
17925
+ * rendering an error, so the awaited `flush` is what actually gets the event
17926
+ * (and any in-flight transaction) off the machine.
17927
+ *
17928
+ * Returns the Sentry event id so the caller can surface it as a reference the
17929
+ * user can quote when reporting the bug; returns `undefined` when Sentry was
17930
+ * never initialized (`--no-score`, tests, or a missing DSN) or delivery failed.
17931
+ * Swallows any transport failure so telemetry can never mask the user's
17932
+ * original error.
17933
+ */
17934
+ const reportErrorToSentry = async (error) => {
17935
+ if (!Sentry.isInitialized()) return void 0;
17936
+ try {
17937
+ const { tags, contexts } = buildSentryScope();
17938
+ const runTrace = getActiveRunTrace();
17939
+ const eventId = Sentry.withScope((scope) => {
17940
+ for (const [name, context] of Object.entries(contexts)) scope.setContext(name, context);
17941
+ scope.setTags(tags);
17942
+ if (runTrace) scope.setPropagationContext({
17943
+ traceId: runTrace.traceId,
17944
+ parentSpanId: runTrace.spanId,
17945
+ sampled: runTrace.sampled,
17946
+ sampleRand: Math.random()
17947
+ });
17948
+ return Sentry.captureException(error);
17949
+ });
17950
+ await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
17951
+ return eventId;
17952
+ } catch {
17953
+ return;
17954
+ }
17955
+ };
17956
+ //#endregion
16580
17957
  //#region src/cli/utils/path-format.ts
16581
17958
  const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
16582
17959
  //#endregion
@@ -16644,7 +18021,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
16644
18021
  yield* Console.log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalScanElapsedMilliseconds)}`);
16645
18022
  if (surfaceDiagnostics.length > 0) {
16646
18023
  yield* Console.log("");
16647
- yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)));
18024
+ yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment());
16648
18025
  }
16649
18026
  const lowestScoredScan = findLowestScoredScan(completedScans);
16650
18027
  const aggregateScore = lowestScoredScan?.result.score ?? null;
@@ -16674,6 +18051,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
16674
18051
  for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
16675
18052
  yield* Console.log("");
16676
18053
  yield* printVerboseTip(surfaceDiagnostics, verbose);
18054
+ yield* printDocsNote();
16677
18055
  });
16678
18056
  //#endregion
16679
18057
  //#region src/cli/utils/prompt-install-setup.ts
@@ -16721,6 +18099,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
16721
18099
  for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
16722
18100
  };
16723
18101
  //#endregion
18102
+ //#region src/cli/utils/resolve-parallel-flag.ts
18103
+ /**
18104
+ * Translates the `--experimental-parallel [workers]` flag into a concrete
18105
+ * worker count for `InspectOptions.concurrency`:
18106
+ *
18107
+ * - flag absent (`undefined`) → `undefined` (defer to the ambient
18108
+ * default: serial unless `REACT_DOCTOR_PARALLEL` is set)
18109
+ * - bare flag / `auto` → auto-detect CPU cores
18110
+ * - `--experimental-parallel <n>` → `n` workers (clamped)
18111
+ * - `false` / `off` / `0` → serial (an explicit opt-out, so
18112
+ * it overrides an env-enabled default rather than deferring to it)
18113
+ * - an unparseable value → auto-detect cores
18114
+ *
18115
+ * Commander yields `true` for a bare flag, the raw string for an explicit
18116
+ * value, and `undefined` when the flag is omitted.
18117
+ */
18118
+ const resolveParallelFlag = (parallel) => {
18119
+ if (parallel === void 0) return void 0;
18120
+ if (parallel === true) return resolveScanConcurrency("auto");
18121
+ if (parallel === false) return 1;
18122
+ const normalized = parallel.trim().toLowerCase();
18123
+ if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
18124
+ if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
18125
+ const parsed = Number.parseInt(normalized, 10);
18126
+ if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
18127
+ return resolveScanConcurrency(parsed);
18128
+ };
18129
+ //#endregion
16724
18130
  //#region src/cli/utils/resolve-cli-inspect-options.ts
16725
18131
  /**
16726
18132
  * Translates CLI flags into the `InspectOptions` contract `inspect()`
@@ -16743,10 +18149,11 @@ const resolveCliInspectOptions = (flags, userConfig) => {
16743
18149
  respectInlineDisables: flags.respectInlineDisables,
16744
18150
  warnings: flags.warnings ?? (wantsWarningGate ? true : void 0),
16745
18151
  scoreOnly: flags.score === true,
16746
- noScore: flags.score === false || (userConfig?.noScore ?? false),
18152
+ noScore: flags.score === false || flags.telemetry === false || (userConfig?.noScore ?? false),
16747
18153
  isCi: isCiEnvironment(),
16748
18154
  silent: Boolean(flags.json),
16749
- outputSurface: flags.prComment ? "prComment" : "cli"
18155
+ outputSurface: flags.prComment ? "prComment" : "cli",
18156
+ concurrency: resolveParallelFlag(flags.experimentalParallel)
16750
18157
  };
16751
18158
  };
16752
18159
  //#endregion
@@ -17011,6 +18418,7 @@ const validateModeFlags = (flags) => {
17011
18418
  if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
17012
18419
  if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
17013
18420
  if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
18421
+ if (flags.score && flags.telemetry === false) throw new Error("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
17014
18422
  if (flags.prComment && (flags.json || flags.score)) throw new Error("--pr-comment cannot be combined with --json or --score.");
17015
18423
  if (flags.annotations && flags.score) throw new Error("--annotations cannot be combined with --score.");
17016
18424
  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.");
@@ -17044,6 +18452,24 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
17044
18452
  changedFiles,
17045
18453
  isCurrentChanges: false
17046
18454
  });
18455
+ /**
18456
+ * On an interactive human run, rename a pre-migration
18457
+ * `react-doctor.config.json` to `doctor.config.ts` before config is loaded,
18458
+ * so the scan reads the renamed file and the user is told once. CI, coding
18459
+ * agents, JSON/score output, pre-commit (`--staged`) hooks, and non-TTY runs
18460
+ * are left untouched — the loader's warning still nudges them — so a scan
18461
+ * never mutates the repo unattended.
18462
+ */
18463
+ const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
18464
+ if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
18465
+ const legacyConfig = findLegacyConfig(requestedDirectory);
18466
+ if (!legacyConfig) return;
18467
+ const migratedPath = migrateLegacyConfig(legacyConfig);
18468
+ if (!migratedPath) return;
18469
+ cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
18470
+ cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
18471
+ cliLogger.break();
18472
+ };
17047
18473
  const inspectAction = async (directory, flags) => {
17048
18474
  const isScoreOnly = Boolean(flags.score);
17049
18475
  const isJsonMode = Boolean(flags.json);
@@ -17056,7 +18482,11 @@ const inspectAction = async (directory, flags) => {
17056
18482
  });
17057
18483
  try {
17058
18484
  validateModeFlags(flags);
17059
- const scanTarget = resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
18485
+ maybeMigrateLegacyConfig(requestedDirectory, {
18486
+ isQuiet,
18487
+ isStaged: Boolean(flags.staged)
18488
+ });
18489
+ const scanTarget = await resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
17060
18490
  const userConfig = scanTarget.userConfig;
17061
18491
  const resolvedDirectory = scanTarget.resolvedDirectory;
17062
18492
  setJsonReportDirectory(resolvedDirectory);
@@ -17230,12 +18660,13 @@ const inspectAction = async (directory, flags) => {
17230
18660
  })) printAgentInstallHint();
17231
18661
  }
17232
18662
  } catch (error) {
18663
+ const sentryEventId = await reportErrorToSentry(error);
17233
18664
  if (isJsonMode) {
17234
18665
  writeJsonErrorReport(error);
17235
18666
  process.exitCode = 1;
17236
18667
  return;
17237
18668
  }
17238
- handleError(error);
18669
+ handleError(error, { sentryEventId });
17239
18670
  }
17240
18671
  };
17241
18672
  //#endregion
@@ -17251,8 +18682,570 @@ const installAction = async (options, command) => {
17251
18682
  projectRoot: options.cwd ?? process.cwd()
17252
18683
  });
17253
18684
  } catch (error) {
17254
- handleError(error);
18685
+ handleError(error, { sentryEventId: await reportErrorToSentry(error) });
18686
+ }
18687
+ };
18688
+ //#endregion
18689
+ //#region src/cli/utils/rule-catalog.ts
18690
+ const buildRuleCatalog = () => REACT_DOCTOR_RULES.map((entry) => ({
18691
+ key: entry.key,
18692
+ id: entry.id,
18693
+ category: entry.rule.category ?? "Other",
18694
+ defaultSeverity: entry.rule.severity,
18695
+ framework: entry.rule.framework ?? "global",
18696
+ tags: entry.rule.tags ?? [],
18697
+ recommendation: entry.rule.recommendation,
18698
+ defaultEnabled: entry.rule.defaultEnabled !== false
18699
+ }));
18700
+ /**
18701
+ * Resolves a user-supplied rule reference to a catalog entry. Accepts the
18702
+ * fully-qualified key (`react-doctor/no-danger`), the bare id (`no-danger`),
18703
+ * and legacy plugin keys (`react/no-danger`) via the shared alias map.
18704
+ */
18705
+ const findRuleInCatalog = (catalog, ruleQuery) => {
18706
+ const normalizedQuery = ruleQuery.trim();
18707
+ if (normalizedQuery.length === 0) return void 0;
18708
+ const directMatch = catalog.find((entry) => entry.key === normalizedQuery || entry.id === normalizedQuery);
18709
+ if (directMatch) return directMatch;
18710
+ return catalog.find((entry) => isSameRuleKey(entry.key, normalizedQuery));
18711
+ };
18712
+ const listRuleCategories = (catalog) => [...new Set(catalog.map((entry) => entry.category))].sort();
18713
+ const listRuleTags = (catalog) => [...new Set(catalog.flatMap((entry) => [...entry.tags]))].sort();
18714
+ //#endregion
18715
+ //#region src/cli/utils/render-rule-catalog.ts
18716
+ const SEVERITY_COLUMN_WIDTH_CHARS = 6;
18717
+ const colorizeSeverity = (severity, text) => {
18718
+ if (severity === "error") return highlighter.error(text);
18719
+ if (severity === "warn") return highlighter.warn(text);
18720
+ return highlighter.gray(text);
18721
+ };
18722
+ const formatSourceNote = (effective) => effective.source === "default" ? highlighter.dim("(default)") : highlighter.dim(`(${effective.source})`);
18723
+ const renderRuleCatalog = (rows) => {
18724
+ if (rows.length === 0) return highlighter.dim("No rules match the given filters.");
18725
+ const rowsByCategory = /* @__PURE__ */ new Map();
18726
+ for (const row of rows) {
18727
+ const bucket = rowsByCategory.get(row.entry.category) ?? [];
18728
+ bucket.push(row);
18729
+ rowsByCategory.set(row.entry.category, bucket);
18730
+ }
18731
+ const lines = [];
18732
+ for (const category of [...rowsByCategory.keys()].sort()) {
18733
+ const categoryRows = (rowsByCategory.get(category) ?? []).sort((leftRow, rightRow) => leftRow.entry.key.localeCompare(rightRow.entry.key));
18734
+ lines.push(highlighter.bold(`${category} ${highlighter.dim(`(${categoryRows.length})`)}`));
18735
+ for (const row of categoryRows) {
18736
+ const severityBadge = colorizeSeverity(row.effective.value, row.effective.value.padEnd(SEVERITY_COLUMN_WIDTH_CHARS));
18737
+ const tagSuffix = row.entry.tags.length > 0 ? highlighter.dim(` [${row.entry.tags.join(", ")}]`) : "";
18738
+ lines.push(` ${severityBadge} ${row.entry.key} ${formatSourceNote(row.effective)}${tagSuffix}`);
18739
+ }
18740
+ lines.push("");
18741
+ }
18742
+ lines.push(highlighter.dim(`${rows.length} rule${rows.length === 1 ? "" : "s"} shown.`));
18743
+ return lines.join("\n");
18744
+ };
18745
+ const DETAIL_LABEL_COLUMN_WIDTH_CHARS = 18;
18746
+ const formatDetailRow = (label, value) => ` ${highlighter.dim(label.padEnd(DETAIL_LABEL_COLUMN_WIDTH_CHARS))}${value}`;
18747
+ const renderRuleExplanation = (row) => {
18748
+ const { entry, effective } = row;
18749
+ const lines = [highlighter.bold(entry.key), ""];
18750
+ lines.push(formatDetailRow("Category", entry.category));
18751
+ lines.push(formatDetailRow("Default severity", entry.defaultSeverity));
18752
+ lines.push(formatDetailRow("Current severity", `${colorizeSeverity(effective.value, effective.value)} ${formatSourceNote(effective)}`));
18753
+ lines.push(formatDetailRow("Framework", entry.framework));
18754
+ lines.push(formatDetailRow("Tags", entry.tags.length > 0 ? entry.tags.join(", ") : "none"));
18755
+ lines.push(formatDetailRow("Default enabled", entry.defaultEnabled ? "yes" : "no (opt-in)"));
18756
+ lines.push("");
18757
+ lines.push(highlighter.bold("Why it matters"));
18758
+ lines.push(` ${entry.recommendation ?? "No additional guidance recorded for this rule yet."}`);
18759
+ lines.push("");
18760
+ lines.push(highlighter.bold("Configure"));
18761
+ lines.push(highlighter.dim(` react-doctor rules disable ${entry.key}`));
18762
+ lines.push(highlighter.dim(` react-doctor rules enable ${entry.key} --severity error`));
18763
+ lines.push(highlighter.dim(` react-doctor rules set ${entry.key} warn`));
18764
+ lines.push("");
18765
+ lines.push(highlighter.bold("Learn more"));
18766
+ lines.push(highlighter.dim(` ${buildRuleDocsUrl("react-doctor", entry.id)}`));
18767
+ return lines.join("\n");
18768
+ };
18769
+ //#endregion
18770
+ //#region src/cli/utils/rule-config-file.ts
18771
+ const NEW_CONFIG_FILENAME = "doctor.config.json";
18772
+ const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
18773
+ const JSON_INDENT_SPACES = 2;
18774
+ const MANAGED_KEYS = [
18775
+ "rules",
18776
+ "categories",
18777
+ "ignore"
18778
+ ];
18779
+ /**
18780
+ * Decides where a rule-config mutation should be written. Discovery
18781
+ * reuses `loadConfigWithSource` (the loader the scan uses) so edits land
18782
+ * in the file the scan reads — `doctor.config.{ts,js,…}` is preferred,
18783
+ * then `package.json#reactDoctor`. When nothing exists, a fresh
18784
+ * `doctor.config.json` is targeted at `projectRoot`. Data configs are
18785
+ * re-read raw so unrelated fields round-trip untouched.
18786
+ */
18787
+ const resolveRuleConfigTarget = async (projectRoot) => {
18788
+ clearConfigCache();
18789
+ const loaded = await loadConfigWithSource(projectRoot);
18790
+ if (loaded) {
18791
+ if (loaded.format === "package-json") {
18792
+ const embedded = (readObjectFile(loaded.configFilePath) ?? {})[PACKAGE_JSON_CONFIG_KEY];
18793
+ return {
18794
+ format: "package-json",
18795
+ filePath: loaded.configFilePath,
18796
+ directory: loaded.sourceDirectory,
18797
+ exists: true,
18798
+ config: isPlainObject(embedded) ? embedded : {}
18799
+ };
18800
+ }
18801
+ if (loaded.format === "json") return {
18802
+ format: "json",
18803
+ filePath: loaded.configFilePath,
18804
+ directory: loaded.sourceDirectory,
18805
+ exists: true,
18806
+ config: readObjectFile(loaded.configFilePath) ?? {}
18807
+ };
18808
+ return {
18809
+ format: "module",
18810
+ filePath: loaded.configFilePath,
18811
+ directory: loaded.sourceDirectory,
18812
+ exists: true,
18813
+ config: loaded.config
18814
+ };
18815
+ }
18816
+ return {
18817
+ format: "json",
18818
+ filePath: path.join(projectRoot, NEW_CONFIG_FILENAME),
18819
+ directory: projectRoot,
18820
+ exists: false,
18821
+ config: {}
18822
+ };
18823
+ };
18824
+ const writeJsonConfig = (filePath, nextConfig) => {
18825
+ const { $schema, ...rest } = nextConfig;
18826
+ writeFileSync(filePath, `${JSON.stringify({
18827
+ $schema: $schema ?? "https://react.doctor/schema/config.json",
18828
+ ...rest
18829
+ }, null, JSON_INDENT_SPACES)}\n`);
18830
+ };
18831
+ const writePackageJsonConfig = (filePath, nextConfig) => {
18832
+ const packageJson = readObjectFile(filePath) ?? {};
18833
+ writeFileSync(filePath, `${JSON.stringify({
18834
+ ...packageJson,
18835
+ [PACKAGE_JSON_CONFIG_KEY]: nextConfig
18836
+ }, null, JSON_INDENT_SPACES)}\n`);
18837
+ };
18838
+ const syncManagedKeys = (target, nextConfig) => {
18839
+ for (const key of MANAGED_KEYS) {
18840
+ const value = nextConfig[key];
18841
+ if (value === void 0) {
18842
+ if (target[key] !== void 0) delete target[key];
18843
+ } else target[key] = value;
18844
+ }
18845
+ };
18846
+ const assignNodeSource = (owner, key, code) => {
18847
+ owner[key] = code;
18848
+ };
18849
+ const editVariableDeclarationConfig = (declaration, config, nextConfig) => {
18850
+ syncManagedKeys(config, nextConfig);
18851
+ const initializer = declaration.init;
18852
+ if (!initializer) return false;
18853
+ const generatedSource = generateCode(config).code;
18854
+ if (initializer.type === "ObjectExpression") {
18855
+ assignNodeSource(declaration, "init", generatedSource);
18856
+ return true;
18857
+ }
18858
+ if (initializer.type === "TSSatisfiesExpression" && initializer.expression.type === "ObjectExpression") {
18859
+ assignNodeSource(initializer, "expression", generatedSource);
18860
+ return true;
18861
+ }
18862
+ return false;
18863
+ };
18864
+ const writeModuleConfig = async (filePath, nextConfig) => {
18865
+ try {
18866
+ const module = await loadFile(filePath);
18867
+ if (module.exports.default?.$type === "identifier") {
18868
+ const { declaration, config } = getConfigFromVariableDeclaration(module);
18869
+ if (!config || !editVariableDeclarationConfig(declaration, config, nextConfig)) return false;
18870
+ } else syncManagedKeys(getDefaultExportOptions(module), nextConfig);
18871
+ await writeFile(module, filePath);
18872
+ return true;
18873
+ } catch {
18874
+ return false;
18875
+ }
18876
+ };
18877
+ const writeRuleConfig = async (target, nextConfig) => {
18878
+ if (target.format === "module") {
18879
+ const written = await writeModuleConfig(target.filePath, nextConfig);
18880
+ if (written) clearConfigCache();
18881
+ return { written };
18882
+ }
18883
+ if (target.format === "package-json") writePackageJsonConfig(target.filePath, nextConfig);
18884
+ else writeJsonConfig(target.filePath, nextConfig);
18885
+ clearConfigCache();
18886
+ return { written: true };
18887
+ };
18888
+ //#endregion
18889
+ //#region src/cli/utils/resolve-effective-rule-severity.ts
18890
+ /**
18891
+ * Resolves what a rule will actually do under the current config without
18892
+ * running a scan. `ignore.tags` is a pre-lint gate: a rule carrying an
18893
+ * ignored tag is dropped (via `shouldEnableRule`) before any severity is
18894
+ * read, so it wins over every override. Among rules that survive the gate,
18895
+ * the scanner's order is `rules` > `categories` > `buckets` > the registry
18896
+ * default.
18897
+ */
18898
+ const resolveEffectiveRuleSeverity = (config, entry) => {
18899
+ const ignoredTags = config?.ignore?.tags ?? [];
18900
+ if (entry.tags.some((tag) => ignoredTags.includes(tag))) return {
18901
+ value: "off",
18902
+ source: "tag"
18903
+ };
18904
+ const ruleOverrides = config?.rules ?? {};
18905
+ for (const equivalentKey of getEquivalentRuleKeys(entry.key)) {
18906
+ const override = ruleOverrides[equivalentKey];
18907
+ if (override !== void 0) return {
18908
+ value: override,
18909
+ source: "rule"
18910
+ };
17255
18911
  }
18912
+ const categoryOverride = config?.categories?.[entry.category];
18913
+ if (categoryOverride !== void 0) return {
18914
+ value: categoryOverride,
18915
+ source: "category"
18916
+ };
18917
+ if (COMPILER_CLEANUP_RULE_KEYS.has(entry.key)) {
18918
+ const bucketOverride = config?.buckets?.[COMPILER_CLEANUP_BUCKET];
18919
+ if (bucketOverride !== void 0) return {
18920
+ value: bucketOverride,
18921
+ source: "bucket"
18922
+ };
18923
+ }
18924
+ return {
18925
+ value: entry.defaultEnabled ? entry.defaultSeverity : "off",
18926
+ source: "default"
18927
+ };
18928
+ };
18929
+ //#endregion
18930
+ //#region src/cli/utils/update-rule-config.ts
18931
+ /**
18932
+ * Sets a per-rule severity, replacing any existing entry for the same
18933
+ * rule (including legacy-aliased keys, so a config still targeting
18934
+ * `react/no-danger` is rewritten to the canonical key instead of
18935
+ * leaving a dead duplicate).
18936
+ */
18937
+ const setRuleSeverity = (config, ruleKey, severity) => {
18938
+ const equivalentKeys = new Set(getEquivalentRuleKeys(ruleKey));
18939
+ const nextRules = {};
18940
+ for (const [existingKey, existingSeverity] of Object.entries(config.rules ?? {})) if (!equivalentKeys.has(existingKey)) nextRules[existingKey] = existingSeverity;
18941
+ nextRules[ruleKey] = severity;
18942
+ return {
18943
+ ...config,
18944
+ rules: nextRules
18945
+ };
18946
+ };
18947
+ const setCategorySeverity = (config, category, severity) => ({
18948
+ ...config,
18949
+ categories: {
18950
+ ...config.categories,
18951
+ [category]: severity
18952
+ }
18953
+ });
18954
+ const addIgnoredTag = (config, tag) => {
18955
+ const currentTags = config.ignore?.tags ?? [];
18956
+ if (currentTags.includes(tag)) return config;
18957
+ return {
18958
+ ...config,
18959
+ ignore: {
18960
+ ...config.ignore,
18961
+ tags: [...new Set([...currentTags, tag])].sort()
18962
+ }
18963
+ };
18964
+ };
18965
+ const removeIgnoredTag = (config, tag) => {
18966
+ const currentTags = config.ignore?.tags ?? [];
18967
+ if (!currentTags.includes(tag)) return config;
18968
+ const remainingTags = currentTags.filter((existingTag) => existingTag !== tag);
18969
+ const { tags: _removed, ...remainingIgnore } = config.ignore ?? {};
18970
+ if (remainingTags.length === 0) {
18971
+ if (Object.keys(remainingIgnore).length === 0) {
18972
+ const { ignore: _ignore, ...configWithoutIgnore } = config;
18973
+ return configWithoutIgnore;
18974
+ }
18975
+ return {
18976
+ ...config,
18977
+ ignore: remainingIgnore
18978
+ };
18979
+ }
18980
+ return {
18981
+ ...config,
18982
+ ignore: {
18983
+ ...remainingIgnore,
18984
+ tags: remainingTags
18985
+ }
18986
+ };
18987
+ };
18988
+ //#endregion
18989
+ //#region src/cli/commands/rules.ts
18990
+ const SEVERITY_VALUES = [
18991
+ "off",
18992
+ "warn",
18993
+ "error"
18994
+ ];
18995
+ const resolveProjectRoot = (options) => {
18996
+ const requestedDirectory = path.resolve(options.cwd ?? process.cwd());
18997
+ return findNearestPackageDirectory(requestedDirectory) ?? requestedDirectory;
18998
+ };
18999
+ const parseSeverity = (value) => SEVERITY_VALUES.includes(value) ? value : null;
19000
+ const reportInvalidSeverity = (value) => {
19001
+ cliLogger.error(`Invalid severity "${value}". Expected one of: ${SEVERITY_VALUES.join(", ")}.`);
19002
+ process.exitCode = 1;
19003
+ };
19004
+ const reportRuleNotFound = (ruleQuery) => {
19005
+ cliLogger.error(`Unknown rule "${ruleQuery}".`);
19006
+ cliLogger.dim(" Run `react-doctor rules list` to see every available rule.");
19007
+ process.exitCode = 1;
19008
+ };
19009
+ const describeTargetPath = (target) => {
19010
+ const relativePath = path.relative(process.cwd(), target.filePath);
19011
+ const displayPath = relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : target.filePath;
19012
+ return target.exists ? displayPath : `${displayPath} ${highlighter.dim("(created)")}`;
19013
+ };
19014
+ const applyConfigChange = async (options, change) => {
19015
+ const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
19016
+ const nextConfig = change(target.config);
19017
+ const { written } = await writeRuleConfig(target, nextConfig);
19018
+ return {
19019
+ target,
19020
+ nextConfig,
19021
+ written
19022
+ };
19023
+ };
19024
+ const reportManualEdit = (target, nextConfig) => {
19025
+ const managed = {};
19026
+ for (const key of [
19027
+ "rules",
19028
+ "categories",
19029
+ "ignore"
19030
+ ]) if (nextConfig[key] !== void 0) managed[key] = nextConfig[key];
19031
+ cliLogger.error(`Couldn't automatically edit ${describeTargetPath(target)} (dynamic config).`);
19032
+ cliLogger.dim(" Apply this to your config's default export, then re-run:");
19033
+ for (const line of JSON.stringify(managed, null, 2).split("\n")) cliLogger.dim(` ${line}`);
19034
+ process.exitCode = 1;
19035
+ };
19036
+ const rulesListAction = async (options) => {
19037
+ const catalog = buildRuleCatalog();
19038
+ const config = validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config);
19039
+ const categoryFilter = options.category?.toLowerCase();
19040
+ const frameworkFilter = options.framework?.toLowerCase();
19041
+ const rows = catalog.filter((entry) => {
19042
+ if (categoryFilter && entry.category.toLowerCase() !== categoryFilter) return false;
19043
+ if (frameworkFilter && entry.framework.toLowerCase() !== frameworkFilter) return false;
19044
+ if (options.tag && !entry.tags.includes(options.tag)) return false;
19045
+ return true;
19046
+ }).map((entry) => ({
19047
+ entry,
19048
+ effective: resolveEffectiveRuleSeverity(config, entry)
19049
+ })).filter((row) => options.configured ? row.effective.source !== "default" : true);
19050
+ if (options.json) {
19051
+ const payload = rows.map((row) => ({
19052
+ key: row.entry.key,
19053
+ id: row.entry.id,
19054
+ category: row.entry.category,
19055
+ framework: row.entry.framework,
19056
+ tags: row.entry.tags,
19057
+ defaultSeverity: row.entry.defaultSeverity,
19058
+ defaultEnabled: row.entry.defaultEnabled,
19059
+ severity: row.effective.value,
19060
+ source: row.effective.source
19061
+ }));
19062
+ cliLogger.log(JSON.stringify(payload, null, 2));
19063
+ return;
19064
+ }
19065
+ cliLogger.log(renderRuleCatalog(rows));
19066
+ };
19067
+ const rulesExplainAction = async (ruleQuery, options) => {
19068
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
19069
+ if (!entry) {
19070
+ reportRuleNotFound(ruleQuery);
19071
+ return;
19072
+ }
19073
+ const effective = resolveEffectiveRuleSeverity(validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config), entry);
19074
+ if (options.json) {
19075
+ cliLogger.log(JSON.stringify({
19076
+ key: entry.key,
19077
+ id: entry.id,
19078
+ category: entry.category,
19079
+ framework: entry.framework,
19080
+ tags: entry.tags,
19081
+ defaultSeverity: entry.defaultSeverity,
19082
+ defaultEnabled: entry.defaultEnabled,
19083
+ severity: effective.value,
19084
+ source: effective.source,
19085
+ recommendation: entry.recommendation ?? null,
19086
+ learnMoreUrl: buildRuleDocsUrl("react-doctor", entry.id)
19087
+ }, null, 2));
19088
+ return;
19089
+ }
19090
+ cliLogger.log(renderRuleExplanation({
19091
+ entry,
19092
+ effective
19093
+ }));
19094
+ };
19095
+ const setRuleSeverityAndReport = async (entry, severity, options) => {
19096
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => setRuleSeverity(config, entry.key, severity));
19097
+ if (!written) {
19098
+ reportManualEdit(target, nextConfig);
19099
+ return;
19100
+ }
19101
+ cliLogger.success(`Set ${entry.key} → ${severity}`);
19102
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
19103
+ };
19104
+ const rulesSetAction = async (ruleQuery, severityValue, options) => {
19105
+ const severity = parseSeverity(severityValue);
19106
+ if (!severity) {
19107
+ reportInvalidSeverity(severityValue);
19108
+ return;
19109
+ }
19110
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
19111
+ if (!entry) {
19112
+ reportRuleNotFound(ruleQuery);
19113
+ return;
19114
+ }
19115
+ await setRuleSeverityAndReport(entry, severity, options);
19116
+ };
19117
+ const rulesEnableAction = async (ruleQuery, options) => {
19118
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
19119
+ if (!entry) {
19120
+ reportRuleNotFound(ruleQuery);
19121
+ return;
19122
+ }
19123
+ if (options.severity === void 0) {
19124
+ await setRuleSeverityAndReport(entry, entry.defaultSeverity, options);
19125
+ return;
19126
+ }
19127
+ const severity = parseSeverity(options.severity);
19128
+ if (!severity) {
19129
+ reportInvalidSeverity(options.severity);
19130
+ return;
19131
+ }
19132
+ if (severity === "off") {
19133
+ cliLogger.error("`enable` cannot set a rule to off. Use `react-doctor rules disable` instead.");
19134
+ process.exitCode = 1;
19135
+ return;
19136
+ }
19137
+ await setRuleSeverityAndReport(entry, severity, options);
19138
+ };
19139
+ const rulesDisableAction = async (ruleQuery, options) => {
19140
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
19141
+ if (!entry) {
19142
+ reportRuleNotFound(ruleQuery);
19143
+ return;
19144
+ }
19145
+ await setRuleSeverityAndReport(entry, "off", options);
19146
+ };
19147
+ const rulesCategoryAction = async (categoryQuery, severityValue, options) => {
19148
+ const severity = parseSeverity(severityValue);
19149
+ if (!severity) {
19150
+ reportInvalidSeverity(severityValue);
19151
+ return;
19152
+ }
19153
+ const knownCategories = listRuleCategories(buildRuleCatalog());
19154
+ const matchedCategory = knownCategories.find((category) => category.toLowerCase() === categoryQuery.toLowerCase());
19155
+ if (!matchedCategory) {
19156
+ cliLogger.error(`Unknown category "${categoryQuery}".`);
19157
+ cliLogger.dim(` Known categories: ${knownCategories.join(", ")}`);
19158
+ process.exitCode = 1;
19159
+ return;
19160
+ }
19161
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => setCategorySeverity(config, matchedCategory, severity));
19162
+ if (!written) {
19163
+ reportManualEdit(target, nextConfig);
19164
+ return;
19165
+ }
19166
+ cliLogger.success(`Set category "${matchedCategory}" → ${severity}`);
19167
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
19168
+ };
19169
+ const rulesIgnoreTagAction = async (tag, options) => {
19170
+ const knownTags = listRuleTags(buildRuleCatalog());
19171
+ if (!knownTags.includes(tag)) {
19172
+ cliLogger.error(`Unknown tag "${tag}".`);
19173
+ cliLogger.dim(` Known tags: ${knownTags.join(", ")}`);
19174
+ process.exitCode = 1;
19175
+ return;
19176
+ }
19177
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => addIgnoredTag(config, tag));
19178
+ if (!written) {
19179
+ reportManualEdit(target, nextConfig);
19180
+ return;
19181
+ }
19182
+ cliLogger.success(`Ignoring tag "${tag}" (rules with this tag are skipped before linting)`);
19183
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
19184
+ };
19185
+ const rulesUnignoreTagAction = async (tag, options) => {
19186
+ const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
19187
+ if (!(target.config.ignore?.tags ?? []).includes(tag)) {
19188
+ cliLogger.dim(`Tag "${tag}" was not being ignored; nothing to change.`);
19189
+ return;
19190
+ }
19191
+ const nextConfig = removeIgnoredTag(target.config, tag);
19192
+ const { written } = await writeRuleConfig(target, nextConfig);
19193
+ if (!written) {
19194
+ reportManualEdit(target, nextConfig);
19195
+ return;
19196
+ }
19197
+ cliLogger.success(`Tag "${tag}" is no longer ignored`);
19198
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
19199
+ };
19200
+ //#endregion
19201
+ //#region src/cli/commands/version.ts
19202
+ /**
19203
+ * oclif-style version line. 12-factor CLI Apps (#3, "What version am I
19204
+ * on?"): the `version` command is the primary place users grab debugging
19205
+ * info, so it carries the Node runtime and platform alongside the CLI
19206
+ * version. The `-v` / `-V` / `--version` flags stay terse (just the
19207
+ * number) so scripts can parse them.
19208
+ */
19209
+ const buildVersionString = () => `react-doctor/${VERSION} ${process.platform}-${process.arch} node-${process.version}`;
19210
+ const versionAction = () => {
19211
+ process.stdout.write(`${buildVersionString()}\n`);
19212
+ };
19213
+ //#endregion
19214
+ //#region src/cli/utils/apply-color-preference.ts
19215
+ /**
19216
+ * Resolve an explicit color preference from `--color` / `--no-color` or the
19217
+ * app-specific `REACT_DOCTOR_NO_COLOR` / `REACT_DOCTOR_FORCE_COLOR` env vars
19218
+ * (clig.dev Output; 12-factor #6), overriding picocolors' own
19219
+ * `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY detection. Flags win over env
19220
+ * vars; with neither set, picocolors' detection stands.
19221
+ *
19222
+ * A resolved preference is mirrored onto the standard `NO_COLOR` /
19223
+ * `FORCE_COLOR` env vars in addition to our picocolors highlighter, so
19224
+ * libraries with their own color stacks (the `ora` spinner, `prompts`)
19225
+ * honor it too rather than only the scan report. Scanning argv directly
19226
+ * (not Commander's parsed options) applies the preference before Commander
19227
+ * parses, so it reaches every later path. The scan stops at `--`.
19228
+ */
19229
+ const applyColorPreference = (argv, env = process.env) => {
19230
+ let enabled;
19231
+ for (const argument of argv) {
19232
+ if (argument === "--") break;
19233
+ if (argument === "--no-color") enabled = false;
19234
+ else if (argument === "--color") enabled = true;
19235
+ }
19236
+ if (enabled === void 0) {
19237
+ if (env.REACT_DOCTOR_NO_COLOR) enabled = false;
19238
+ else if (env.REACT_DOCTOR_FORCE_COLOR) enabled = true;
19239
+ }
19240
+ if (enabled === void 0) return;
19241
+ if (enabled) {
19242
+ env.FORCE_COLOR = "1";
19243
+ delete env.NO_COLOR;
19244
+ } else {
19245
+ env.NO_COLOR = "1";
19246
+ delete env.FORCE_COLOR;
19247
+ }
19248
+ setColorEnabled(enabled);
17256
19249
  };
17257
19250
  //#endregion
17258
19251
  //#region src/cli/utils/exit-gracefully.ts
@@ -17264,21 +19257,54 @@ const exitGracefully = () => {
17264
19257
  process.exit(130);
17265
19258
  };
17266
19259
  //#endregion
19260
+ //#region src/cli/utils/normalize-help-command.ts
19261
+ /**
19262
+ * 12-factor CLI Apps (#1, "Great help is essential"): `mycli help` and
19263
+ * `mycli help <command>` must display help. Commander doesn't wire this
19264
+ * up once the root command has its own default action plus a positional
19265
+ * argument — it treats a leading `help` as the `[directory]` to scan,
19266
+ * which then errors with "No React project found in ./help".
19267
+ *
19268
+ * We rewrite the argv up front so the existing `--help` paths handle it:
19269
+ * `react-doctor help` -> `react-doctor --help`
19270
+ * `react-doctor help install` -> `react-doctor install --help`
19271
+ *
19272
+ * Only a *leading* `help` token is rewritten, so a flag value such as
19273
+ * `--project help` is never mistaken for the help command. The target is
19274
+ * the first non-flag token after `help`, so intervening flags like
19275
+ * `help --no-color install` still resolve to `install`. An unknown target
19276
+ * (`help bogus`) falls back to root help rather than erroring.
19277
+ */
19278
+ const normalizeHelpInvocation = (argv, knownCommands) => {
19279
+ const nodeArguments = argv.slice(0, 2);
19280
+ const userArguments = argv.slice(2);
19281
+ if (userArguments[0] !== "help") return [...argv];
19282
+ const target = userArguments.slice(1).find((argument) => !argument.startsWith("-"));
19283
+ if (target !== void 0 && knownCommands.includes(target)) return [
19284
+ ...nodeArguments,
19285
+ target,
19286
+ "--help"
19287
+ ];
19288
+ return [...nodeArguments, "--help"];
19289
+ };
19290
+ //#endregion
17267
19291
  //#region src/cli/utils/strip-unknown-cli-flags.ts
17268
- const NODE_ARGUMENT_COUNT = 2;
17269
19292
  const ROOT_FLAG_SPEC = {
17270
19293
  longOptionsWithoutValues: new Set([
17271
19294
  "--annotations",
19295
+ "--color",
17272
19296
  "--dead-code",
17273
19297
  "--full",
17274
19298
  "--help",
17275
19299
  "--json",
17276
19300
  "--json-compact",
17277
19301
  "--lint",
19302
+ "--no-color",
17278
19303
  "--no-dead-code",
17279
19304
  "--no-lint",
17280
19305
  "--no-respect-inline-disables",
17281
19306
  "--no-score",
19307
+ "--no-telemetry",
17282
19308
  "--no-warnings",
17283
19309
  "--pr-comment",
17284
19310
  "--respect-inline-disables",
@@ -17296,7 +19322,7 @@ const ROOT_FLAG_SPEC = {
17296
19322
  "--project",
17297
19323
  "--why"
17298
19324
  ]),
17299
- longOptionsWithOptionalValues: new Set(["--diff"]),
19325
+ longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
17300
19326
  shortOptionsWithoutValues: new Set([
17301
19327
  "-h",
17302
19328
  "-v",
@@ -17307,8 +19333,10 @@ const ROOT_FLAG_SPEC = {
17307
19333
  const INSTALL_FLAG_SPEC = {
17308
19334
  longOptionsWithoutValues: new Set([
17309
19335
  "--agent-hooks",
19336
+ "--color",
17310
19337
  "--dry-run",
17311
19338
  "--help",
19339
+ "--no-color",
17312
19340
  "--yes"
17313
19341
  ]),
17314
19342
  longOptionsWithRequiredValues: new Set(["--cwd"]),
@@ -17316,7 +19344,40 @@ const INSTALL_FLAG_SPEC = {
17316
19344
  shortOptionsWithoutValues: new Set(["-h", "-y"]),
17317
19345
  shortOptionsWithRequiredValues: new Set(["-c"])
17318
19346
  };
17319
- const COMMAND_FLAG_SPECS = new Map([["install", INSTALL_FLAG_SPEC], ["setup", INSTALL_FLAG_SPEC]]);
19347
+ const COMMAND_FLAG_SPECS = new Map([
19348
+ ["install", INSTALL_FLAG_SPEC],
19349
+ ["setup", INSTALL_FLAG_SPEC],
19350
+ ["version", {
19351
+ longOptionsWithoutValues: new Set([
19352
+ "--color",
19353
+ "--help",
19354
+ "--no-color"
19355
+ ]),
19356
+ longOptionsWithRequiredValues: /* @__PURE__ */ new Set(),
19357
+ longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
19358
+ shortOptionsWithoutValues: new Set(["-h"]),
19359
+ shortOptionsWithRequiredValues: /* @__PURE__ */ new Set()
19360
+ }],
19361
+ ["rules", {
19362
+ longOptionsWithoutValues: new Set([
19363
+ "--color",
19364
+ "--configured",
19365
+ "--help",
19366
+ "--json",
19367
+ "--no-color"
19368
+ ]),
19369
+ longOptionsWithRequiredValues: new Set([
19370
+ "--category",
19371
+ "--cwd",
19372
+ "--framework",
19373
+ "--severity",
19374
+ "--tag"
19375
+ ]),
19376
+ longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
19377
+ shortOptionsWithoutValues: new Set(["-h"]),
19378
+ shortOptionsWithRequiredValues: new Set(["-c"])
19379
+ }]
19380
+ ]);
17320
19381
  const isFlagLike = (argument) => argument.startsWith("-") && argument !== "-";
17321
19382
  const getLongOptionName = (argument) => {
17322
19383
  const equalsIndex = argument.indexOf("=");
@@ -17370,8 +19431,8 @@ const stripUnknownFlags = (userArguments, flagSpec) => {
17370
19431
  return sanitizedArguments;
17371
19432
  };
17372
19433
  const stripUnknownCliFlags = (argv) => {
17373
- const nodeArguments = argv.slice(0, NODE_ARGUMENT_COUNT);
17374
- const userArguments = argv.slice(NODE_ARGUMENT_COUNT);
19434
+ const nodeArguments = argv.slice(0, 2);
19435
+ const userArguments = argv.slice(2);
17375
19436
  const commandIndex = findCommandIndex(userArguments);
17376
19437
  if (commandIndex === null) return [...nodeArguments, ...stripUnknownFlags(userArguments, ROOT_FLAG_SPEC)];
17377
19438
  const commandName = userArguments[commandIndex];
@@ -17385,30 +19446,83 @@ const stripUnknownCliFlags = (argv) => {
17385
19446
  };
17386
19447
  //#endregion
17387
19448
  //#region src/cli/index.ts
19449
+ initializeSentry();
17388
19450
  process.on("SIGINT", exitGracefully);
17389
19451
  process.on("SIGTERM", exitGracefully);
17390
19452
  unrefStdin();
17391
- 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", `
19453
+ const formatExampleLines = (examples) => {
19454
+ const width = Math.max(...examples.map(([command]) => command.length));
19455
+ return examples.map(([command, description]) => ` $ ${command.padEnd(width)} ${highlighter.dim(`# ${description}`)}`).join("\n");
19456
+ };
19457
+ const renderRootHelpEpilog = () => `
19458
+ ${highlighter.dim("Examples:")}
19459
+ ${formatExampleLines([
19460
+ ["react-doctor", "scan the current project"],
19461
+ ["react-doctor ./apps/web", "scan a specific directory"],
19462
+ ["react-doctor --diff main", "scan only files changed vs. main"],
19463
+ ["react-doctor --staged", "scan staged files (pre-commit hook)"],
19464
+ ["react-doctor --fail-on warning", "exit non-zero on warnings (CI gate)"],
19465
+ ["react-doctor --json > report.json", "write a machine-readable report"],
19466
+ ["react-doctor --explain src/App.tsx:42", "explain why a rule fired there"],
19467
+ ["react-doctor install", "set up the agent skill and git hook"]
19468
+ ])}
19469
+
17392
19470
  ${highlighter.dim("Configuration:")}
17393
- Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
17394
- CLI flags always override config values. See the README for the full schema.
19471
+ Add a ${highlighter.info("doctor.config.ts")} (or .js/.mjs/.json — or a ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
19472
+ Use ${highlighter.info("react-doctor rules")} to list, explain, and configure rules. CLI flags always override config values.
19473
+
19474
+ ${highlighter.dim("Feedback & bug reports:")}
19475
+ ${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
17395
19476
 
17396
19477
  ${highlighter.dim("Learn more:")}
17397
19478
  ${highlighter.info(CANONICAL_GITHUB_URL)}
17398
- `);
19479
+ `;
19480
+ const renderInstallHelpEpilog = () => `
19481
+ ${highlighter.dim("Examples:")}
19482
+ ${formatExampleLines([
19483
+ ["react-doctor install", "interactive setup"],
19484
+ ["react-doctor install --yes", "non-interactive; all detected agents"],
19485
+ ["react-doctor install --dry-run", "preview without writing files"],
19486
+ ["react-doctor install --agent-hooks", "also install native agent hooks"]
19487
+ ])}
19488
+
19489
+ ${highlighter.dim("Learn more:")}
19490
+ ${highlighter.info(CANONICAL_GITHUB_URL)}
19491
+ `;
19492
+ 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);
17399
19493
  program.action(inspectAction);
17400
- 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);
19494
+ 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);
19495
+ 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);
19496
+ const rules = program.command("rules").description("List, explain, and configure which React Doctor rules run");
19497
+ rules.command("list").description("List rules and the severity they run at under your config").option("--category <name>", "only show rules in a category (e.g. Performance)").option("--tag <name>", "only show rules with a tag (e.g. design, test-noise)").option("--framework <name>", "only show rules for a framework (e.g. global, nextjs)").option("--configured", "only show rules your config has changed from the default").option("--json", "output a structured JSON array").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((_options, command) => rulesListAction(command.optsWithGlobals()));
19498
+ rules.command("explain <rule>").description("Explain why a rule matters, its current severity, and how to configure it").option("--json", "output a structured JSON object").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesExplainAction(rule, command.optsWithGlobals()));
19499
+ rules.command("set <rule> <severity>").description("Set a rule's severity: off, warn, or error").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, severity, _options, command) => rulesSetAction(rule, severity, command.optsWithGlobals()));
19500
+ rules.command("enable <rule>").description("Enable a rule at its recommended severity (or pass --severity)").option("--severity <level>", "severity to enable at: warn or error").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesEnableAction(rule, command.optsWithGlobals()));
19501
+ rules.command("disable <rule>").description("Disable a rule so it never runs").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesDisableAction(rule, command.optsWithGlobals()));
19502
+ rules.command("category <category> <severity>").description("Set the severity for a whole category (off, warn, error)").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((category, severity, _options, command) => rulesCategoryAction(category, severity, command.optsWithGlobals()));
19503
+ rules.command("ignore-tag <tag>").description("Skip a whole rule family by tag before linting (e.g. design)").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((tag, _options, command) => rulesIgnoreTagAction(tag, command.optsWithGlobals()));
19504
+ rules.command("unignore-tag <tag>").description("Stop ignoring a tag previously skipped via ignore-tag").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((tag, _options, command) => rulesUnignoreTagAction(tag, command.optsWithGlobals()));
17401
19505
  process.stdout.on("error", (error) => {
17402
19506
  if (error.code === "EPIPE") process.exit(0);
17403
19507
  });
17404
- program.parseAsync(stripUnknownCliFlags(process.argv)).catch((error) => {
19508
+ const knownCommands = program.commands.flatMap((command) => [command.name(), ...command.aliases()]);
19509
+ const strippedArgv = stripUnknownCliFlags(process.argv);
19510
+ if (process.argv.includes("-V") && !strippedArgv.includes("-V")) {
19511
+ process.stdout.write(`${VERSION}\n`);
19512
+ process.exit(0);
19513
+ }
19514
+ applyColorPreference(strippedArgv);
19515
+ const argv = normalizeHelpInvocation(strippedArgv, knownCommands);
19516
+ program.parseAsync(argv).then(() => flushSentry()).catch(async (error) => {
19517
+ const sentryEventId = await reportErrorToSentry(error);
17405
19518
  if (isJsonModeActive()) {
17406
19519
  writeJsonErrorReport(error);
17407
19520
  process.exit(1);
17408
19521
  }
17409
- handleError(error);
19522
+ handleError(error, { sentryEventId });
17410
19523
  });
17411
19524
  //#endregion
17412
19525
  export {};
17413
19526
 
17414
- //# sourceMappingURL=cli.js.map
19527
+ //# sourceMappingURL=cli.js.map
19528
+ //# debugId=74dccde1-47a7-5718-88d7-ea8e5d7b463b