react-doctor 0.2.14-dev.b9e9bcb → 0.2.14-dev.bdb9e36

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]="d012dbb2-27e9-5092-9eab-c7fa4573f0b2")}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";
@@ -17,6 +19,8 @@ import * as Otlp from "effect/unstable/observability/Otlp";
17
19
  import * as Context from "effect/Context";
18
20
  import os, { tmpdir } from "node:os";
19
21
  import * as Console from "effect/Console";
22
+ import { parseJSON5 } from "confbox";
23
+ import { createJiti } from "jiti";
20
24
  import * as Fiber from "effect/Fiber";
21
25
  import * as Filter from "effect/Filter";
22
26
  import * as Option from "effect/Option";
@@ -32,6 +36,8 @@ import * as ts from "typescript";
32
36
  import { gzipSync } from "node:zlib";
33
37
  import * as Sentry from "@sentry/node";
34
38
  import { performance } from "node:perf_hooks";
39
+ import * as Tracer from "effect/Tracer";
40
+ import * as Exit from "effect/Exit";
35
41
  import { stripVTControlCharacters } from "node:util";
36
42
  import tty from "node:tty";
37
43
  import { codeFrameColumns } from "@babel/code-frame";
@@ -40,6 +46,8 @@ import basePrompts from "prompts";
40
46
  import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
41
47
  import { fileURLToPath } from "node:url";
42
48
  import Conf from "conf";
49
+ import { generateCode, loadFile, writeFile } from "magicast";
50
+ import { getConfigFromVariableDeclaration, getDefaultExportOptions } from "magicast/helpers";
43
51
  //#region \0rolldown/runtime.js
44
52
  var __create$1 = Object.create;
45
53
  var __defProp$1 = Object.defineProperty;
@@ -6291,7 +6299,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
6291
6299
  const SCORE_API_URL = "https://www.react.doctor/api/score";
6292
6300
  const ENTERPRISE_CONTACT_URL = "https://react.doctor/enterprise";
6293
6301
  const SHARE_BASE_URL = "https://react.doctor/share";
6294
- 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`;
6295
6304
  const FETCH_TIMEOUT_MS = 1e4;
6296
6305
  const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
6297
6306
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
@@ -6309,7 +6318,14 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
6309
6318
  "tsconfig.json",
6310
6319
  "tsconfig.base.json",
6311
6320
  "package.json",
6312
- "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",
6313
6329
  "oxlint.json",
6314
6330
  ".oxlintrc.json"
6315
6331
  ];
@@ -7500,77 +7516,135 @@ const validateConfigTypes = (config) => {
7500
7516
  const warn = (message) => {
7501
7517
  Effect.runSync(Console.warn(message));
7502
7518
  };
7503
- const CONFIG_FILENAME = "react-doctor.config.json";
7504
- const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
7505
- const loadConfigFromDirectory = (directory) => {
7506
- const configFilePath = path.join(directory, CONFIG_FILENAME);
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"));
7546
+ if (isPlainObject(packageJson)) {
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) => {
7507
7564
  let sawBrokenConfigFile = false;
7508
- if (isFile(configFilePath)) {
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);
7509
7569
  try {
7510
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
7511
- const parsed = JSON.parse(fileContent);
7570
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
7512
7571
  if (isPlainObject(parsed)) return {
7513
7572
  status: "found",
7514
7573
  loaded: {
7515
7574
  config: validateConfigTypes(parsed),
7516
- sourceDirectory: directory
7575
+ sourceDirectory: directory,
7576
+ configFilePath: filePath,
7577
+ format: isDataFile ? "json" : "module"
7517
7578
  }
7518
7579
  };
7519
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
7580
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
7581
+ sawBrokenConfigFile = true;
7520
7582
  } catch (error) {
7521
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
7583
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
7584
+ sawBrokenConfigFile = true;
7522
7585
  }
7523
- sawBrokenConfigFile = true;
7524
7586
  }
7525
- const packageJsonPath = path.join(directory, "package.json");
7526
- if (isFile(packageJsonPath)) try {
7527
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
7528
- const packageJson = JSON.parse(fileContent);
7529
- if (isPlainObject(packageJson)) {
7530
- const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
7531
- if (isPlainObject(embeddedConfig)) return {
7532
- status: "found",
7533
- loaded: {
7534
- config: validateConfigTypes(embeddedConfig),
7535
- sourceDirectory: directory
7536
- }
7537
- };
7538
- }
7539
- } catch {}
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).`);
7540
7593
  return {
7541
7594
  status: sawBrokenConfigFile ? "invalid" : "absent",
7542
7595
  loaded: null
7543
7596
  };
7544
7597
  };
7545
7598
  const cachedConfigs = /* @__PURE__ */ new Map();
7546
- const loadConfigWithSource = (rootDirectory) => {
7547
- const cached = cachedConfigs.get(rootDirectory);
7548
- if (cached !== void 0) return cached;
7549
- const localResult = loadConfigFromDirectory(rootDirectory);
7550
- if (localResult.status === "found") {
7551
- cachedConfigs.set(rootDirectory, localResult.loaded);
7552
- return localResult.loaded;
7553
- }
7554
- if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
7555
- cachedConfigs.set(rootDirectory, null);
7556
- return null;
7557
- }
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;
7558
7606
  let ancestorDirectory = path.dirname(rootDirectory);
7559
7607
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
7560
- const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
7561
- if (ancestorResult.status === "found") {
7562
- cachedConfigs.set(rootDirectory, ancestorResult.loaded);
7563
- return ancestorResult.loaded;
7564
- }
7565
- if (isProjectBoundary(ancestorDirectory)) {
7566
- cachedConfigs.set(rootDirectory, null);
7567
- return null;
7568
- }
7608
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
7609
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
7610
+ if (isProjectBoundary(ancestorDirectory)) return null;
7569
7611
  ancestorDirectory = path.dirname(ancestorDirectory);
7570
7612
  }
7571
- cachedConfigs.set(rootDirectory, null);
7572
7613
  return null;
7573
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
+ };
7574
7648
  const resolveConfigRootDir = (config, configSourceDirectory) => {
7575
7649
  if (!config || !configSourceDirectory) return null;
7576
7650
  const rawRootDir = config.rootDir;
@@ -7598,8 +7672,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
7598
7672
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
7599
7673
  *
7600
7674
  * 1. Resolve the requested directory to absolute.
7601
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
7602
- * if present.
7675
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
7603
7676
  * 3. Honor `config.rootDir` to redirect the scan to a nested
7604
7677
  * project root, if configured.
7605
7678
  * 4. Walk into a nested React subproject when the requested
@@ -7617,9 +7690,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
7617
7690
  * via its own cache). Routing through `resolveScanTarget` keeps every
7618
7691
  * shell in agreement on what "the scan directory" means.
7619
7692
  */
7620
- const resolveScanTarget = (requestedDirectory, options = {}) => {
7693
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
7621
7694
  const absoluteRequested = path.resolve(requestedDirectory);
7622
- const loadedConfig = loadConfigWithSource(absoluteRequested);
7695
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
7623
7696
  const userConfig = loadedConfig?.config ?? null;
7624
7697
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
7625
7698
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
@@ -7943,6 +8016,61 @@ const checkExpoPackageJsonConflicts = (context) => {
7943
8016
  }));
7944
8017
  return diagnostics;
7945
8018
  };
8019
+ const APP_CONFIG_JSON_FILES = ["app.config.json", "app.json"];
8020
+ const APP_CONFIG_DYNAMIC_FILES = [
8021
+ "app.config.ts",
8022
+ "app.config.js",
8023
+ "app.config.cjs",
8024
+ "app.config.mjs"
8025
+ ];
8026
+ const ExpoConfigSchema = Schema.Struct({
8027
+ newArchEnabled: Schema.optional(Schema.Boolean),
8028
+ updates: Schema.optional(Schema.Struct({ disableAntiBrickingMeasures: Schema.optional(Schema.Boolean) }))
8029
+ });
8030
+ const AppManifestSchema = Schema.Struct({ expo: Schema.optional(ExpoConfigSchema) });
8031
+ const NO_CONFIG = {
8032
+ config: null,
8033
+ configFile: null
8034
+ };
8035
+ const decodeExpoConfig = (filePath) => {
8036
+ let raw;
8037
+ try {
8038
+ raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
8039
+ } catch {
8040
+ return null;
8041
+ }
8042
+ return Option.getOrNull(Schema.decodeUnknownOption(AppManifestSchema)(raw))?.expo ?? null;
8043
+ };
8044
+ const readExpoAppConfig = (rootDirectory) => {
8045
+ if (APP_CONFIG_DYNAMIC_FILES.some((fileName) => isFile(path.join(rootDirectory, fileName)))) return NO_CONFIG;
8046
+ for (const fileName of APP_CONFIG_JSON_FILES) {
8047
+ const filePath = path.join(rootDirectory, fileName);
8048
+ if (!isFile(filePath)) continue;
8049
+ const config = decodeExpoConfig(filePath);
8050
+ if (config) return {
8051
+ config,
8052
+ configFile: fileName
8053
+ };
8054
+ }
8055
+ return NO_CONFIG;
8056
+ };
8057
+ const REANIMATED_PACKAGE = "react-native-reanimated";
8058
+ const WORKLETS_PACKAGE = "react-native-worklets";
8059
+ const FIRST_NEW_ARCH_ONLY_REANIMATED_MAJOR = 4;
8060
+ const checkExpoReanimatedNewArch = (context) => {
8061
+ const reanimatedSpec = context.packageJson.dependencies?.[REANIMATED_PACKAGE] ?? context.packageJson.devDependencies?.[REANIMATED_PACKAGE];
8062
+ const reanimatedMajor = reanimatedSpec === void 0 ? null : getLowestDependencyMajor(reanimatedSpec);
8063
+ if (!(reanimatedMajor !== null && reanimatedMajor >= FIRST_NEW_ARCH_ONLY_REANIMATED_MAJOR || context.directDependencyNames.has(WORKLETS_PACKAGE))) return [];
8064
+ const appConfig = readExpoAppConfig(context.rootDirectory);
8065
+ if (appConfig.config?.newArchEnabled !== false) return [];
8066
+ return [buildExpoDiagnostic({
8067
+ rule: "expo-reanimated-v4-requires-new-arch",
8068
+ severity: "error",
8069
+ filePath: appConfig.configFile ?? "app.json",
8070
+ message: "react-native-reanimated v4 supports only the New Architecture, but `newArchEnabled: false` is set in your app config, so the app will crash on first launch.",
8071
+ help: "Remove `newArchEnabled: false` from your app config (the New Architecture is the default on SDK 52+), or pin react-native-reanimated to v3 if you must stay on the legacy architecture."
8072
+ })];
8073
+ };
7946
8074
  const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
7947
8075
  const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
7948
8076
  const checkExpoRouterReactNavigation = (context) => {
@@ -7958,6 +8086,17 @@ const checkExpoRouterReactNavigation = (context) => {
7958
8086
  help: "Remove these `@react-navigation/*` packages and replace direct imports with their expo-router equivalents. See https://docs.expo.dev/router/migrate/sdk-55-to-56/"
7959
8087
  })];
7960
8088
  };
8089
+ const checkExpoUpdatesConfig = (context) => {
8090
+ const appConfig = readExpoAppConfig(context.rootDirectory);
8091
+ if (appConfig.config?.updates?.disableAntiBrickingMeasures !== true) return [];
8092
+ return [buildExpoDiagnostic({
8093
+ rule: "expo-updates-no-unsafe-production-config",
8094
+ severity: "error",
8095
+ filePath: appConfig.configFile ?? "app.json",
8096
+ message: "`updates.disableAntiBrickingMeasures: true` disables expo-updates' recovery safeguards and is liable to leave installed apps in a permanently bricked state, so it must not be used in production.",
8097
+ help: "Remove `disableAntiBrickingMeasures` from your app config's `updates` block. See https://docs.expo.dev/versions/latest/config/app/#updates"
8098
+ })];
8099
+ };
7961
8100
  const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
7962
8101
  const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
7963
8102
  const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
@@ -7984,7 +8123,9 @@ const checkExpoProject = (rootDirectory, project) => {
7984
8123
  ...checkExpoLockfile(context),
7985
8124
  ...checkExpoGitignore(context),
7986
8125
  ...checkExpoEnvLocalFiles(context),
7987
- ...checkExpoMetroConfig(context)
8126
+ ...checkExpoMetroConfig(context),
8127
+ ...checkExpoReanimatedNewArch(context),
8128
+ ...checkExpoUpdatesConfig(context)
7988
8129
  ];
7989
8130
  };
7990
8131
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
@@ -8101,6 +8242,69 @@ const checkPnpmHardening = (rootDirectory) => {
8101
8242
  }));
8102
8243
  return diagnostics;
8103
8244
  };
8245
+ const BUILDER_BOB_PACKAGE = "react-native-builder-bob";
8246
+ const isBuilderBobLibrary = (packageJson) => {
8247
+ const bobConfig = packageJson[BUILDER_BOB_PACKAGE];
8248
+ return typeof bobConfig === "object" && bobConfig !== null;
8249
+ };
8250
+ const checkReactNativeLibraryDependencies = (rootDirectory) => {
8251
+ const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
8252
+ if (!isBuilderBobLibrary(packageJson)) return [];
8253
+ const misplaced = ["react", "react-native"].filter((name) => packageJson.dependencies?.[name] !== void 0);
8254
+ if (misplaced.length === 0) return [];
8255
+ const quoted = misplaced.map((name) => `"${name}"`).join(" and ");
8256
+ return [{
8257
+ filePath: "package.json",
8258
+ plugin: "react-doctor",
8259
+ rule: "rn-library-react-in-dependencies",
8260
+ severity: "warning",
8261
+ message: `This react-native-builder-bob library lists ${quoted} in \`dependencies\` — that ships a second copy into consumer apps, causing "Invalid hook call" (duplicate React) and duplicate-native-module crashes.`,
8262
+ help: `Move ${quoted} to \`peerDependencies\` (keep ${misplaced.length === 1 ? "it" : "them"} in \`devDependencies\` for local development).`,
8263
+ line: 0,
8264
+ column: 0,
8265
+ category: "Correctness"
8266
+ }];
8267
+ };
8268
+ const BABEL_CONFIG_FILE_NAMES = [
8269
+ "babel.config.js",
8270
+ "babel.config.cjs",
8271
+ "babel.config.mjs",
8272
+ "babel.config.json",
8273
+ ".babelrc",
8274
+ ".babelrc.js",
8275
+ ".babelrc.json"
8276
+ ];
8277
+ const LEGACY_PRESET_SPEC = "module:metro-react-native-babel-preset";
8278
+ const checkReactNativeMetroBabelPreset = (rootDirectory) => {
8279
+ for (const fileName of BABEL_CONFIG_FILE_NAMES) {
8280
+ const filePath = path.join(rootDirectory, fileName);
8281
+ if (!isFile(filePath)) continue;
8282
+ let contents;
8283
+ try {
8284
+ contents = fs.readFileSync(filePath, "utf-8");
8285
+ } catch {
8286
+ continue;
8287
+ }
8288
+ if (!contents.includes(LEGACY_PRESET_SPEC)) continue;
8289
+ return [{
8290
+ filePath: fileName,
8291
+ plugin: "react-doctor",
8292
+ rule: "rn-no-metro-babel-preset",
8293
+ severity: "error",
8294
+ message: "`module:metro-react-native-babel-preset` was renamed to `@react-native/babel-preset` and is no longer installed by React Native 0.73+ — this preset reference fails to resolve and breaks the Metro/Babel transform.",
8295
+ help: "Replace the preset with `module:@react-native/babel-preset` (or `babel-preset-expo` on Expo) and remove the old `metro-react-native-babel-preset` dependency.",
8296
+ line: 0,
8297
+ column: 0,
8298
+ category: "Correctness"
8299
+ }];
8300
+ }
8301
+ return [];
8302
+ };
8303
+ const isReactNativeProject = (project) => project.framework === "react-native" || project.framework === "expo" || project.hasReactNativeWorkspace || project.expoVersion !== null;
8304
+ const checkReactNativeProject = (rootDirectory, project) => {
8305
+ if (!isReactNativeProject(project)) return [];
8306
+ return [...checkReactNativeMetroBabelPreset(rootDirectory), ...checkReactNativeLibraryDependencies(rootDirectory)];
8307
+ };
8104
8308
  const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
8105
8309
  const REDUCED_MOTION_FILE_GLOBS = [
8106
8310
  "*.ts",
@@ -8666,8 +8870,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
8666
8870
  const cache = yield* Cache.make({
8667
8871
  capacity: 16,
8668
8872
  timeToLive: CONFIG_CACHE_TTL_MS,
8669
- lookup: (directory) => Effect.sync(() => {
8670
- const loaded = loadConfigWithSource(directory);
8873
+ lookup: (directory) => Effect.promise(async () => {
8874
+ const loaded = await loadConfigWithSource(directory);
8671
8875
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
8672
8876
  return {
8673
8877
  config: loaded?.config ?? null,
@@ -10846,7 +11050,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10846
11050
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
10847
11051
  yield* beforeLint(project, lintIncludePaths ?? void 0);
10848
11052
  const isDiffMode = input.includePaths.length > 0;
10849
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
11053
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
10850
11054
  const transform = buildDiagnosticPipeline({
10851
11055
  rootDirectory: scanDirectory,
10852
11056
  userConfig: resolvedConfig.config,
@@ -10858,7 +11062,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10858
11062
  const environmentDiagnostics = isDiffMode ? [] : [
10859
11063
  ...checkReducedMotion(scanDirectory),
10860
11064
  ...checkPnpmHardening(scanDirectory),
10861
- ...checkExpoProject(scanDirectory, project)
11065
+ ...checkExpoProject(scanDirectory, project),
11066
+ ...checkReactNativeProject(scanDirectory, project)
10862
11067
  ];
10863
11068
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
10864
11069
  const lintFailure = yield* Ref.make({
@@ -11438,12 +11643,13 @@ const setColorEnabled = (enabled) => {
11438
11643
  highlighter.bold = colors.bold;
11439
11644
  };
11440
11645
  /**
11441
- * Canonical URL for a rule's reviewer-tested fix recipe, served at
11442
- * `https://www.react.doctor/prompts/rules/<plugin>/<rule>.md`. The
11443
- * `/doctor` playbook fetches it on demand so each fix follows the
11444
- * canonical recipe instead of being improvised per diagnostic.
11646
+ * Canonical URL for a rule's documentation page — its reviewer-tested fix
11647
+ * recipe rendered for humans — served at
11648
+ * `https://www.react.doctor/docs/rules/<plugin>/<rule>`. The CLI links here
11649
+ * from its fix-recipe directive so each fix follows the canonical recipe
11650
+ * instead of being improvised per diagnostic.
11445
11651
  */
11446
- const buildRulePromptUrl = (plugin, rule) => `${PROMPTS_RULES_BASE_URL}/${plugin}/${rule}.md`;
11652
+ const buildRuleDocsUrl = (plugin, rule) => `${DOCS_RULES_BASE_URL}/${plugin}/${rule}`;
11447
11653
  const groupBy = (items, keyFn) => {
11448
11654
  const groups = /* @__PURE__ */ new Map();
11449
11655
  for (const item of items) {
@@ -11456,8 +11662,8 @@ const groupBy = (items, keyFn) => {
11456
11662
  };
11457
11663
  /**
11458
11664
  * Whether a diagnostic's rule has a published per-rule fix recipe at
11459
- * `${PROMPTS_RULES_BASE_URL}/react-doctor/<rule>.md`
11460
- * (see `buildRulePromptUrl`).
11665
+ * `${DOCS_RULES_BASE_URL}/react-doctor/<rule>`
11666
+ * (see `buildRuleDocsUrl`).
11461
11667
  *
11462
11668
  * Recipes are generated from react-doctor's own engine rules, so only
11463
11669
  * those resolve. Dead-code (`deslop`), the synthetic environment and
@@ -11469,53 +11675,658 @@ const groupBy = (items, keyFn) => {
11469
11675
  */
11470
11676
  const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
11471
11677
  //#endregion
11678
+ //#region src/cli/utils/is-ci-environment.ts
11679
+ const CI_ENVIRONMENT_VARIABLES = [
11680
+ "GITHUB_ACTIONS",
11681
+ "GITLAB_CI",
11682
+ "CIRCLECI"
11683
+ ];
11684
+ const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
11685
+ ["GITHUB_ACTIONS", "github-actions"],
11686
+ ["GITLAB_CI", "gitlab-ci"],
11687
+ ["CIRCLECI", "circleci"],
11688
+ ["BUILDKITE", "buildkite"],
11689
+ ["JENKINS_URL", "jenkins"],
11690
+ ["TF_BUILD", "azure-pipelines"],
11691
+ ["CODEBUILD_BUILD_ID", "aws-codebuild"],
11692
+ ["TEAMCITY_VERSION", "teamcity"],
11693
+ ["BITBUCKET_BUILD_NUMBER", "bitbucket"],
11694
+ ["TRAVIS", "travis"],
11695
+ ["DRONE", "drone"]
11696
+ ];
11697
+ const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
11698
+ ["CLAUDECODE", "claude-code"],
11699
+ ["CLAUDE_CODE", "claude-code"],
11700
+ ["CURSOR_AGENT", "cursor"],
11701
+ ["CODEX_CI", "codex"],
11702
+ ["CODEX_SANDBOX", "codex"],
11703
+ ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
11704
+ ["OPENCODE", "opencode"],
11705
+ ["GOOSE_TERMINAL", "goose"],
11706
+ ["AMP_THREAD_ID", "amp"]
11707
+ ];
11708
+ const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
11709
+ const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
11710
+ const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
11711
+ [...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
11712
+ const FALSY_CI_FLAG_VALUES = new Set([
11713
+ "",
11714
+ "0",
11715
+ "false"
11716
+ ]);
11717
+ const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
11718
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
11719
+ const detectCiProvider = () => {
11720
+ for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
11721
+ return isCiFlagSet(process.env.CI) ? "unknown" : null;
11722
+ };
11723
+ const detectCodingAgentFromValue = () => {
11724
+ for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
11725
+ const value = process.env[environmentVariable]?.toLowerCase();
11726
+ if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
11727
+ }
11728
+ return null;
11729
+ };
11730
+ const detectCodingAgent = () => {
11731
+ for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
11732
+ const agentFromValue = detectCodingAgentFromValue();
11733
+ if (agentFromValue) return agentFromValue;
11734
+ if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
11735
+ return null;
11736
+ };
11737
+ const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
11738
+ const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
11739
+ //#endregion
11740
+ //#region src/cli/utils/is-non-interactive-environment.ts
11741
+ const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
11742
+ "CI",
11743
+ "GITHUB_ACTIONS",
11744
+ "GITLAB_CI",
11745
+ "BUILDKITE",
11746
+ "JENKINS_URL",
11747
+ "TF_BUILD",
11748
+ "CODEBUILD_BUILD_ID",
11749
+ "TEAMCITY_VERSION",
11750
+ "BITBUCKET_BUILD_NUMBER",
11751
+ "CIRCLECI",
11752
+ "TRAVIS",
11753
+ "DRONE",
11754
+ "GIT_DIR"
11755
+ ];
11756
+ const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
11757
+ //#endregion
11472
11758
  //#region src/cli/utils/constants.ts
11473
11759
  const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
11474
11760
  const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
11475
- const SENTRY_DSN = "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920";
11761
+ const SENTRY_FLUSH_TIMEOUT_MS = 2e3;
11762
+ const NANOSECONDS_PER_SECOND = 1000000000n;
11763
+ //#endregion
11764
+ //#region src/cli/utils/noop-console.ts
11765
+ /**
11766
+ * A concrete `Console.Console` whose methods are all no-ops.
11767
+ *
11768
+ * Used by `--silent` (provided via
11769
+ * `Effect.provideService(Console.Console, makeNoopConsole())`) and by
11770
+ * `enableJsonMode` (assigned over the relevant slots on
11771
+ * `globalThis.console` so imperative legacy callsites that aren't
11772
+ * Effect-typed also fall silent). Sourcing both from a single concrete
11773
+ * object keeps "what is a no-op console" answered in one place; the
11774
+ * earlier `new Proxy({} as Console.Console, { get: () => () => undefined })`
11775
+ * combined a cast with a Proxy to do the same thing implicitly.
11776
+ *
11777
+ * The interface mirrors Effect v4's `Console.Console` shape exactly so
11778
+ * `Effect.provideService(Console.Console, makeNoopConsole())` requires
11779
+ * no cast.
11780
+ */
11781
+ const makeNoopConsole = () => ({
11782
+ assert: () => {},
11783
+ clear: () => {},
11784
+ count: () => {},
11785
+ countReset: () => {},
11786
+ debug: () => {},
11787
+ dir: () => {},
11788
+ dirxml: () => {},
11789
+ error: () => {},
11790
+ group: () => {},
11791
+ groupCollapsed: () => {},
11792
+ groupEnd: () => {},
11793
+ info: () => {},
11794
+ log: () => {},
11795
+ table: () => {},
11796
+ time: () => {},
11797
+ timeEnd: () => {},
11798
+ timeLog: () => {},
11799
+ trace: () => {},
11800
+ warn: () => {}
11801
+ });
11476
11802
  //#endregion
11477
11803
  //#region src/cli/utils/version.ts
11478
- const VERSION = "0.2.14-dev.b9e9bcb";
11804
+ const VERSION = "0.2.14-dev.bdb9e36";
11479
11805
  //#endregion
11480
- //#region src/instrument.ts
11481
- let isInitialized = false;
11482
- const shouldEnableSentry = () => {
11483
- if (process.argv.includes("--no-score") || process.argv.includes("--no-telemetry")) return false;
11484
- if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
11485
- return true;
11486
- };
11806
+ //#region src/cli/utils/json-mode.ts
11807
+ let context = null;
11487
11808
  /**
11488
- * Initializes the Sentry Node SDK for CLI crash reporting. Invoked as
11489
- * the first statement of the CLI entry (`cli/index.ts`) so the SDK's
11490
- * global `uncaughtException` / `unhandledRejection` handlers are armed
11491
- * before any command runs.
11809
+ * JSON mode writes the report payload to stdout; any incidental log
11810
+ * line printed by an Effect program would corrupt the JSON. Effect's
11811
+ * `Console` module resolves to `globalThis.console` by default (see
11812
+ * `effect/internal/effect.ts` `ConsoleRef`), so copying the methods
11813
+ * from `makeNoopConsole()` onto the global is enough to silence every
11814
+ * `yield* Console.log(...)` and `cliLogger.*` call sourced from
11815
+ * react-doctor or its services.
11492
11816
  *
11493
- * Exported as a function rather than a bare side-effecting import
11494
- * because the package declares `"sideEffects": false`, which lets the
11495
- * bundler tree-shake side-effect-only modules. An explicit call keeps
11496
- * the initialization in the published `dist/cli.js`.
11817
+ * We use the same `makeNoopConsole()` source as the `--silent` path
11818
+ * (which provides the Effect Console via
11819
+ * `Effect.provideService(Console.Console, makeNoopConsole())`) one
11820
+ * canonical "no-op console" definition shared by the two silent
11821
+ * mechanisms. The two routes still differ in how they install the
11822
+ * noop: silent mode swaps the Effect Console reference inside the
11823
+ * program; JSON mode patches the global because the surrounding CLI
11824
+ * command body is still imperative. Both will collapse into the
11825
+ * Effect-typed route once the command body finishes its migration.
11497
11826
  *
11498
- * Scoped to the CLI application only the programmatic
11499
- * `@react-doctor/api` library never initializes Sentry, so importing
11500
- * `diagnose()` into a consumer app can't hijack their telemetry.
11827
+ * JSON mode is one-shot per CLI invocation, so we never restore.
11501
11828
  */
11502
- const initializeSentry = () => {
11503
- if (isInitialized || !shouldEnableSentry()) return;
11504
- isInitialized = true;
11505
- Sentry.init({
11506
- dsn: SENTRY_DSN,
11507
- sendDefaultPii: true,
11508
- release: VERSION
11509
- });
11829
+ const installSilentConsole = () => {
11830
+ const noopConsole = makeNoopConsole();
11831
+ const target = globalThis.console;
11832
+ const source = noopConsole;
11833
+ for (const key of [
11834
+ "log",
11835
+ "error",
11836
+ "warn",
11837
+ "info",
11838
+ "debug",
11839
+ "trace"
11840
+ ]) target[key] = source[key];
11510
11841
  };
11511
- //#endregion
11512
- //#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
11513
- const ANSI_BACKGROUND_OFFSET = 10;
11514
- const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
11515
- const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
11516
- const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
11517
- const styles$1 = {
11518
- modifier: {
11842
+ const enableJsonMode = ({ compact, directory }) => {
11843
+ context = {
11844
+ compact,
11845
+ directory,
11846
+ startTime: performance.now(),
11847
+ mode: "full"
11848
+ };
11849
+ installSilentConsole();
11850
+ };
11851
+ const isJsonModeActive = () => context !== null;
11852
+ const setJsonReportDirectory = (directory) => {
11853
+ if (context) context.directory = directory;
11854
+ };
11855
+ const setJsonReportMode = (mode) => {
11856
+ if (context) context.mode = mode;
11857
+ };
11858
+ const writeJsonReport = (report) => {
11859
+ const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
11860
+ process.stdout.write(`${serialized}\n`);
11861
+ };
11862
+ const writeJsonErrorReport = (error) => {
11863
+ if (!context) return;
11864
+ try {
11865
+ writeJsonReport(buildJsonReportError({
11866
+ version: VERSION,
11867
+ directory: context.directory,
11868
+ error,
11869
+ elapsedMilliseconds: performance.now() - context.startTime,
11870
+ mode: context.mode
11871
+ }));
11872
+ } catch {
11873
+ process.stdout.write(INTERNAL_ERROR_JSON_FALLBACK);
11874
+ }
11875
+ };
11876
+ //#endregion
11877
+ //#region src/cli/utils/scrub-sensitive-text.ts
11878
+ const HOME_DIRECTORY = os.homedir();
11879
+ const USER_HOME_PATTERNS = [/[A-Za-z]:[\\/]Users[\\/][^\\/]+/gi, /(?:\/Users\/|\/home\/)[^/\\]+/gi];
11880
+ /**
11881
+ * Replaces the user's home directory (and generic `/Users|/home|C:\Users\<name>`
11882
+ * roots) with `~` so absolute paths can't be tied back to an individual. Keeps
11883
+ * the path's relative structure intact, which stays useful for debugging while
11884
+ * dropping the personally-identifying prefix. Idempotent — re-running on an
11885
+ * already-scrubbed `~/...` path is a no-op.
11886
+ */
11887
+ const scrubSensitivePaths = (text) => {
11888
+ let scrubbed = text;
11889
+ if (HOME_DIRECTORY.length > 1) scrubbed = scrubbed.split(HOME_DIRECTORY).join("~");
11890
+ for (const pattern of USER_HOME_PATTERNS) scrubbed = scrubbed.replace(pattern, "~");
11891
+ return scrubbed;
11892
+ };
11893
+ //#endregion
11894
+ //#region src/cli/utils/build-run-context.ts
11895
+ const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
11896
+ const detectInvokedVia = () => {
11897
+ const userAgent = process.env.npm_config_user_agent;
11898
+ if (!userAgent) return "unknown";
11899
+ return userAgent.split("/", 1)[0]?.trim() || "unknown";
11900
+ };
11901
+ const detectNodeMajor = () => {
11902
+ const major = Number.parseInt(process.versions.node.split(".", 1)[0] ?? "", 10);
11903
+ return Number.isNaN(major) ? 0 : major;
11904
+ };
11905
+ const detectOrigin = () => {
11906
+ if (process.env.GIT_DIR) return "git-hook";
11907
+ if (isCodingAgentEnvironment()) return "agent";
11908
+ if (isCiEnvironment()) return "ci";
11909
+ return "cli";
11910
+ };
11911
+ const detectCommand = (userArguments) => {
11912
+ for (const argument of userArguments) {
11913
+ if (argument === "--") break;
11914
+ if (argument.startsWith("-")) continue;
11915
+ return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
11916
+ }
11917
+ return "inspect";
11918
+ };
11919
+ /**
11920
+ * Snapshot of the current invocation, attached to Sentry events as the
11921
+ * `run` context to make crashes triage-able (which version, platform,
11922
+ * CI/agent, how it was invoked). Every field is cheap, synchronous, and
11923
+ * safe to read at any point — cwd reads fall back, env reads are
11924
+ * booleans — so it's rebuilt lazily at capture time when runtime-only
11925
+ * signals like `jsonMode` are finally known.
11926
+ */
11927
+ const buildRunContext = () => {
11928
+ const userArguments = process.argv.slice(2);
11929
+ return {
11930
+ version: VERSION,
11931
+ origin: detectOrigin(),
11932
+ command: detectCommand(userArguments),
11933
+ argv: scrubSensitivePaths(userArguments.join(" ")),
11934
+ cwd: scrubSensitivePaths(process.cwd()),
11935
+ node: process.version,
11936
+ nodeMajor: detectNodeMajor(),
11937
+ platform: process.platform,
11938
+ arch: process.arch,
11939
+ ci: isCiEnvironment(),
11940
+ ciProvider: detectCiProvider(),
11941
+ codingAgent: detectCodingAgent(),
11942
+ interactive: !isNonInteractiveEnvironment(),
11943
+ jsonMode: isJsonModeActive(),
11944
+ invokedVia: detectInvokedVia()
11945
+ };
11946
+ };
11947
+ //#endregion
11948
+ //#region src/cli/utils/build-sentry-project-context.ts
11949
+ /**
11950
+ * Projects the {@link ProjectInfo} we already detect during a scan into the
11951
+ * Sentry scope shape: a handful of searchable `project.*` tags plus the
11952
+ * anonymous project *shape* as a `project` context block. Lets crash/transaction
11953
+ * triage answer "which kind of project hit this?" (framework, React/Expo
11954
+ * version, TypeScript, size) without sending source code — and deliberately
11955
+ * omits `projectName` and `rootDirectory`, the two identifying fields, so the
11956
+ * project can't be tied back to a specific company/repo.
11957
+ */
11958
+ const buildSentryProjectContext = (projectInfo) => ({
11959
+ tags: {
11960
+ "project.framework": projectInfo.framework,
11961
+ "project.reactMajor": projectInfo.reactMajorVersion,
11962
+ "project.typescript": projectInfo.hasTypeScript,
11963
+ "project.reactCompiler": projectInfo.hasReactCompiler,
11964
+ "project.expo": projectInfo.expoVersion !== null,
11965
+ "project.reactNative": projectInfo.hasReactNativeWorkspace
11966
+ },
11967
+ context: {
11968
+ framework: projectInfo.framework,
11969
+ reactVersion: projectInfo.reactVersion,
11970
+ reactMajorVersion: projectInfo.reactMajorVersion,
11971
+ hasTypeScript: projectInfo.hasTypeScript,
11972
+ hasReactCompiler: projectInfo.hasReactCompiler,
11973
+ hasTanStackQuery: projectInfo.hasTanStackQuery,
11974
+ tailwindVersion: projectInfo.tailwindVersion,
11975
+ zodVersion: projectInfo.zodVersion,
11976
+ preactVersion: projectInfo.preactVersion,
11977
+ hasReactNativeWorkspace: projectInfo.hasReactNativeWorkspace,
11978
+ expoVersion: projectInfo.expoVersion,
11979
+ hasReanimated: projectInfo.hasReanimated,
11980
+ sourceFileCount: projectInfo.sourceFileCount
11981
+ }
11982
+ });
11983
+ let currentProjectInfo = null;
11984
+ const setSentryProjectInfo = (projectInfo) => {
11985
+ currentProjectInfo = projectInfo;
11986
+ };
11987
+ const getSentryProjectInfo = () => currentProjectInfo;
11988
+ //#endregion
11989
+ //#region src/cli/utils/build-sentry-scope.ts
11990
+ /**
11991
+ * Projects a {@link RunContext} snapshot (plus the current run's
11992
+ * {@link getSentryProjectInfo project info}, when a scan has discovered it) into
11993
+ * the Sentry scope shape — the searchable `tags` that make crashes/transactions
11994
+ * filterable (which command, origin, CI provider, coding agent, Node major,
11995
+ * package manager, project framework/React major) plus the full `run` and
11996
+ * `project` context blocks for deep triage.
11997
+ *
11998
+ * Shared by `instrument.ts` (seeded as `initialScope` so *every* event,
11999
+ * including performance transactions, carries it) and `report-error.ts` (a
12000
+ * capture-time refresh, since runtime-only signals like `jsonMode` and the
12001
+ * scanned project are only known once a command has begun).
12002
+ */
12003
+ const buildSentryScope = (runContext = buildRunContext()) => {
12004
+ const tags = {
12005
+ origin: runContext.origin,
12006
+ command: runContext.command,
12007
+ ci: runContext.ci,
12008
+ ciProvider: runContext.ciProvider,
12009
+ codingAgent: runContext.codingAgent,
12010
+ interactive: runContext.interactive,
12011
+ jsonMode: runContext.jsonMode,
12012
+ invokedVia: runContext.invokedVia,
12013
+ nodeMajor: runContext.nodeMajor
12014
+ };
12015
+ const contexts = { run: { ...runContext } };
12016
+ const projectInfo = getSentryProjectInfo();
12017
+ if (projectInfo) {
12018
+ const project = buildSentryProjectContext(projectInfo);
12019
+ Object.assign(tags, project.tags);
12020
+ contexts.project = project.context;
12021
+ }
12022
+ return {
12023
+ tags,
12024
+ contexts
12025
+ };
12026
+ };
12027
+ //#endregion
12028
+ //#region src/cli/utils/scrub-sentry-event.ts
12029
+ const anonymizeText = (text) => redactSensitiveText(scrubSensitivePaths(text));
12030
+ /**
12031
+ * Recursively rewrites every string within an arbitrary value (object / array /
12032
+ * primitive) through {@link anonymizeText}, mutating in place. Used to sweep the
12033
+ * unstructured corners of an event (contexts, extra, tags, breadcrumb data,
12034
+ * span attributes) where a path or secret could hide.
12035
+ */
12036
+ const anonymizeInPlace = (value) => {
12037
+ if (Array.isArray(value)) {
12038
+ for (let index = 0; index < value.length; index += 1) {
12039
+ const item = value[index];
12040
+ if (typeof item === "string") value[index] = anonymizeText(item);
12041
+ else anonymizeInPlace(item);
12042
+ }
12043
+ return;
12044
+ }
12045
+ if (!isPlainObject(value)) return;
12046
+ for (const key of Object.keys(value)) {
12047
+ const inner = value[key];
12048
+ if (typeof inner === "string") value[key] = anonymizeText(inner);
12049
+ else anonymizeInPlace(inner);
12050
+ }
12051
+ };
12052
+ /**
12053
+ * Anonymizes a Sentry event (error or transaction) before it leaves the
12054
+ * machine. Strips identity the SDK attaches automatically — the IP-bearing
12055
+ * `user`, the `server_name`, and the device `name` (all hostnames) — drops
12056
+ * captured local variables (unbounded, un-anonymizable user data), and scrubs
12057
+ * home-directory paths + known secrets/emails from every remaining string:
12058
+ * messages, stack frames, breadcrumbs, contexts/extra/tags, and span
12059
+ * attributes (e.g. the `inspect.directory` path on the bridged `runInspect`
12060
+ * span).
12061
+ *
12062
+ * Wired into both `beforeSend` and `beforeSendTransaction`. If scrubbing ever
12063
+ * throws, the event is dropped (`null`) rather than risk sending un-anonymized
12064
+ * data — telemetry is best-effort, privacy is not.
12065
+ */
12066
+ const scrubSentryEvent = (event) => {
12067
+ try {
12068
+ delete event.server_name;
12069
+ delete event.user;
12070
+ const device = event.contexts?.device;
12071
+ if (device) delete device.name;
12072
+ if (event.contexts) anonymizeInPlace(event.contexts);
12073
+ if (event.extra) anonymizeInPlace(event.extra);
12074
+ if (event.tags) anonymizeInPlace(event.tags);
12075
+ if (typeof event.message === "string") event.message = anonymizeText(event.message);
12076
+ for (const breadcrumb of event.breadcrumbs ?? []) {
12077
+ if (typeof breadcrumb.message === "string") breadcrumb.message = anonymizeText(breadcrumb.message);
12078
+ if (breadcrumb.data) anonymizeInPlace(breadcrumb.data);
12079
+ }
12080
+ for (const exception of event.exception?.values ?? []) {
12081
+ if (typeof exception.value === "string") exception.value = anonymizeText(exception.value);
12082
+ for (const frame of exception.stacktrace?.frames ?? []) {
12083
+ delete frame.vars;
12084
+ if (typeof frame.filename === "string") frame.filename = scrubSensitivePaths(frame.filename);
12085
+ if (typeof frame.abs_path === "string") frame.abs_path = scrubSensitivePaths(frame.abs_path);
12086
+ if (typeof frame.module === "string") frame.module = scrubSensitivePaths(frame.module);
12087
+ }
12088
+ }
12089
+ for (const span of event.spans ?? []) {
12090
+ if (typeof span.description === "string") span.description = anonymizeText(span.description);
12091
+ if (span.data) anonymizeInPlace(span.data);
12092
+ }
12093
+ return event;
12094
+ } catch {
12095
+ return null;
12096
+ }
12097
+ };
12098
+ //#endregion
12099
+ //#region src/instrument.ts
12100
+ let isInitialized = false;
12101
+ let resolvedTracesSampleRate = 0;
12102
+ const shouldEnableSentry = () => {
12103
+ if (process.argv.includes("--no-score") || process.argv.includes("--no-telemetry")) return false;
12104
+ if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
12105
+ return true;
12106
+ };
12107
+ const isEnvFlagEnabled = (value) => value === "1" || value?.toLowerCase() === "true";
12108
+ /**
12109
+ * A version is a "dev" build when it's the unbuilt placeholder (`0.0.0`) or
12110
+ * carries a prerelease suffix (e.g. the `-dev.<sha>` snapshots published from
12111
+ * CI). Everything else is a real, tagged release.
12112
+ */
12113
+ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
12114
+ /**
12115
+ * Sentry release identifier. `react-doctor@<version>` keeps it unique within
12116
+ * the org and — crucially — matches the value `scripts/sentry-sourcemaps.mjs`
12117
+ * uploads source-map artifacts under, so stack frames symbolicate. Honors the
12118
+ * standard `SENTRY_RELEASE` override.
12119
+ */
12120
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.2.14-dev.bdb9e36`;
12121
+ /**
12122
+ * Deployment environment shown in Sentry's environment filter. Defaults to
12123
+ * `production` for tagged releases and `development` for dev/unbuilt versions,
12124
+ * overridable via the standard `SENTRY_ENVIRONMENT` env var.
12125
+ */
12126
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.2.14-dev.bdb9e36") ? "development" : "production");
12127
+ /**
12128
+ * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
12129
+ * (set to `0` to disable tracing) and falls back to
12130
+ * {@link SENTRY_DEFAULT_TRACES_SAMPLE_RATE}. Invalid / out-of-range values fall
12131
+ * back to the default rather than silently disabling tracing.
12132
+ */
12133
+ const resolveTracesSampleRate = () => {
12134
+ const raw = process.env.SENTRY_TRACES_SAMPLE_RATE;
12135
+ if (raw === void 0 || raw.trim() === "") return 1;
12136
+ const parsed = Number(raw);
12137
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) return 1;
12138
+ return parsed;
12139
+ };
12140
+ /**
12141
+ * Whether performance traces will actually be recorded — Sentry is live and the
12142
+ * resolved sample rate is above zero. Used to gate the per-run root span and
12143
+ * the Effect→Sentry tracer bridge so they're true no-ops when tracing is off.
12144
+ */
12145
+ const isSentryTracingEnabled = () => Sentry.isInitialized() && resolvedTracesSampleRate > 0;
12146
+ /**
12147
+ * Flushes queued Sentry events (errors + transactions) before the CLI exits, so
12148
+ * the success-path transaction is delivered. A no-op when Sentry was never
12149
+ * initialized, and it swallows transport failures so telemetry can never mask
12150
+ * the user's result.
12151
+ */
12152
+ const flushSentry = async () => {
12153
+ if (!Sentry.isInitialized()) return;
12154
+ try {
12155
+ await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
12156
+ } catch {}
12157
+ };
12158
+ /**
12159
+ * Initializes the Sentry Node SDK for CLI crash reporting and performance
12160
+ * tracing. Invoked as the first statement of the CLI entry (`cli/index.ts`) so
12161
+ * the SDK's global `uncaughtException` / `unhandledRejection` handlers and OTel
12162
+ * auto-instrumentation are armed before any command runs.
12163
+ *
12164
+ * Exported as a function rather than a bare side-effecting import because the
12165
+ * package declares `"sideEffects": false`, which lets the bundler tree-shake
12166
+ * side-effect-only modules. An explicit call keeps the initialization in the
12167
+ * published `dist/cli.js`.
12168
+ *
12169
+ * Scoped to the CLI application only — the programmatic `@react-doctor/api`
12170
+ * library never initializes Sentry, so importing `diagnose()` into a consumer
12171
+ * app can't hijack their telemetry.
12172
+ *
12173
+ * Configuration is environment-overridable for self-hosting and tuning:
12174
+ * `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`,
12175
+ * `SENTRY_TRACES_SAMPLE_RATE` (`0` disables tracing), and `SENTRY_DEBUG`.
12176
+ */
12177
+ const initializeSentry = () => {
12178
+ if (isInitialized || !shouldEnableSentry()) return;
12179
+ isInitialized = true;
12180
+ resolvedTracesSampleRate = resolveTracesSampleRate();
12181
+ const { tags, contexts } = buildSentryScope();
12182
+ Sentry.init({
12183
+ dsn: process.env.SENTRY_DSN || "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920",
12184
+ release: resolveSentryRelease(),
12185
+ environment: resolveSentryEnvironment(),
12186
+ sendDefaultPii: false,
12187
+ tracesSampleRate: resolvedTracesSampleRate,
12188
+ debug: isEnvFlagEnabled(process.env.SENTRY_DEBUG),
12189
+ initialScope: {
12190
+ tags,
12191
+ contexts
12192
+ },
12193
+ beforeSend: (event) => scrubSentryEvent(event),
12194
+ beforeSendTransaction: (event) => scrubSentryEvent(event)
12195
+ });
12196
+ };
12197
+ //#endregion
12198
+ //#region src/cli/utils/sentry-tracer.ts
12199
+ const toHrTime = (epochNanoseconds) => [Number(epochNanoseconds / NANOSECONDS_PER_SECOND), Number(epochNanoseconds % NANOSECONDS_PER_SECOND)];
12200
+ const SPAN_KIND_TO_OTEL = {
12201
+ internal: 0,
12202
+ server: 1,
12203
+ client: 2,
12204
+ producer: 3,
12205
+ consumer: 4
12206
+ };
12207
+ const toSentryAttributeValue = (value) => {
12208
+ if (value === null || value === void 0) return void 0;
12209
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
12210
+ return String(value);
12211
+ };
12212
+ const normalizeAttributes = (attributes) => {
12213
+ const normalized = {};
12214
+ if (!attributes) return normalized;
12215
+ for (const [key, value] of Object.entries(attributes)) normalized[key] = toSentryAttributeValue(value);
12216
+ return normalized;
12217
+ };
12218
+ const isSentryBackedSpan = (span) => span._tag === "Span" && "sentrySpan" in span;
12219
+ const spanContextFor = (span) => isSentryBackedSpan(span) ? span.sentrySpan.spanContext() : {
12220
+ traceId: span.traceId,
12221
+ spanId: span.spanId,
12222
+ traceFlags: span.sampled ? 1 : 0
12223
+ };
12224
+ /**
12225
+ * Builds an Effect {@link Tracer.Tracer} that materializes every Effect span
12226
+ * (`Effect.withSpan(...)` / `Effect.fn("Service.method")`) as a child Sentry
12227
+ * span, producing one unified per-run trace in Sentry. The CLI already
12228
+ * instruments `runInspect` and each core service method, so this bridge lights
12229
+ * all of that up in Sentry for free.
12230
+ *
12231
+ * `rootSpan` is the active per-run transaction; Effect spans without an Effect
12232
+ * parent attach to it, so nesting is correct even if async-context propagation
12233
+ * is interrupted by Effect's fiber scheduler. Provided to a program via
12234
+ * `Effect.withTracer(...)`.
12235
+ */
12236
+ const makeSentryTracer = (rootSpan, startInactiveSpan = Sentry.startInactiveSpan) => Tracer.make({ span: (options) => {
12237
+ const parentSpan = Option.isSome(options.parent) && isSentryBackedSpan(options.parent.value) ? options.parent.value.sentrySpan : rootSpan;
12238
+ const sentrySpan = startInactiveSpan({
12239
+ name: options.name,
12240
+ startTime: toHrTime(options.startTime),
12241
+ parentSpan,
12242
+ kind: SPAN_KIND_TO_OTEL[options.kind]
12243
+ });
12244
+ const { traceId, spanId } = sentrySpan.spanContext();
12245
+ const attributes = /* @__PURE__ */ new Map();
12246
+ let status = {
12247
+ _tag: "Started",
12248
+ startTime: options.startTime
12249
+ };
12250
+ return {
12251
+ _tag: "Span",
12252
+ sentrySpan,
12253
+ name: options.name,
12254
+ spanId,
12255
+ traceId,
12256
+ parent: options.parent,
12257
+ annotations: options.annotations,
12258
+ links: options.links,
12259
+ sampled: options.sampled,
12260
+ kind: options.kind,
12261
+ get status() {
12262
+ return status;
12263
+ },
12264
+ get attributes() {
12265
+ return attributes;
12266
+ },
12267
+ end: (endTime, exit) => {
12268
+ status = {
12269
+ _tag: "Ended",
12270
+ startTime: options.startTime,
12271
+ endTime,
12272
+ exit
12273
+ };
12274
+ sentrySpan.setStatus({ code: Exit.isSuccess(exit) ? 1 : 2 });
12275
+ sentrySpan.end(toHrTime(endTime));
12276
+ },
12277
+ attribute: (key, value) => {
12278
+ attributes.set(key, value);
12279
+ sentrySpan.setAttribute(key, toSentryAttributeValue(value));
12280
+ },
12281
+ event: (name, startTime, eventAttributes) => {
12282
+ sentrySpan.addEvent(name, normalizeAttributes(eventAttributes), toHrTime(startTime));
12283
+ },
12284
+ addLinks: (links) => {
12285
+ for (const link of links) sentrySpan.addLink({
12286
+ context: spanContextFor(link.span),
12287
+ attributes: normalizeAttributes(link.attributes)
12288
+ });
12289
+ }
12290
+ };
12291
+ } });
12292
+ //#endregion
12293
+ //#region src/cli/utils/apply-observability.ts
12294
+ const isOtlpExportConfigured = () => Boolean(process.env.REACT_DOCTOR_OTLP_ENDPOINT) && Boolean(process.env.REACT_DOCTOR_OTLP_AUTH_HEADER);
12295
+ const externalSpanFrom = (sentrySpan) => {
12296
+ const { traceId, spanId, traceFlags } = sentrySpan.spanContext();
12297
+ return Tracer.externalSpan({
12298
+ traceId,
12299
+ spanId,
12300
+ sampled: (traceFlags & 1) === 1
12301
+ });
12302
+ };
12303
+ /**
12304
+ * Installs the tracing backend for the inspect program. Effect's tracer is a
12305
+ * single reference, so the backends are mutually exclusive — we pick by
12306
+ * precedence:
12307
+ *
12308
+ * 1. **User OTLP backend** (`REACT_DOCTOR_OTLP_*` set) wins; we additionally
12309
+ * parent the Effect trace under the active Sentry trace via an
12310
+ * `ExternalSpan` so a trace exported to the user's backend shares its
12311
+ * `trace_id` with the corresponding Sentry trace.
12312
+ * 2. **Sentry tracing active** (and no user OTLP): route Effect's existing
12313
+ * span instrumentation straight into Sentry as one unified per-run trace.
12314
+ * 3. **Neither**: provide the (no-op) OTLP layer, leaving Effect's native
12315
+ * in-memory tracer — identical to the prior default behavior.
12316
+ */
12317
+ const applyObservability = (program, rootSentrySpan) => {
12318
+ if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(Effect.provideService(Tracer.ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(Effect.provide(layerOtlp));
12319
+ if (rootSentrySpan) return program.pipe(Effect.withTracer(makeSentryTracer(rootSentrySpan)));
12320
+ return program.pipe(Effect.provide(layerOtlp));
12321
+ };
12322
+ //#endregion
12323
+ //#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
12324
+ const ANSI_BACKGROUND_OFFSET = 10;
12325
+ const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
12326
+ const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
12327
+ const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
12328
+ const styles$1 = {
12329
+ modifier: {
11519
12330
  reset: [0, 0],
11520
12331
  bold: [1, 22],
11521
12332
  dim: [2, 22],
@@ -14456,94 +15267,14 @@ function ora(options) {
14456
15267
  return new Ora(options);
14457
15268
  }
14458
15269
  //#endregion
14459
- //#region src/cli/utils/is-ci-environment.ts
14460
- const CI_ENVIRONMENT_VARIABLES = [
14461
- "GITHUB_ACTIONS",
14462
- "GITLAB_CI",
14463
- "CIRCLECI"
14464
- ];
14465
- const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
14466
- ["GITHUB_ACTIONS", "github-actions"],
14467
- ["GITLAB_CI", "gitlab-ci"],
14468
- ["CIRCLECI", "circleci"],
14469
- ["BUILDKITE", "buildkite"],
14470
- ["JENKINS_URL", "jenkins"],
14471
- ["TF_BUILD", "azure-pipelines"],
14472
- ["CODEBUILD_BUILD_ID", "aws-codebuild"],
14473
- ["TEAMCITY_VERSION", "teamcity"],
14474
- ["BITBUCKET_BUILD_NUMBER", "bitbucket"],
14475
- ["TRAVIS", "travis"],
14476
- ["DRONE", "drone"]
14477
- ];
14478
- const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
14479
- ["CLAUDECODE", "claude-code"],
14480
- ["CLAUDE_CODE", "claude-code"],
14481
- ["CURSOR_AGENT", "cursor"],
14482
- ["CODEX_CI", "codex"],
14483
- ["CODEX_SANDBOX", "codex"],
14484
- ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
14485
- ["OPENCODE", "opencode"],
14486
- ["GOOSE_TERMINAL", "goose"],
14487
- ["AMP_THREAD_ID", "amp"]
14488
- ];
14489
- const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
14490
- const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
14491
- const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
14492
- [...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
14493
- const FALSY_CI_FLAG_VALUES = new Set([
14494
- "",
14495
- "0",
14496
- "false"
14497
- ]);
14498
- const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
14499
- const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
14500
- const detectCiProvider = () => {
14501
- for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
14502
- return isCiFlagSet(process.env.CI) ? "unknown" : null;
14503
- };
14504
- const detectCodingAgentFromValue = () => {
14505
- for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
14506
- const value = process.env[environmentVariable]?.toLowerCase();
14507
- if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
14508
- }
14509
- return null;
14510
- };
14511
- const detectCodingAgent = () => {
14512
- for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
14513
- const agentFromValue = detectCodingAgentFromValue();
14514
- if (agentFromValue) return agentFromValue;
14515
- if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
14516
- return null;
14517
- };
14518
- const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
14519
- const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
14520
- //#endregion
14521
- //#region src/cli/utils/is-non-interactive-environment.ts
14522
- const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
14523
- "CI",
14524
- "GITHUB_ACTIONS",
14525
- "GITLAB_CI",
14526
- "BUILDKITE",
14527
- "JENKINS_URL",
14528
- "TF_BUILD",
14529
- "CODEBUILD_BUILD_ID",
14530
- "TEAMCITY_VERSION",
14531
- "BITBUCKET_BUILD_NUMBER",
14532
- "CIRCLECI",
14533
- "TRAVIS",
14534
- "DRONE",
14535
- "GIT_DIR"
14536
- ];
14537
- const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
14538
- //#endregion
14539
- //#region src/cli/utils/is-spinner-interactive.ts
14540
- const isSpinnerInteractive = (stream = process.stderr) => {
14541
- if (stream.isTTY !== true) return false;
14542
- const columnCount = stream.columns;
14543
- if (!columnCount || columnCount <= 0) return false;
14544
- if (process.env.TERM === "dumb") return false;
14545
- if (isNonInteractiveEnvironment()) return false;
14546
- return true;
15270
+ //#region src/cli/utils/is-spinner-interactive.ts
15271
+ const isSpinnerInteractive = (stream = process.stderr) => {
15272
+ if (stream.isTTY !== true) return false;
15273
+ const columnCount = stream.columns;
15274
+ if (!columnCount || columnCount <= 0) return false;
15275
+ if (process.env.TERM === "dumb") return false;
15276
+ if (isNonInteractiveEnvironment()) return false;
15277
+ return true;
14547
15278
  };
14548
15279
  //#endregion
14549
15280
  //#region src/cli/utils/spinner.ts
@@ -14648,44 +15379,89 @@ const buildRuntimeLayers = (input) => {
14648
15379
  return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
14649
15380
  };
14650
15381
  //#endregion
14651
- //#region src/cli/utils/noop-console.ts
15382
+ //#region src/cli/utils/active-run-trace.ts
15383
+ let activeRunTrace = null;
15384
+ const setActiveRunTrace = (trace) => {
15385
+ activeRunTrace = trace;
15386
+ };
15387
+ const getActiveRunTrace = () => activeRunTrace;
15388
+ //#endregion
15389
+ //#region src/cli/utils/to-span-attributes.ts
14652
15390
  /**
14653
- * A concrete `Console.Console` whose methods are all no-ops.
15391
+ * Converts a Sentry tag map (which permits `null` to denote an absent signal)
15392
+ * into Sentry/OTel span attributes, which only accept primitives. `null` values
15393
+ * are dropped rather than coerced, so an absent signal doesn't become a
15394
+ * misleading `"null"` attribute.
15395
+ */
15396
+ const toSpanAttributes = (tags) => {
15397
+ const attributes = {};
15398
+ for (const [key, value] of Object.entries(tags)) if (value !== null) attributes[key] = value;
15399
+ return attributes;
15400
+ };
15401
+ //#endregion
15402
+ //#region src/cli/utils/with-sentry-run-span.ts
15403
+ /**
15404
+ * Clears the module-level run-scoped Sentry state — the current scanned project
15405
+ * and the active run trace. `inspect()` calls this at the start of every run and
15406
+ * again after a clean one (it's invoked once per project in a workspace scan),
15407
+ * so a prior or just-finished scan can't attach its project tags / trace to a
15408
+ * later run or to a non-scan error (e.g. inspectAction's post-loop
15409
+ * finalize/handoff steps). A thrown scan error skips the post-run reset, leaving
15410
+ * the state for the command catch to attribute and link the crash. Safe to call
15411
+ * when Sentry is off (the refs are read only when an event is built).
15412
+ */
15413
+ const resetSentryRunState = () => {
15414
+ setSentryProjectInfo(null);
15415
+ setActiveRunTrace(null);
15416
+ };
15417
+ /**
15418
+ * Runs an inspect invocation inside a Sentry root span (transaction) so each
15419
+ * `react-doctor` run is a first-class trace with timing and the run snapshot as
15420
+ * attributes. The span is handed to `run` so the Effect→Sentry tracer bridge
15421
+ * can parent its spans under it.
14654
15422
  *
14655
- * Used by `--silent` (provided via
14656
- * `Effect.provideService(Console.Console, makeNoopConsole())`) and by
14657
- * `enableJsonMode` (assigned over the relevant slots on
14658
- * `globalThis.console` so imperative legacy callsites that aren't
14659
- * Effect-typed also fall silent). Sourcing both from a single concrete
14660
- * object keeps "what is a no-op console" answered in one place; the
14661
- * earlier `new Proxy({} as Console.Console, { get: () => () => undefined })`
14662
- * combined a cast with a Proxy to do the same thing implicitly.
15423
+ * A no-op pass-through when Sentry performance tracing is off (Sentry disabled,
15424
+ * `--no-score`, tests, or `SENTRY_TRACES_SAMPLE_RATE=0`) `run` receives
15425
+ * `undefined` and no transaction is created, so there's no added exit latency.
14663
15426
  *
14664
- * The interface mirrors Effect v4's `Console.Console` shape exactly so
14665
- * `Effect.provideService(Console.Console, makeNoopConsole())` requires
14666
- * no cast.
15427
+ * While the span runs, its trace context is recorded as the active run trace so
15428
+ * `reportErrorToSentry` can attach a crash thrown during the scan back to this
15429
+ * transaction's trace (errors surface in the command catch, after the span has
15430
+ * ended). `inspect()` owns clearing it (and the scanned project): it resets the
15431
+ * state right after a clean run and at the start of the next one, so the trace
15432
+ * is never attached to a non-scan error; on a thrown error the state is left in
15433
+ * place for the command catch, then the process exits.
14667
15434
  */
14668
- const makeNoopConsole = () => ({
14669
- assert: () => {},
14670
- clear: () => {},
14671
- count: () => {},
14672
- countReset: () => {},
14673
- debug: () => {},
14674
- dir: () => {},
14675
- dirxml: () => {},
14676
- error: () => {},
14677
- group: () => {},
14678
- groupCollapsed: () => {},
14679
- groupEnd: () => {},
14680
- info: () => {},
14681
- log: () => {},
14682
- table: () => {},
14683
- time: () => {},
14684
- timeEnd: () => {},
14685
- timeLog: () => {},
14686
- trace: () => {},
14687
- warn: () => {}
14688
- });
15435
+ const withSentryRunSpan = (run) => {
15436
+ if (!isSentryTracingEnabled()) return run(void 0);
15437
+ const { tags } = buildSentryScope();
15438
+ const command = typeof tags.command === "string" ? tags.command : "inspect";
15439
+ return Sentry.startSpan({
15440
+ name: `react-doctor ${command}`,
15441
+ op: "cli.inspect",
15442
+ attributes: toSpanAttributes(tags)
15443
+ }, (rootSpan) => {
15444
+ const spanContext = rootSpan.spanContext();
15445
+ setActiveRunTrace({
15446
+ traceId: spanContext.traceId,
15447
+ spanId: spanContext.spanId,
15448
+ sampled: (spanContext.traceFlags & 1) === 1
15449
+ });
15450
+ return run(rootSpan);
15451
+ });
15452
+ };
15453
+ /**
15454
+ * Records the scanned project (discovered in the `beforeLint` hook) for Sentry:
15455
+ * remembers it for the lazy error-capture path (`buildSentryScope` folds it into
15456
+ * exception events) and, when tracing is live, sets it as attributes on the
15457
+ * run's root span so the transaction/trace carries the project shape too.
15458
+ * Always cheap — the span attribute set is skipped when `rootSpan` is absent
15459
+ * (tracing off), and storing the info is a plain assignment.
15460
+ */
15461
+ const recordSentryProjectContext = (projectInfo, rootSpan) => {
15462
+ setSentryProjectInfo(projectInfo);
15463
+ rootSpan?.setAttributes(toSpanAttributes(buildSentryProjectContext(projectInfo).tags));
15464
+ };
14689
15465
  //#endregion
14690
15466
  //#region src/cli/utils/build-no-score-message.ts
14691
15467
  const ENTERPRISE_CONTACT_HINT = `Want something custom to your company? Contact us at ${ENTERPRISE_CONTACT_URL}.`;
@@ -14732,8 +15508,10 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
14732
15508
  return priorityB - priorityA;
14733
15509
  };
14734
15510
  const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
14735
- const FETCH_FIX_RECIPE_LABEL = "Fetch & follow the canonical fix recipe before fixing";
14736
- const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FETCH_FIX_RECIPE_LABEL}: ${buildRulePromptUrl(diagnostic.plugin, diagnostic.rule)}` : null;
15511
+ const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
15512
+ const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
15513
+ const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
15514
+ const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
14737
15515
  //#endregion
14738
15516
  //#region src/cli/utils/box-text.ts
14739
15517
  const ESCAPE = String.fromCharCode(27);
@@ -14864,15 +15642,17 @@ const buildVerboseSiteMap = (diagnostics) => {
14864
15642
  return fileSites;
14865
15643
  };
14866
15644
  const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
15645
+ const formatTrailingSiteBadge = (count) => {
15646
+ const badge = formatSiteCountBadge(count);
15647
+ return badge.length > 0 ? ` ${highlighter.gray(badge)}` : "";
15648
+ };
14867
15649
  const categoryTopRuleKey = (categoryGroup) => categoryGroup.ruleGroups[0][0];
14868
15650
  const buildCategoryDiagnosticGroups = (diagnostics, rulePriority) => {
14869
- return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
14870
- return {
14871
- category,
14872
- diagnostics: categoryDiagnostics,
14873
- ruleGroups: sortRuleGroupsByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority)
14874
- };
14875
- }).toSorted((categoryGroupA, categoryGroupB) => {
15651
+ return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => ({
15652
+ category,
15653
+ diagnostics: categoryDiagnostics,
15654
+ ruleGroups: buildSortedRuleGroups(categoryDiagnostics, rulePriority)
15655
+ })).toSorted((categoryGroupA, categoryGroupB) => {
14876
15656
  const priorityDelta = compareByRulePriority(categoryTopRuleKey(categoryGroupA), categoryTopRuleKey(categoryGroupB), rulePriority);
14877
15657
  if (priorityDelta !== 0) return priorityDelta;
14878
15658
  return categoryGroupA.category.localeCompare(categoryGroupB.category);
@@ -14888,6 +15668,7 @@ const buildCompactCategoryLine = (categoryGroup) => {
14888
15668
  };
14889
15669
  const TOP_ERROR_DETAIL_INDENT = " ";
14890
15670
  const pickRepresentativeDiagnostic = (ruleDiagnostics) => ruleDiagnostics.find((diagnostic) => diagnostic.line > 0) ?? ruleDiagnostics[0];
15671
+ const isErrorRuleGroup = (ruleDiagnostics) => pickRepresentativeDiagnostic(ruleDiagnostics).severity === "error";
14891
15672
  const FRAME_CONTEXT_REACH_LINES = 3;
14892
15673
  const clusterNearbyDiagnostics = (diagnostics) => {
14893
15674
  const byFile = groupBy(diagnostics, (diagnostic) => diagnostic.filePath);
@@ -14919,17 +15700,17 @@ const formatClusterLocation = (cluster) => {
14919
15700
  if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
14920
15701
  return `${filePath}:${cluster.startLine}`;
14921
15702
  };
14922
- const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
15703
+ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
14923
15704
  const lead = cluster.diagnostics[0];
14924
15705
  const isMultiSite = cluster.diagnostics.length > 1;
14925
15706
  const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
14926
- const codeFrame = buildCodeFrame({
15707
+ const codeFrame = renderCodeFrame ? buildCodeFrame({
14927
15708
  filePath: lead.filePath,
14928
15709
  line: cluster.startLine,
14929
15710
  column: isMultiSite ? 0 : lead.column,
14930
15711
  endLine: isMultiSite ? cluster.endLine : void 0,
14931
15712
  rootDirectory: resolveSourceRoot(lead)
14932
- });
15713
+ }) : null;
14933
15714
  if (codeFrame) lines.push(indentMultilineText(boxText(codeFrame, 60), TOP_ERROR_DETAIL_INDENT));
14934
15715
  const seenHints = /* @__PURE__ */ new Set();
14935
15716
  for (const diagnostic of cluster.diagnostics) if (diagnostic.suppressionHint && !seenHints.has(diagnostic.suppressionHint)) {
@@ -14941,23 +15722,60 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
14941
15722
  const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite) => {
14942
15723
  const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
14943
15724
  const { severity } = representative;
14944
- const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
14945
- const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
15725
+ const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
14946
15726
  const headline = colorizeBySeverity(`${representative.category}: ${representative.title ?? ruleKey}`, severity);
14947
15727
  const lines = [` ${colorizeBySeverity(severity === "error" ? "✗" : "⚠", severity)} ${headline}${trailingBadge}`];
14948
15728
  if (!renderEverySite) for (const explanationLine of wrapTextToWidth(representative.message, 60, { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
14949
15729
  if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, 60, { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
15730
+ const renderCodeFrame = severity === "error";
14950
15731
  const sites = renderEverySite ? ruleDiagnostics : [representative];
14951
- for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot));
15732
+ for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
14952
15733
  return lines;
14953
15734
  };
14954
- const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => {
14955
- return sortRuleGroupsByImportance([...groupBy(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority).slice(0, limit);
15735
+ const WARNING_DETAIL_INDENT = " ";
15736
+ const computeRuleNameColumnWidth = (ruleKeys) => ruleKeys.reduce((widest, ruleKey) => Math.max(widest, ruleKey.length), 36);
15737
+ const padRuleNameToColumn = (ruleName, columnWidth) => ruleName.length >= columnWidth ? ruleName : ruleName + " ".repeat(columnWidth - ruleName.length);
15738
+ const buildWarningHeaderLine = (ruleKey, siteCount, ruleNameColumnWidth) => {
15739
+ const ruleName = formatSiteCountBadge(siteCount).length > 0 ? padRuleNameToColumn(ruleKey, ruleNameColumnWidth) : ruleKey;
15740
+ return ` ${highlighter.warn("⚠")} ${ruleName}${formatTrailingSiteBadge(siteCount)}`;
15741
+ };
15742
+ const buildWarningRuleBlock = (ruleKey, ruleDiagnostics, ruleNameColumnWidth, isAgentEnvironment) => {
15743
+ const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
15744
+ const lines = [buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth)];
15745
+ if (!isAgentEnvironment) {
15746
+ const learnMoreLine = formatLearnMoreLine(representative);
15747
+ if (learnMoreLine) lines.push(`${WARNING_DETAIL_INDENT}${highlighter.info(learnMoreLine)}`);
15748
+ }
15749
+ lines.push(highlighter.gray(indentMultilineText(representative.message, WARNING_DETAIL_INDENT)));
15750
+ if (representative.help) lines.push(highlighter.gray(indentMultilineText(`→ ${representative.help}`, WARNING_DETAIL_INDENT)));
15751
+ if (isAgentEnvironment) {
15752
+ const fixRecipeLine = formatFixRecipeLine(representative);
15753
+ if (fixRecipeLine) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${fixRecipeLine}`));
15754
+ }
15755
+ for (const [filePath, sites] of buildVerboseSiteMap(ruleDiagnostics)) {
15756
+ if (sites.length === 0) {
15757
+ lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}`));
15758
+ continue;
15759
+ }
15760
+ for (const site of sites) {
15761
+ lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}:${site.line}`));
15762
+ if (site.suppressionHint) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT} ↳ ${site.suppressionHint}`));
15763
+ }
15764
+ }
15765
+ return lines;
15766
+ };
15767
+ const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
15768
+ const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => selectErrorRuleGroups(diagnostics, rulePriority).slice(0, limit);
15769
+ const buildMoreRulesLine = (hiddenRuleCount, severityNoun, accent) => {
15770
+ const ruleNoun = hiddenRuleCount === 1 ? "rule" : "rules";
15771
+ 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`)}`;
14956
15772
  };
14957
15773
  const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
14958
15774
  const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
14959
- const topRuleGroups = selectTopErrorRuleGroups(diagnostics, 3, rulePriority);
15775
+ const errorRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority);
15776
+ const topRuleGroups = errorRuleGroups.slice(0, 3);
14960
15777
  if (topRuleGroups.length === 0) return [];
15778
+ const hiddenRuleCount = errorRuleGroups.length - topRuleGroups.length;
14961
15779
  const lines = [
14962
15780
  highlighter.dim(` ${"─".repeat(60)}`),
14963
15781
  ` ${highlighter.bold(`Top ${topRuleGroups.length} ${topRuleGroups.length === 1 ? "error" : "errors"} you should fix`)}`,
@@ -14967,6 +15785,23 @@ const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
14967
15785
  lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false));
14968
15786
  lines.push("");
14969
15787
  }
15788
+ if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "errors", highlighter.error));
15789
+ return lines;
15790
+ };
15791
+ const buildWarningsListLines = (diagnostics, rulePriority) => {
15792
+ const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
15793
+ if (warningDiagnostics.length === 0) return [];
15794
+ const sortedRuleGroups = buildSortedRuleGroups(warningDiagnostics, rulePriority);
15795
+ const shownRuleGroups = sortedRuleGroups.slice(0, 10);
15796
+ const hiddenRuleCount = sortedRuleGroups.length - shownRuleGroups.length;
15797
+ const ruleNameColumnWidth = computeRuleNameColumnWidth(shownRuleGroups.map(([ruleKey]) => ruleKey));
15798
+ const lines = [
15799
+ highlighter.dim(` ${"─".repeat(60)}`),
15800
+ ` ${highlighter.bold(`${warningDiagnostics.length} ${warningDiagnostics.length === 1 ? "warning" : "warnings"}`)}`,
15801
+ ""
15802
+ ];
15803
+ for (const [ruleKey, ruleDiagnostics] of shownRuleGroups) lines.push(buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth));
15804
+ if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "warnings", highlighter.warn));
14970
15805
  return lines;
14971
15806
  };
14972
15807
  const buildCategoryBreakdownLines = (diagnostics, rulePriority) => buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCompactCategoryLine);
@@ -14993,12 +15828,18 @@ const buildCountsSummaryLines = (diagnostics) => {
14993
15828
  * single Effect.forEach over Console.log so failures or fiber
14994
15829
  * interruption produce predictable partial output.
14995
15830
  */
14996
- const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority) => Effect.gen(function* () {
15831
+ const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false) => Effect.gen(function* () {
14997
15832
  const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
14998
15833
  let detailLines;
14999
15834
  if (!isVerbose) detailLines = buildTopErrorsLines(diagnostics, resolveSourceRoot, rulePriority);
15000
- else detailLines = sortRuleGroupsByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority).flatMap(([ruleKey, ruleDiagnostics]) => [...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true), ""]);
15001
- const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines);
15835
+ else {
15836
+ const sortedRuleGroups = buildSortedRuleGroups(diagnostics, rulePriority);
15837
+ const warningRuleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.filter(([, ruleDiagnostics]) => !isErrorRuleGroup(ruleDiagnostics)).map(([ruleKey]) => ruleKey));
15838
+ detailLines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => {
15839
+ return [...isErrorRuleGroup(ruleDiagnostics) ? buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true) : buildWarningRuleBlock(ruleKey, ruleDiagnostics, warningRuleNameColumnWidth, isAgentEnvironment), ""];
15840
+ });
15841
+ }
15842
+ const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines, isVerbose ? [] : buildWarningsListLines(diagnostics, rulePriority));
15002
15843
  for (const line of lines) yield* Console.log(line);
15003
15844
  });
15004
15845
  const formatElapsedTime = (elapsedMilliseconds) => {
@@ -15240,8 +16081,7 @@ const printNoScoreHeader = (noScoreMessage) => Effect.gen(function* () {
15240
16081
  const writeDiagnosticsDirectory = (diagnostics) => {
15241
16082
  const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
15242
16083
  mkdirSync(outputDirectory, { recursive: true });
15243
- const sortedRuleGroups = sortRuleGroupsByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
15244
- for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
16084
+ for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(diagnostics)) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
15245
16085
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
15246
16086
  return outputDirectory;
15247
16087
  };
@@ -15261,7 +16101,14 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
15261
16101
  };
15262
16102
  const printVerboseTip = (diagnostics, isVerbose) => Effect.gen(function* () {
15263
16103
  if (isVerbose || diagnostics.length === 0) return;
15264
- yield* Console.log(highlighter.dim(` Tip: Run ${highlighter.info("npx react-doctor@latest --verbose")} to list every issue`));
16104
+ const command = highlighter.info("npx react-doctor@latest --verbose");
16105
+ 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`;
16106
+ yield* Console.log(highlighter.dim(` Tip: ${message}`));
16107
+ });
16108
+ const printDocsNote = () => Effect.gen(function* () {
16109
+ yield* Console.log("");
16110
+ yield* Console.log(` ${highlighter.bold("Docs:")} ${highlighter.info(DOCS_URL)}`);
16111
+ yield* Console.log(highlighter.dim(" Set up CI/CD, suppress rules with a config file, and scan diffs or PRs."));
15265
16112
  });
15266
16113
  const printSummary = (input) => Effect.gen(function* () {
15267
16114
  if (input.scoreResult) {
@@ -15456,7 +16303,7 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
15456
16303
  customRulesOnly: userConfig?.customRulesOnly ?? false,
15457
16304
  share: userConfig?.share ?? true,
15458
16305
  respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
15459
- warnings: inputOptions.warnings ?? userConfig?.warnings ?? false,
16306
+ warnings: inputOptions.warnings ?? userConfig?.warnings ?? true,
15460
16307
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
15461
16308
  ignoredTags: buildIgnoredTags(userConfig),
15462
16309
  outputSurface: inputOptions.outputSurface ?? "cli",
@@ -15465,6 +16312,7 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
15465
16312
  });
15466
16313
  const inspect = async (directory, inputOptions = {}) => {
15467
16314
  const startTime = performance.now();
16315
+ resetSentryRunState();
15468
16316
  const hasConfigOverride = inputOptions.configOverride !== void 0;
15469
16317
  let scanDirectory;
15470
16318
  let userConfig;
@@ -15474,7 +16322,7 @@ const inspect = async (directory, inputOptions = {}) => {
15474
16322
  userConfig = inputOptions.configOverride ?? null;
15475
16323
  configSourceDirectory = null;
15476
16324
  } else {
15477
- const scanTarget = resolveScanTarget(directory);
16325
+ const scanTarget = await resolveScanTarget(directory);
15478
16326
  scanDirectory = scanTarget.resolvedDirectory;
15479
16327
  userConfig = scanTarget.userConfig;
15480
16328
  configSourceDirectory = scanTarget.configSourceDirectory;
@@ -15483,12 +16331,14 @@ const inspect = async (directory, inputOptions = {}) => {
15483
16331
  const wasSpinnerSilent = isSpinnerSilent();
15484
16332
  if (options.silent) setSpinnerSilent(true);
15485
16333
  try {
15486
- return await runInspectWithRuntime(scanDirectory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime);
16334
+ const result = await withSentryRunSpan((rootSentrySpan) => runInspectWithRuntime(scanDirectory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, rootSentrySpan));
16335
+ resetSentryRunState();
16336
+ return result;
15487
16337
  } finally {
15488
16338
  if (options.silent) setSpinnerSilent(wasSpinnerSilent);
15489
16339
  }
15490
16340
  };
15491
- const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime) => {
16341
+ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, rootSentrySpan) => {
15492
16342
  const isDiffMode = options.includePaths.length > 0;
15493
16343
  const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
15494
16344
  const lintBindingMissing = options.lint && !resolvedNodeBinaryPath;
@@ -15519,6 +16369,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
15519
16369
  resolveLocalGithubViewerPermission: !options.noScore,
15520
16370
  suppressScanSummary: options.suppressRendering
15521
16371
  }, { beforeLint: (projectInfo, lintIncludePaths) => Effect.gen(function* () {
16372
+ recordSentryProjectContext(projectInfo, rootSentrySpan);
15522
16373
  if (options.scoreOnly || options.suppressRendering) return;
15523
16374
  const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
15524
16375
  yield* printProjectDetection({
@@ -15529,7 +16380,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
15529
16380
  lintSourceFileCount
15530
16381
  });
15531
16382
  }) });
15532
- 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));
16383
+ const programWithLayers = applyObservability(options.silent ? program.pipe(Effect.provide(layers), Effect.provideService(Console.Console, silentConsole)) : program.pipe(Effect.provide(layers)), rootSentrySpan);
15533
16384
  const output = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
15534
16385
  const didLintFail = lintBindingMissing || output.didLintFail;
15535
16386
  const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
@@ -15602,7 +16453,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
15602
16453
  return buildResult();
15603
16454
  }
15604
16455
  yield* Console.log("");
15605
- yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]));
16456
+ yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment());
15606
16457
  if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
15607
16458
  if (demotedDiagnosticCount > 0) {
15608
16459
  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.`));
@@ -15627,6 +16478,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
15627
16478
  yield* Console.warn(highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`));
15628
16479
  }
15629
16480
  yield* printVerboseTip([...surfaceDiagnostics], options.verbose);
16481
+ yield* printDocsNote();
15630
16482
  return buildResult();
15631
16483
  });
15632
16484
  //#endregion
@@ -15671,7 +16523,7 @@ const getErrorReportContext = () => ({
15671
16523
  isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE])
15672
16524
  });
15673
16525
  const formatConfiguredState = (isConfigured) => isConfigured ? "yes" : "no";
15674
- const buildErrorIssueBody = (error, context) => {
16526
+ const buildErrorIssueBody = (error, context, sentryEventId) => {
15675
16527
  const formattedError = formatErrorForReport(error) || "(empty error)";
15676
16528
  const isOtlpExporterEnabled = context.isOtlpEndpointConfigured && context.isOtlpAuthHeaderConfigured;
15677
16529
  return [
@@ -15688,6 +16540,7 @@ const buildErrorIssueBody = (error, context) => {
15688
16540
  `- platform: ${context.platform} ${context.architecture}`,
15689
16541
  `- cwd: ${context.cwd}`,
15690
16542
  `- command: ${context.command}`,
16543
+ ...sentryEventId ? [`- Sentry reference: ${sentryEventId}`] : [],
15691
16544
  "",
15692
16545
  "## OpenTelemetry",
15693
16546
  "",
@@ -15701,12 +16554,12 @@ const buildErrorIssueBody = (error, context) => {
15701
16554
  "Please add reproduction steps and any relevant repository details."
15702
16555
  ].join("\n");
15703
16556
  };
15704
- const buildErrorIssueUrl = (error) => {
16557
+ const buildErrorIssueUrl = (error, sentryEventId) => {
15705
16558
  const formattedError = formatSingleLine(formatErrorForReport(error));
15706
16559
  const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
15707
16560
  issueUrl.searchParams.set("title", formattedError ? `CLI error: ${formattedError}` : "CLI error");
15708
16561
  issueUrl.searchParams.set("labels", "bug");
15709
- issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext()));
16562
+ issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext(), sentryEventId));
15710
16563
  return issueUrl.toString();
15711
16564
  };
15712
16565
  /**
@@ -15716,11 +16569,12 @@ const buildErrorIssueUrl = (error) => {
15716
16569
  * red-highlighted (matches the historical `consoleLogger.error`
15717
16570
  * contract) so the user sees a clearly distinguished error block.
15718
16571
  */
15719
- const handleErrorEffect = (error) => Effect.gen(function* () {
16572
+ const handleErrorEffect = (error, sentryEventId) => Effect.gen(function* () {
15720
16573
  yield* Console.error("");
15721
16574
  yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
15722
- yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
16575
+ yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error, sentryEventId)}`));
15723
16576
  yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
16577
+ if (sentryEventId) yield* Console.error(highlighter.error(`Reference (mention this when reporting): ${sentryEventId}`));
15724
16578
  yield* Console.error("");
15725
16579
  yield* Console.error(highlighter.error(formatErrorForReport(error)));
15726
16580
  yield* Console.error("");
@@ -15730,15 +16584,15 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
15730
16584
  * aren't yet Effect-typed). Bridges via `Effect.runSync` so the
15731
16585
  * underlying Console writes happen exactly like the Effect path.
15732
16586
  */
15733
- const handleError = (error, options = { shouldExit: true }) => {
15734
- Effect.runSync(handleErrorEffect(error));
16587
+ const handleError = (error, options = {}) => {
16588
+ Effect.runSync(handleErrorEffect(error, options.sentryEventId));
15735
16589
  if (options.shouldExit !== false) process.exit(1);
15736
16590
  process.exitCode = 1;
15737
16591
  };
15738
16592
  //#endregion
15739
16593
  //#region src/cli/utils/build-handoff-payload.ts
15740
16594
  const buildHandoffPayload = (input) => {
15741
- const topGroups = sortRuleGroupsByImportance([...groupBy([...input.diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)]).slice(0, 3);
16595
+ const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
15742
16596
  let diagnosticsDirectory = null;
15743
16597
  try {
15744
16598
  diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
@@ -15981,7 +16835,7 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
15981
16835
  const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
15982
16836
  const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
15983
16837
  const CURSOR_HOOKS_SCHEMA_VERSION = 1;
15984
- const JSON_INDENT_SPACES = 2;
16838
+ const JSON_INDENT_SPACES$1 = 2;
15985
16839
  const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
15986
16840
  const readJsonFile = (filePath, fallback) => {
15987
16841
  if (!existsSync(filePath)) return fallback;
@@ -15991,7 +16845,7 @@ const readJsonFile = (filePath, fallback) => {
15991
16845
  };
15992
16846
  const writeJsonFile = (filePath, value) => {
15993
16847
  mkdirSync(path.dirname(filePath), { recursive: true });
15994
- writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES)}\n`);
16848
+ writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
15995
16849
  };
15996
16850
  const writeHookScript = (filePath) => {
15997
16851
  mkdirSync(path.dirname(filePath), { recursive: true });
@@ -16757,6 +17611,29 @@ const getSkillSourceDirectory = () => {
16757
17611
  const distDirectory = path.dirname(fileURLToPath(import.meta.url));
16758
17612
  return path.join(distDirectory, "skills", SKILL_NAME);
16759
17613
  };
17614
+ const findBundledSiblingSkills = (primarySkillDir) => {
17615
+ const skillsParent = path.dirname(primarySkillDir);
17616
+ if (!existsSync(skillsParent)) return [];
17617
+ const resolvedPrimary = path.resolve(primarySkillDir);
17618
+ return readdirSync(skillsParent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => ({
17619
+ name: entry.name,
17620
+ source: path.join(skillsParent, entry.name)
17621
+ })).filter((sibling) => path.resolve(sibling.source) !== resolvedPrimary && existsSync(path.join(sibling.source, SKILL_MANIFEST_FILE)));
17622
+ };
17623
+ const installBundledSiblingSkills = async (primarySkillDir, agents, projectRoot) => {
17624
+ const installedSkillNames = [];
17625
+ for (const sibling of findBundledSiblingSkills(primarySkillDir)) {
17626
+ const result = await installSkillsFromSource({
17627
+ source: sibling.source,
17628
+ agents: [...agents],
17629
+ cwd: projectRoot,
17630
+ mode: "copy"
17631
+ });
17632
+ if (result.failed.length > 0) throw new Error(result.failed.map((failure) => `${getSkillAgentConfig(failure.agent).displayName}: ${failure.error}`).join("\n"));
17633
+ if (result.skills.length > 0) installedSkillNames.push(sibling.name);
17634
+ }
17635
+ return installedSkillNames;
17636
+ };
16760
17637
  const canInstallNativeAgentHooks = (agents) => agents.some((agent) => agent === "claude-code" || agent === "cursor");
16761
17638
  const buildWorkflowContent = () => [
16762
17639
  "name: React Doctor",
@@ -16863,6 +17740,7 @@ const runInstallReactDoctor = async (options = {}) => {
16863
17740
  cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
16864
17741
  for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
16865
17742
  cliLogger.dim(` Source: ${sourceDir}`);
17743
+ for (const sibling of findBundledSiblingSkills(sourceDir)) cliLogger.dim(` Also installs skill: ${sibling.name}`);
16866
17744
  cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
16867
17745
  cliLogger.dim(" Dev dependency: react-doctor");
16868
17746
  if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
@@ -16885,6 +17763,12 @@ const runInstallReactDoctor = async (options = {}) => {
16885
17763
  installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
16886
17764
  throw error;
16887
17765
  }
17766
+ try {
17767
+ const installedSiblingSkills = await installBundledSiblingSkills(sourceDir, selectedAgents, projectRoot);
17768
+ if (installedSiblingSkills.length > 0) cliLogger.dim(` Also installed the ${installedSiblingSkills.join(", ")} skill.`);
17769
+ } catch {
17770
+ cliLogger.dim(" Skipped bundled sibling skills (install error).");
17771
+ }
16888
17772
  await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
16889
17773
  if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
16890
17774
  const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
@@ -17070,75 +17954,68 @@ const handoffToAgent = async (input) => {
17070
17954
  }
17071
17955
  };
17072
17956
  //#endregion
17073
- //#region src/cli/utils/json-mode.ts
17074
- let context = null;
17957
+ //#region src/cli/utils/read-object-file.ts
17075
17958
  /**
17076
- * JSON mode writes the report payload to stdout; any incidental log
17077
- * line printed by an Effect program would corrupt the JSON. Effect's
17078
- * `Console` module resolves to `globalThis.console` by default (see
17079
- * `effect/internal/effect.ts` → `ConsoleRef`), so copying the methods
17080
- * from `makeNoopConsole()` onto the global is enough to silence every
17081
- * `yield* Console.log(...)` and `cliLogger.*` call sourced from
17082
- * react-doctor or its services.
17083
- *
17084
- * We use the same `makeNoopConsole()` source as the `--silent` path
17085
- * (which provides the Effect Console via
17086
- * `Effect.provideService(Console.Console, makeNoopConsole())`) — one
17087
- * canonical "no-op console" definition shared by the two silent
17088
- * mechanisms. The two routes still differ in how they install the
17089
- * noop: silent mode swaps the Effect Console reference inside the
17090
- * program; JSON mode patches the global because the surrounding CLI
17091
- * command body is still imperative. Both will collapse into the
17092
- * Effect-typed route once the command body finishes its migration.
17093
- *
17094
- * JSON mode is one-shot per CLI invocation, so we never restore.
17959
+ * Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
17960
+ * unparseable, or not an object. JSON5 parsing tolerates comments and
17961
+ * trailing commas so hand-edited config files round-trip.
17095
17962
  */
17096
- const installSilentConsole = () => {
17097
- const noopConsole = makeNoopConsole();
17098
- const target = globalThis.console;
17099
- const source = noopConsole;
17100
- for (const key of [
17101
- "log",
17102
- "error",
17103
- "warn",
17104
- "info",
17105
- "debug",
17106
- "trace"
17107
- ]) target[key] = source[key];
17108
- };
17109
- const enableJsonMode = ({ compact, directory }) => {
17110
- context = {
17111
- compact,
17112
- directory,
17113
- startTime: performance.now(),
17114
- mode: "full"
17115
- };
17116
- installSilentConsole();
17117
- };
17118
- const isJsonModeActive = () => context !== null;
17119
- const setJsonReportDirectory = (directory) => {
17120
- if (context) context.directory = directory;
17121
- };
17122
- const setJsonReportMode = (mode) => {
17123
- if (context) context.mode = mode;
17124
- };
17125
- const writeJsonReport = (report) => {
17126
- const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
17127
- process.stdout.write(`${serialized}\n`);
17128
- };
17129
- const writeJsonErrorReport = (error) => {
17130
- if (!context) return;
17963
+ const readObjectFile = (filePath) => {
17131
17964
  try {
17132
- writeJsonReport(buildJsonReportError({
17133
- version: VERSION,
17134
- directory: context.directory,
17135
- error,
17136
- elapsedMilliseconds: performance.now() - context.startTime,
17137
- mode: context.mode
17138
- }));
17965
+ const parsed = parseJSON5(readFileSync(filePath, "utf-8"));
17966
+ return isPlainObject(parsed) ? parsed : null;
17139
17967
  } catch {
17140
- process.stdout.write(INTERNAL_ERROR_JSON_FALLBACK);
17968
+ return null;
17969
+ }
17970
+ };
17971
+ //#endregion
17972
+ //#region src/cli/utils/serialize-ts-object-literal.ts
17973
+ const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
17974
+ const INDENT_UNIT = " ";
17975
+ const serializeKey = (key) => SAFE_IDENTIFIER_PATTERN.test(key) ? key : JSON.stringify(key);
17976
+ /**
17977
+ * Serializes a JSON-compatible value as an idiomatic TypeScript literal:
17978
+ * identifier-shaped object keys stay unquoted, two-space indented, no blank
17979
+ * lines. Intended for JSON-sourced config values (string / number / boolean /
17980
+ * null / array / plain object); any other type falls back to its JSON form.
17981
+ */
17982
+ const serializeTsObjectLiteral = (value, depth = 0) => {
17983
+ const indent = INDENT_UNIT.repeat(depth);
17984
+ const childIndent = INDENT_UNIT.repeat(depth + 1);
17985
+ if (Array.isArray(value)) {
17986
+ if (value.length === 0) return "[]";
17987
+ return `[\n${value.map((item) => `${childIndent}${serializeTsObjectLiteral(item, depth + 1)}`).join(",\n")}\n${indent}]`;
17141
17988
  }
17989
+ if (isPlainObject(value)) {
17990
+ const keys = Object.keys(value);
17991
+ if (keys.length === 0) return "{}";
17992
+ return `{\n${keys.map((key) => `${childIndent}${serializeKey(key)}: ${serializeTsObjectLiteral(value[key], depth + 1)}`).join(",\n")}\n${indent}}`;
17993
+ }
17994
+ return JSON.stringify(value);
17995
+ };
17996
+ //#endregion
17997
+ //#region src/cli/utils/migrate-legacy-config.ts
17998
+ const MIGRATED_CONFIG_FILENAME = "doctor.config.ts";
17999
+ /**
18000
+ * Renames a pre-migration `react-doctor.config.json` to a typed
18001
+ * `doctor.config.ts`, preserving the user's settings as the default export.
18002
+ * `$schema` is dropped — the `ReactDoctorConfig` type supersedes it for
18003
+ * editor autocomplete. Returns the new file's absolute path, or `null` when
18004
+ * the legacy file can't be parsed as an object (left untouched so the user
18005
+ * can resolve it by hand).
18006
+ */
18007
+ const migrateLegacyConfig = (legacy) => {
18008
+ const parsed = readObjectFile(legacy.legacyFilePath);
18009
+ if (!parsed) return null;
18010
+ const config = { ...parsed };
18011
+ delete config.$schema;
18012
+ const targetPath = path.join(legacy.directory, MIGRATED_CONFIG_FILENAME);
18013
+ writeFileSync(targetPath, `import type { ReactDoctorConfig } from "react-doctor/api";
18014
+
18015
+ export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
18016
+ `);
18017
+ rmSync(legacy.legacyFilePath, { force: true });
18018
+ return targetPath;
17142
18019
  };
17143
18020
  //#endregion
17144
18021
  //#region src/cli/utils/annotation-encoding.ts
@@ -17170,76 +18047,43 @@ const printBrandedHeader = Effect.gen(function* () {
17170
18047
  yield* Console.log("");
17171
18048
  });
17172
18049
  //#endregion
17173
- //#region src/cli/utils/build-run-context.ts
17174
- const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
17175
- const detectOrigin = () => {
17176
- if (process.env.GIT_DIR) return "git-hook";
17177
- if (isCodingAgentEnvironment()) return "agent";
17178
- if (isCiEnvironment()) return "ci";
17179
- return "cli";
17180
- };
17181
- const detectCommand = (userArguments) => {
17182
- for (const argument of userArguments) {
17183
- if (argument === "--") break;
17184
- if (argument.startsWith("-")) continue;
17185
- return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
17186
- }
17187
- return "inspect";
17188
- };
17189
- /**
17190
- * Snapshot of the current invocation, attached to Sentry events as the
17191
- * `run` context to make crashes triage-able (which version, platform,
17192
- * CI/agent, how it was invoked). Every field is cheap, synchronous, and
17193
- * safe to read at any point — cwd reads fall back, env reads are
17194
- * booleans — so it's rebuilt lazily at capture time when runtime-only
17195
- * signals like `jsonMode` are finally known.
17196
- */
17197
- const buildRunContext = () => {
17198
- const userArguments = process.argv.slice(2);
17199
- return {
17200
- version: VERSION,
17201
- origin: detectOrigin(),
17202
- command: detectCommand(userArguments),
17203
- argv: userArguments.join(" "),
17204
- cwd: process.cwd(),
17205
- node: process.version,
17206
- platform: process.platform,
17207
- arch: process.arch,
17208
- ci: isCiEnvironment(),
17209
- ciProvider: detectCiProvider(),
17210
- codingAgent: detectCodingAgent(),
17211
- interactive: !isNonInteractiveEnvironment(),
17212
- jsonMode: isJsonModeActive()
17213
- };
17214
- };
17215
- //#endregion
17216
18050
  //#region src/cli/utils/report-error.ts
17217
18051
  /**
17218
- * Sends an error to Sentry, enriched with a snapshot of the current run
17219
- * (version, platform, CI/agent, invocation), and waits for delivery
17220
- * before the caller exits. The CLI tears down the process synchronously
17221
- * after rendering an error, so the awaited `flush` is what actually gets
17222
- * the event off the machine (see the Sentry CLI/serverless flush
17223
- * contract).
18052
+ * Sends an error to Sentry enriched with a fresh snapshot of the current run
18053
+ * (version, platform, CI/agent, invocation, scanned project) and, when a run
18054
+ * transaction is in flight, linked to its trace via the scope's propagation
18055
+ * context so the crash and its transaction share a `trace_id` then waits for
18056
+ * delivery before the caller exits. The CLI tears down synchronously after
18057
+ * rendering an error, so the awaited `flush` is what actually gets the event
18058
+ * (and any in-flight transaction) off the machine.
17224
18059
  *
17225
- * Returns early when Sentry was never initialized (`--no-score`, tests,
17226
- * or a missing DSN), and swallows any transport failure so telemetry can
17227
- * never mask the user's original error.
18060
+ * Returns the Sentry event id so the caller can surface it as a reference the
18061
+ * user can quote when reporting the bug; returns `undefined` when Sentry was
18062
+ * never initialized (`--no-score`, tests, or a missing DSN) or delivery failed.
18063
+ * Swallows any transport failure so telemetry can never mask the user's
18064
+ * original error.
17228
18065
  */
17229
18066
  const reportErrorToSentry = async (error) => {
17230
- if (!Sentry.isInitialized()) return;
18067
+ if (!Sentry.isInitialized()) return void 0;
17231
18068
  try {
17232
- const runContext = buildRunContext();
17233
- Sentry.setContext("run", { ...runContext });
17234
- Sentry.setTags({
17235
- origin: runContext.origin,
17236
- command: runContext.command,
17237
- ciProvider: runContext.ciProvider,
17238
- codingAgent: runContext.codingAgent
18069
+ const { tags, contexts } = buildSentryScope();
18070
+ const runTrace = getActiveRunTrace();
18071
+ const eventId = Sentry.withScope((scope) => {
18072
+ for (const [name, context] of Object.entries(contexts)) scope.setContext(name, context);
18073
+ scope.setTags(tags);
18074
+ if (runTrace) scope.setPropagationContext({
18075
+ traceId: runTrace.traceId,
18076
+ parentSpanId: runTrace.spanId,
18077
+ sampled: runTrace.sampled,
18078
+ sampleRand: Math.random()
18079
+ });
18080
+ return Sentry.captureException(error);
17239
18081
  });
17240
- Sentry.captureException(error);
17241
- await Sentry.flush(2e3);
17242
- } catch {}
18082
+ await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
18083
+ return eventId;
18084
+ } catch {
18085
+ return;
18086
+ }
17243
18087
  };
17244
18088
  //#endregion
17245
18089
  //#region src/cli/utils/path-format.ts
@@ -17309,7 +18153,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
17309
18153
  yield* Console.log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalScanElapsedMilliseconds)}`);
17310
18154
  if (surfaceDiagnostics.length > 0) {
17311
18155
  yield* Console.log("");
17312
- yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)));
18156
+ yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment());
17313
18157
  }
17314
18158
  const lowestScoredScan = findLowestScoredScan(completedScans);
17315
18159
  const aggregateScore = lowestScoredScan?.result.score ?? null;
@@ -17339,6 +18183,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
17339
18183
  for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
17340
18184
  yield* Console.log("");
17341
18185
  yield* printVerboseTip(surfaceDiagnostics, verbose);
18186
+ yield* printDocsNote();
17342
18187
  });
17343
18188
  //#endregion
17344
18189
  //#region src/cli/utils/prompt-install-setup.ts
@@ -17739,6 +18584,24 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
17739
18584
  changedFiles,
17740
18585
  isCurrentChanges: false
17741
18586
  });
18587
+ /**
18588
+ * On an interactive human run, rename a pre-migration
18589
+ * `react-doctor.config.json` to `doctor.config.ts` before config is loaded,
18590
+ * so the scan reads the renamed file and the user is told once. CI, coding
18591
+ * agents, JSON/score output, pre-commit (`--staged`) hooks, and non-TTY runs
18592
+ * are left untouched — the loader's warning still nudges them — so a scan
18593
+ * never mutates the repo unattended.
18594
+ */
18595
+ const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
18596
+ if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
18597
+ const legacyConfig = findLegacyConfig(requestedDirectory);
18598
+ if (!legacyConfig) return;
18599
+ const migratedPath = migrateLegacyConfig(legacyConfig);
18600
+ if (!migratedPath) return;
18601
+ cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
18602
+ cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
18603
+ cliLogger.break();
18604
+ };
17742
18605
  const inspectAction = async (directory, flags) => {
17743
18606
  const isScoreOnly = Boolean(flags.score);
17744
18607
  const isJsonMode = Boolean(flags.json);
@@ -17751,7 +18614,11 @@ const inspectAction = async (directory, flags) => {
17751
18614
  });
17752
18615
  try {
17753
18616
  validateModeFlags(flags);
17754
- const scanTarget = resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
18617
+ maybeMigrateLegacyConfig(requestedDirectory, {
18618
+ isQuiet,
18619
+ isStaged: Boolean(flags.staged)
18620
+ });
18621
+ const scanTarget = await resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
17755
18622
  const userConfig = scanTarget.userConfig;
17756
18623
  const resolvedDirectory = scanTarget.resolvedDirectory;
17757
18624
  setJsonReportDirectory(resolvedDirectory);
@@ -17925,13 +18792,13 @@ const inspectAction = async (directory, flags) => {
17925
18792
  })) printAgentInstallHint();
17926
18793
  }
17927
18794
  } catch (error) {
17928
- await reportErrorToSentry(error);
18795
+ const sentryEventId = await reportErrorToSentry(error);
17929
18796
  if (isJsonMode) {
17930
18797
  writeJsonErrorReport(error);
17931
18798
  process.exitCode = 1;
17932
18799
  return;
17933
18800
  }
17934
- handleError(error);
18801
+ handleError(error, { sentryEventId });
17935
18802
  }
17936
18803
  };
17937
18804
  //#endregion
@@ -17947,9 +18814,520 @@ const installAction = async (options, command) => {
17947
18814
  projectRoot: options.cwd ?? process.cwd()
17948
18815
  });
17949
18816
  } catch (error) {
17950
- await reportErrorToSentry(error);
17951
- handleError(error);
18817
+ handleError(error, { sentryEventId: await reportErrorToSentry(error) });
18818
+ }
18819
+ };
18820
+ //#endregion
18821
+ //#region src/cli/utils/rule-catalog.ts
18822
+ const buildRuleCatalog = () => REACT_DOCTOR_RULES.map((entry) => ({
18823
+ key: entry.key,
18824
+ id: entry.id,
18825
+ category: entry.rule.category ?? "Other",
18826
+ defaultSeverity: entry.rule.severity,
18827
+ framework: entry.rule.framework ?? "global",
18828
+ tags: entry.rule.tags ?? [],
18829
+ recommendation: entry.rule.recommendation,
18830
+ defaultEnabled: entry.rule.defaultEnabled !== false
18831
+ }));
18832
+ /**
18833
+ * Resolves a user-supplied rule reference to a catalog entry. Accepts the
18834
+ * fully-qualified key (`react-doctor/no-danger`), the bare id (`no-danger`),
18835
+ * and legacy plugin keys (`react/no-danger`) via the shared alias map.
18836
+ */
18837
+ const findRuleInCatalog = (catalog, ruleQuery) => {
18838
+ const normalizedQuery = ruleQuery.trim();
18839
+ if (normalizedQuery.length === 0) return void 0;
18840
+ const directMatch = catalog.find((entry) => entry.key === normalizedQuery || entry.id === normalizedQuery);
18841
+ if (directMatch) return directMatch;
18842
+ return catalog.find((entry) => isSameRuleKey(entry.key, normalizedQuery));
18843
+ };
18844
+ const listRuleCategories = (catalog) => [...new Set(catalog.map((entry) => entry.category))].sort();
18845
+ const listRuleTags = (catalog) => [...new Set(catalog.flatMap((entry) => [...entry.tags]))].sort();
18846
+ //#endregion
18847
+ //#region src/cli/utils/render-rule-catalog.ts
18848
+ const SEVERITY_COLUMN_WIDTH_CHARS = 6;
18849
+ const colorizeSeverity = (severity, text) => {
18850
+ if (severity === "error") return highlighter.error(text);
18851
+ if (severity === "warn") return highlighter.warn(text);
18852
+ return highlighter.gray(text);
18853
+ };
18854
+ const formatSourceNote = (effective) => effective.source === "default" ? highlighter.dim("(default)") : highlighter.dim(`(${effective.source})`);
18855
+ const renderRuleCatalog = (rows) => {
18856
+ if (rows.length === 0) return highlighter.dim("No rules match the given filters.");
18857
+ const rowsByCategory = /* @__PURE__ */ new Map();
18858
+ for (const row of rows) {
18859
+ const bucket = rowsByCategory.get(row.entry.category) ?? [];
18860
+ bucket.push(row);
18861
+ rowsByCategory.set(row.entry.category, bucket);
18862
+ }
18863
+ const lines = [];
18864
+ for (const category of [...rowsByCategory.keys()].sort()) {
18865
+ const categoryRows = (rowsByCategory.get(category) ?? []).sort((leftRow, rightRow) => leftRow.entry.key.localeCompare(rightRow.entry.key));
18866
+ lines.push(highlighter.bold(`${category} ${highlighter.dim(`(${categoryRows.length})`)}`));
18867
+ for (const row of categoryRows) {
18868
+ const severityBadge = colorizeSeverity(row.effective.value, row.effective.value.padEnd(SEVERITY_COLUMN_WIDTH_CHARS));
18869
+ const tagSuffix = row.entry.tags.length > 0 ? highlighter.dim(` [${row.entry.tags.join(", ")}]`) : "";
18870
+ lines.push(` ${severityBadge} ${row.entry.key} ${formatSourceNote(row.effective)}${tagSuffix}`);
18871
+ }
18872
+ lines.push("");
17952
18873
  }
18874
+ lines.push(highlighter.dim(`${rows.length} rule${rows.length === 1 ? "" : "s"} shown.`));
18875
+ return lines.join("\n");
18876
+ };
18877
+ const DETAIL_LABEL_COLUMN_WIDTH_CHARS = 18;
18878
+ const formatDetailRow = (label, value) => ` ${highlighter.dim(label.padEnd(DETAIL_LABEL_COLUMN_WIDTH_CHARS))}${value}`;
18879
+ const renderRuleExplanation = (row) => {
18880
+ const { entry, effective } = row;
18881
+ const lines = [highlighter.bold(entry.key), ""];
18882
+ lines.push(formatDetailRow("Category", entry.category));
18883
+ lines.push(formatDetailRow("Default severity", entry.defaultSeverity));
18884
+ lines.push(formatDetailRow("Current severity", `${colorizeSeverity(effective.value, effective.value)} ${formatSourceNote(effective)}`));
18885
+ lines.push(formatDetailRow("Framework", entry.framework));
18886
+ lines.push(formatDetailRow("Tags", entry.tags.length > 0 ? entry.tags.join(", ") : "none"));
18887
+ lines.push(formatDetailRow("Default enabled", entry.defaultEnabled ? "yes" : "no (opt-in)"));
18888
+ lines.push("");
18889
+ lines.push(highlighter.bold("Why it matters"));
18890
+ lines.push(` ${entry.recommendation ?? "No additional guidance recorded for this rule yet."}`);
18891
+ lines.push("");
18892
+ lines.push(highlighter.bold("Configure"));
18893
+ lines.push(highlighter.dim(` react-doctor rules disable ${entry.key}`));
18894
+ lines.push(highlighter.dim(` react-doctor rules enable ${entry.key} --severity error`));
18895
+ lines.push(highlighter.dim(` react-doctor rules set ${entry.key} warn`));
18896
+ lines.push("");
18897
+ lines.push(highlighter.bold("Learn more"));
18898
+ lines.push(highlighter.dim(` ${buildRuleDocsUrl("react-doctor", entry.id)}`));
18899
+ return lines.join("\n");
18900
+ };
18901
+ //#endregion
18902
+ //#region src/cli/utils/rule-config-file.ts
18903
+ const NEW_CONFIG_FILENAME = "doctor.config.json";
18904
+ const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
18905
+ const JSON_INDENT_SPACES = 2;
18906
+ const MANAGED_KEYS = [
18907
+ "rules",
18908
+ "categories",
18909
+ "ignore"
18910
+ ];
18911
+ /**
18912
+ * Decides where a rule-config mutation should be written. Discovery
18913
+ * reuses `loadConfigWithSource` (the loader the scan uses) so edits land
18914
+ * in the file the scan reads — `doctor.config.{ts,js,…}` is preferred,
18915
+ * then `package.json#reactDoctor`. When nothing exists, a fresh
18916
+ * `doctor.config.json` is targeted at `projectRoot`. Data configs are
18917
+ * re-read raw so unrelated fields round-trip untouched.
18918
+ */
18919
+ const resolveRuleConfigTarget = async (projectRoot) => {
18920
+ clearConfigCache();
18921
+ const loaded = await loadConfigWithSource(projectRoot);
18922
+ if (loaded) {
18923
+ if (loaded.format === "package-json") {
18924
+ const embedded = (readObjectFile(loaded.configFilePath) ?? {})[PACKAGE_JSON_CONFIG_KEY];
18925
+ return {
18926
+ format: "package-json",
18927
+ filePath: loaded.configFilePath,
18928
+ directory: loaded.sourceDirectory,
18929
+ exists: true,
18930
+ config: isPlainObject(embedded) ? embedded : {}
18931
+ };
18932
+ }
18933
+ if (loaded.format === "json") return {
18934
+ format: "json",
18935
+ filePath: loaded.configFilePath,
18936
+ directory: loaded.sourceDirectory,
18937
+ exists: true,
18938
+ config: readObjectFile(loaded.configFilePath) ?? {}
18939
+ };
18940
+ return {
18941
+ format: "module",
18942
+ filePath: loaded.configFilePath,
18943
+ directory: loaded.sourceDirectory,
18944
+ exists: true,
18945
+ config: loaded.config
18946
+ };
18947
+ }
18948
+ return {
18949
+ format: "json",
18950
+ filePath: path.join(projectRoot, NEW_CONFIG_FILENAME),
18951
+ directory: projectRoot,
18952
+ exists: false,
18953
+ config: {}
18954
+ };
18955
+ };
18956
+ const writeJsonConfig = (filePath, nextConfig) => {
18957
+ const { $schema, ...rest } = nextConfig;
18958
+ writeFileSync(filePath, `${JSON.stringify({
18959
+ $schema: $schema ?? "https://react.doctor/schema/config.json",
18960
+ ...rest
18961
+ }, null, JSON_INDENT_SPACES)}\n`);
18962
+ };
18963
+ const writePackageJsonConfig = (filePath, nextConfig) => {
18964
+ const packageJson = readObjectFile(filePath) ?? {};
18965
+ writeFileSync(filePath, `${JSON.stringify({
18966
+ ...packageJson,
18967
+ [PACKAGE_JSON_CONFIG_KEY]: nextConfig
18968
+ }, null, JSON_INDENT_SPACES)}\n`);
18969
+ };
18970
+ const syncManagedKeys = (target, nextConfig) => {
18971
+ for (const key of MANAGED_KEYS) {
18972
+ const value = nextConfig[key];
18973
+ if (value === void 0) {
18974
+ if (target[key] !== void 0) delete target[key];
18975
+ } else target[key] = value;
18976
+ }
18977
+ };
18978
+ const assignNodeSource = (owner, key, code) => {
18979
+ owner[key] = code;
18980
+ };
18981
+ const editVariableDeclarationConfig = (declaration, config, nextConfig) => {
18982
+ syncManagedKeys(config, nextConfig);
18983
+ const initializer = declaration.init;
18984
+ if (!initializer) return false;
18985
+ const generatedSource = generateCode(config).code;
18986
+ if (initializer.type === "ObjectExpression") {
18987
+ assignNodeSource(declaration, "init", generatedSource);
18988
+ return true;
18989
+ }
18990
+ if (initializer.type === "TSSatisfiesExpression" && initializer.expression.type === "ObjectExpression") {
18991
+ assignNodeSource(initializer, "expression", generatedSource);
18992
+ return true;
18993
+ }
18994
+ return false;
18995
+ };
18996
+ const writeModuleConfig = async (filePath, nextConfig) => {
18997
+ try {
18998
+ const module = await loadFile(filePath);
18999
+ if (module.exports.default?.$type === "identifier") {
19000
+ const { declaration, config } = getConfigFromVariableDeclaration(module);
19001
+ if (!config || !editVariableDeclarationConfig(declaration, config, nextConfig)) return false;
19002
+ } else syncManagedKeys(getDefaultExportOptions(module), nextConfig);
19003
+ await writeFile(module, filePath);
19004
+ return true;
19005
+ } catch {
19006
+ return false;
19007
+ }
19008
+ };
19009
+ const writeRuleConfig = async (target, nextConfig) => {
19010
+ if (target.format === "module") {
19011
+ const written = await writeModuleConfig(target.filePath, nextConfig);
19012
+ if (written) clearConfigCache();
19013
+ return { written };
19014
+ }
19015
+ if (target.format === "package-json") writePackageJsonConfig(target.filePath, nextConfig);
19016
+ else writeJsonConfig(target.filePath, nextConfig);
19017
+ clearConfigCache();
19018
+ return { written: true };
19019
+ };
19020
+ //#endregion
19021
+ //#region src/cli/utils/resolve-effective-rule-severity.ts
19022
+ /**
19023
+ * Resolves what a rule will actually do under the current config without
19024
+ * running a scan. `ignore.tags` is a pre-lint gate: a rule carrying an
19025
+ * ignored tag is dropped (via `shouldEnableRule`) before any severity is
19026
+ * read, so it wins over every override. Among rules that survive the gate,
19027
+ * the scanner's order is `rules` > `categories` > `buckets` > the registry
19028
+ * default.
19029
+ */
19030
+ const resolveEffectiveRuleSeverity = (config, entry) => {
19031
+ const ignoredTags = config?.ignore?.tags ?? [];
19032
+ if (entry.tags.some((tag) => ignoredTags.includes(tag))) return {
19033
+ value: "off",
19034
+ source: "tag"
19035
+ };
19036
+ const ruleOverrides = config?.rules ?? {};
19037
+ for (const equivalentKey of getEquivalentRuleKeys(entry.key)) {
19038
+ const override = ruleOverrides[equivalentKey];
19039
+ if (override !== void 0) return {
19040
+ value: override,
19041
+ source: "rule"
19042
+ };
19043
+ }
19044
+ const categoryOverride = config?.categories?.[entry.category];
19045
+ if (categoryOverride !== void 0) return {
19046
+ value: categoryOverride,
19047
+ source: "category"
19048
+ };
19049
+ if (COMPILER_CLEANUP_RULE_KEYS.has(entry.key)) {
19050
+ const bucketOverride = config?.buckets?.[COMPILER_CLEANUP_BUCKET];
19051
+ if (bucketOverride !== void 0) return {
19052
+ value: bucketOverride,
19053
+ source: "bucket"
19054
+ };
19055
+ }
19056
+ return {
19057
+ value: entry.defaultEnabled ? entry.defaultSeverity : "off",
19058
+ source: "default"
19059
+ };
19060
+ };
19061
+ //#endregion
19062
+ //#region src/cli/utils/update-rule-config.ts
19063
+ /**
19064
+ * Sets a per-rule severity, replacing any existing entry for the same
19065
+ * rule (including legacy-aliased keys, so a config still targeting
19066
+ * `react/no-danger` is rewritten to the canonical key instead of
19067
+ * leaving a dead duplicate).
19068
+ */
19069
+ const setRuleSeverity = (config, ruleKey, severity) => {
19070
+ const equivalentKeys = new Set(getEquivalentRuleKeys(ruleKey));
19071
+ const nextRules = {};
19072
+ for (const [existingKey, existingSeverity] of Object.entries(config.rules ?? {})) if (!equivalentKeys.has(existingKey)) nextRules[existingKey] = existingSeverity;
19073
+ nextRules[ruleKey] = severity;
19074
+ return {
19075
+ ...config,
19076
+ rules: nextRules
19077
+ };
19078
+ };
19079
+ const setCategorySeverity = (config, category, severity) => ({
19080
+ ...config,
19081
+ categories: {
19082
+ ...config.categories,
19083
+ [category]: severity
19084
+ }
19085
+ });
19086
+ const addIgnoredTag = (config, tag) => {
19087
+ const currentTags = config.ignore?.tags ?? [];
19088
+ if (currentTags.includes(tag)) return config;
19089
+ return {
19090
+ ...config,
19091
+ ignore: {
19092
+ ...config.ignore,
19093
+ tags: [...new Set([...currentTags, tag])].sort()
19094
+ }
19095
+ };
19096
+ };
19097
+ const removeIgnoredTag = (config, tag) => {
19098
+ const currentTags = config.ignore?.tags ?? [];
19099
+ if (!currentTags.includes(tag)) return config;
19100
+ const remainingTags = currentTags.filter((existingTag) => existingTag !== tag);
19101
+ const { tags: _removed, ...remainingIgnore } = config.ignore ?? {};
19102
+ if (remainingTags.length === 0) {
19103
+ if (Object.keys(remainingIgnore).length === 0) {
19104
+ const { ignore: _ignore, ...configWithoutIgnore } = config;
19105
+ return configWithoutIgnore;
19106
+ }
19107
+ return {
19108
+ ...config,
19109
+ ignore: remainingIgnore
19110
+ };
19111
+ }
19112
+ return {
19113
+ ...config,
19114
+ ignore: {
19115
+ ...remainingIgnore,
19116
+ tags: remainingTags
19117
+ }
19118
+ };
19119
+ };
19120
+ //#endregion
19121
+ //#region src/cli/commands/rules.ts
19122
+ const SEVERITY_VALUES = [
19123
+ "off",
19124
+ "warn",
19125
+ "error"
19126
+ ];
19127
+ const resolveProjectRoot = (options) => {
19128
+ const requestedDirectory = path.resolve(options.cwd ?? process.cwd());
19129
+ return findNearestPackageDirectory(requestedDirectory) ?? requestedDirectory;
19130
+ };
19131
+ const parseSeverity = (value) => SEVERITY_VALUES.includes(value) ? value : null;
19132
+ const reportInvalidSeverity = (value) => {
19133
+ cliLogger.error(`Invalid severity "${value}". Expected one of: ${SEVERITY_VALUES.join(", ")}.`);
19134
+ process.exitCode = 1;
19135
+ };
19136
+ const reportRuleNotFound = (ruleQuery) => {
19137
+ cliLogger.error(`Unknown rule "${ruleQuery}".`);
19138
+ cliLogger.dim(" Run `react-doctor rules list` to see every available rule.");
19139
+ process.exitCode = 1;
19140
+ };
19141
+ const describeTargetPath = (target) => {
19142
+ const relativePath = path.relative(process.cwd(), target.filePath);
19143
+ const displayPath = relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : target.filePath;
19144
+ return target.exists ? displayPath : `${displayPath} ${highlighter.dim("(created)")}`;
19145
+ };
19146
+ const applyConfigChange = async (options, change) => {
19147
+ const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
19148
+ const nextConfig = change(target.config);
19149
+ const { written } = await writeRuleConfig(target, nextConfig);
19150
+ return {
19151
+ target,
19152
+ nextConfig,
19153
+ written
19154
+ };
19155
+ };
19156
+ const reportManualEdit = (target, nextConfig) => {
19157
+ const managed = {};
19158
+ for (const key of [
19159
+ "rules",
19160
+ "categories",
19161
+ "ignore"
19162
+ ]) if (nextConfig[key] !== void 0) managed[key] = nextConfig[key];
19163
+ cliLogger.error(`Couldn't automatically edit ${describeTargetPath(target)} (dynamic config).`);
19164
+ cliLogger.dim(" Apply this to your config's default export, then re-run:");
19165
+ for (const line of JSON.stringify(managed, null, 2).split("\n")) cliLogger.dim(` ${line}`);
19166
+ process.exitCode = 1;
19167
+ };
19168
+ const rulesListAction = async (options) => {
19169
+ const catalog = buildRuleCatalog();
19170
+ const config = validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config);
19171
+ const categoryFilter = options.category?.toLowerCase();
19172
+ const frameworkFilter = options.framework?.toLowerCase();
19173
+ const rows = catalog.filter((entry) => {
19174
+ if (categoryFilter && entry.category.toLowerCase() !== categoryFilter) return false;
19175
+ if (frameworkFilter && entry.framework.toLowerCase() !== frameworkFilter) return false;
19176
+ if (options.tag && !entry.tags.includes(options.tag)) return false;
19177
+ return true;
19178
+ }).map((entry) => ({
19179
+ entry,
19180
+ effective: resolveEffectiveRuleSeverity(config, entry)
19181
+ })).filter((row) => options.configured ? row.effective.source !== "default" : true);
19182
+ if (options.json) {
19183
+ const payload = rows.map((row) => ({
19184
+ key: row.entry.key,
19185
+ id: row.entry.id,
19186
+ category: row.entry.category,
19187
+ framework: row.entry.framework,
19188
+ tags: row.entry.tags,
19189
+ defaultSeverity: row.entry.defaultSeverity,
19190
+ defaultEnabled: row.entry.defaultEnabled,
19191
+ severity: row.effective.value,
19192
+ source: row.effective.source
19193
+ }));
19194
+ cliLogger.log(JSON.stringify(payload, null, 2));
19195
+ return;
19196
+ }
19197
+ cliLogger.log(renderRuleCatalog(rows));
19198
+ };
19199
+ const rulesExplainAction = async (ruleQuery, options) => {
19200
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
19201
+ if (!entry) {
19202
+ reportRuleNotFound(ruleQuery);
19203
+ return;
19204
+ }
19205
+ const effective = resolveEffectiveRuleSeverity(validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config), entry);
19206
+ if (options.json) {
19207
+ cliLogger.log(JSON.stringify({
19208
+ key: entry.key,
19209
+ id: entry.id,
19210
+ category: entry.category,
19211
+ framework: entry.framework,
19212
+ tags: entry.tags,
19213
+ defaultSeverity: entry.defaultSeverity,
19214
+ defaultEnabled: entry.defaultEnabled,
19215
+ severity: effective.value,
19216
+ source: effective.source,
19217
+ recommendation: entry.recommendation ?? null,
19218
+ learnMoreUrl: buildRuleDocsUrl("react-doctor", entry.id)
19219
+ }, null, 2));
19220
+ return;
19221
+ }
19222
+ cliLogger.log(renderRuleExplanation({
19223
+ entry,
19224
+ effective
19225
+ }));
19226
+ };
19227
+ const setRuleSeverityAndReport = async (entry, severity, options) => {
19228
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => setRuleSeverity(config, entry.key, severity));
19229
+ if (!written) {
19230
+ reportManualEdit(target, nextConfig);
19231
+ return;
19232
+ }
19233
+ cliLogger.success(`Set ${entry.key} → ${severity}`);
19234
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
19235
+ };
19236
+ const rulesSetAction = async (ruleQuery, severityValue, options) => {
19237
+ const severity = parseSeverity(severityValue);
19238
+ if (!severity) {
19239
+ reportInvalidSeverity(severityValue);
19240
+ return;
19241
+ }
19242
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
19243
+ if (!entry) {
19244
+ reportRuleNotFound(ruleQuery);
19245
+ return;
19246
+ }
19247
+ await setRuleSeverityAndReport(entry, severity, options);
19248
+ };
19249
+ const rulesEnableAction = async (ruleQuery, options) => {
19250
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
19251
+ if (!entry) {
19252
+ reportRuleNotFound(ruleQuery);
19253
+ return;
19254
+ }
19255
+ if (options.severity === void 0) {
19256
+ await setRuleSeverityAndReport(entry, entry.defaultSeverity, options);
19257
+ return;
19258
+ }
19259
+ const severity = parseSeverity(options.severity);
19260
+ if (!severity) {
19261
+ reportInvalidSeverity(options.severity);
19262
+ return;
19263
+ }
19264
+ if (severity === "off") {
19265
+ cliLogger.error("`enable` cannot set a rule to off. Use `react-doctor rules disable` instead.");
19266
+ process.exitCode = 1;
19267
+ return;
19268
+ }
19269
+ await setRuleSeverityAndReport(entry, severity, options);
19270
+ };
19271
+ const rulesDisableAction = async (ruleQuery, options) => {
19272
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
19273
+ if (!entry) {
19274
+ reportRuleNotFound(ruleQuery);
19275
+ return;
19276
+ }
19277
+ await setRuleSeverityAndReport(entry, "off", options);
19278
+ };
19279
+ const rulesCategoryAction = async (categoryQuery, severityValue, options) => {
19280
+ const severity = parseSeverity(severityValue);
19281
+ if (!severity) {
19282
+ reportInvalidSeverity(severityValue);
19283
+ return;
19284
+ }
19285
+ const knownCategories = listRuleCategories(buildRuleCatalog());
19286
+ const matchedCategory = knownCategories.find((category) => category.toLowerCase() === categoryQuery.toLowerCase());
19287
+ if (!matchedCategory) {
19288
+ cliLogger.error(`Unknown category "${categoryQuery}".`);
19289
+ cliLogger.dim(` Known categories: ${knownCategories.join(", ")}`);
19290
+ process.exitCode = 1;
19291
+ return;
19292
+ }
19293
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => setCategorySeverity(config, matchedCategory, severity));
19294
+ if (!written) {
19295
+ reportManualEdit(target, nextConfig);
19296
+ return;
19297
+ }
19298
+ cliLogger.success(`Set category "${matchedCategory}" → ${severity}`);
19299
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
19300
+ };
19301
+ const rulesIgnoreTagAction = async (tag, options) => {
19302
+ const knownTags = listRuleTags(buildRuleCatalog());
19303
+ if (!knownTags.includes(tag)) {
19304
+ cliLogger.error(`Unknown tag "${tag}".`);
19305
+ cliLogger.dim(` Known tags: ${knownTags.join(", ")}`);
19306
+ process.exitCode = 1;
19307
+ return;
19308
+ }
19309
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => addIgnoredTag(config, tag));
19310
+ if (!written) {
19311
+ reportManualEdit(target, nextConfig);
19312
+ return;
19313
+ }
19314
+ cliLogger.success(`Ignoring tag "${tag}" (rules with this tag are skipped before linting)`);
19315
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
19316
+ };
19317
+ const rulesUnignoreTagAction = async (tag, options) => {
19318
+ const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
19319
+ if (!(target.config.ignore?.tags ?? []).includes(tag)) {
19320
+ cliLogger.dim(`Tag "${tag}" was not being ignored; nothing to change.`);
19321
+ return;
19322
+ }
19323
+ const nextConfig = removeIgnoredTag(target.config, tag);
19324
+ const { written } = await writeRuleConfig(target, nextConfig);
19325
+ if (!written) {
19326
+ reportManualEdit(target, nextConfig);
19327
+ return;
19328
+ }
19329
+ cliLogger.success(`Tag "${tag}" is no longer ignored`);
19330
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
17953
19331
  };
17954
19332
  //#endregion
17955
19333
  //#region src/cli/commands/version.ts
@@ -18111,6 +19489,25 @@ const COMMAND_FLAG_SPECS = new Map([
18111
19489
  longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
18112
19490
  shortOptionsWithoutValues: new Set(["-h"]),
18113
19491
  shortOptionsWithRequiredValues: /* @__PURE__ */ new Set()
19492
+ }],
19493
+ ["rules", {
19494
+ longOptionsWithoutValues: new Set([
19495
+ "--color",
19496
+ "--configured",
19497
+ "--help",
19498
+ "--json",
19499
+ "--no-color"
19500
+ ]),
19501
+ longOptionsWithRequiredValues: new Set([
19502
+ "--category",
19503
+ "--cwd",
19504
+ "--framework",
19505
+ "--severity",
19506
+ "--tag"
19507
+ ]),
19508
+ longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
19509
+ shortOptionsWithoutValues: new Set(["-h"]),
19510
+ shortOptionsWithRequiredValues: new Set(["-c"])
18114
19511
  }]
18115
19512
  ]);
18116
19513
  const isFlagLike = (argument) => argument.startsWith("-") && argument !== "-";
@@ -18203,8 +19600,8 @@ ${formatExampleLines([
18203
19600
  ])}
18204
19601
 
18205
19602
  ${highlighter.dim("Configuration:")}
18206
- Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
18207
- CLI flags always override config values. See the README for the full schema.
19603
+ 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.
19604
+ Use ${highlighter.info("react-doctor rules")} to list, explain, and configure rules. CLI flags always override config values.
18208
19605
 
18209
19606
  ${highlighter.dim("Feedback & bug reports:")}
18210
19607
  ${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
@@ -18224,10 +19621,19 @@ ${formatExampleLines([
18224
19621
  ${highlighter.dim("Learn more:")}
18225
19622
  ${highlighter.info(CANONICAL_GITHUB_URL)}
18226
19623
  `;
18227
- 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 (errors always show)").option("--no-warnings", "hide warning-severity diagnostics (default)").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderRootHelpEpilog);
19624
+ 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);
18228
19625
  program.action(inspectAction);
18229
19626
  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);
18230
19627
  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);
19628
+ const rules = program.command("rules").description("List, explain, and configure which React Doctor rules run");
19629
+ 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()));
19630
+ 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()));
19631
+ 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()));
19632
+ 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()));
19633
+ 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()));
19634
+ 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()));
19635
+ 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()));
19636
+ 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()));
18231
19637
  process.stdout.on("error", (error) => {
18232
19638
  if (error.code === "EPIPE") process.exit(0);
18233
19639
  });
@@ -18239,15 +19645,16 @@ if (process.argv.includes("-V") && !strippedArgv.includes("-V")) {
18239
19645
  }
18240
19646
  applyColorPreference(strippedArgv);
18241
19647
  const argv = normalizeHelpInvocation(strippedArgv, knownCommands);
18242
- program.parseAsync(argv).catch(async (error) => {
18243
- await reportErrorToSentry(error);
19648
+ program.parseAsync(argv).then(() => flushSentry()).catch(async (error) => {
19649
+ const sentryEventId = await reportErrorToSentry(error);
18244
19650
  if (isJsonModeActive()) {
18245
19651
  writeJsonErrorReport(error);
18246
19652
  process.exit(1);
18247
19653
  }
18248
- handleError(error);
19654
+ handleError(error, { sentryEventId });
18249
19655
  });
18250
19656
  //#endregion
18251
19657
  export {};
18252
19658
 
18253
- //# sourceMappingURL=cli.js.map
19659
+ //# sourceMappingURL=cli.js.map
19660
+ //# debugId=d012dbb2-27e9-5092-9eab-c7fa4573f0b2