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/index.d.ts CHANGED
@@ -5,13 +5,35 @@ import * as Cause from "effect/Cause";
5
5
  //#region src/types/config.d.ts
6
6
  type FailOnLevel = "error" | "warning" | "none";
7
7
  interface ReactDoctorIgnoreOverride {
8
+ /** Glob patterns the override applies to (e.g. `["src/legacy/**"]`). */
8
9
  files: string[];
10
+ /**
11
+ * Rule keys to suppress for the matched files. Omit (or leave empty) to
12
+ * suppress every rule for those files.
13
+ */
9
14
  rules?: string[];
10
15
  }
11
16
  interface ReactDoctorIgnoreConfig {
17
+ /**
18
+ * Fully-qualified rule keys (`"<plugin>/<rule>"`) whose diagnostics are
19
+ * dropped AFTER linting. The rule still runs; its findings are filtered
20
+ * out. To stop a rule from running at all, set it to `"off"` in the
21
+ * top-level `rules` map instead. Prefer `react-doctor rules disable
22
+ * <rule>` to edit this safely.
23
+ */
12
24
  rules?: string[];
25
+ /**
26
+ * Glob patterns whose files are excluded from scanning entirely (matched
27
+ * against paths relative to the scanned directory).
28
+ */
13
29
  files?: string[];
30
+ /** Per-path rule suppressions — narrower than the top-level `rules`/`files`. */
14
31
  overrides?: ReactDoctorIgnoreOverride[];
32
+ /**
33
+ * Behavioral tags whose rules are disabled BEFORE linting, skipping a
34
+ * whole family at once (e.g. `["design", "test-noise", "migration-hint"]`).
35
+ * Prefer `react-doctor rules ignore-tag <tag>` to edit this safely.
36
+ */
15
37
  tags?: string[];
16
38
  }
17
39
  /**
@@ -100,11 +122,11 @@ interface ReactDoctorConfig {
100
122
  deadCode?: boolean;
101
123
  verbose?: boolean;
102
124
  /**
103
- * Whether to surface `"warning"`-severity diagnostics. Default: `false`
104
- * — only `"error"`-severity findings reach any surface (CLI, PR comment,
105
- * score, `--fail-on`).
125
+ * Whether to surface `"warning"`-severity diagnostics. Default: `true`
126
+ * — every warning reaches every surface (CLI, PR comment, score,
127
+ * `--fail-on`).
106
128
  *
107
- * Set to `true` to surface every warning on every surface. This is the
129
+ * Set to `false` to surface only `"error"`-severity findings. This is the
108
130
  * master toggle and runs after per-rule / per-category severity
109
131
  * overrides: a rule the user explicitly restamps to `"warn"` (via
110
132
  * `rules` / `categories`) still shows even when `warnings` is `false`.
@@ -122,7 +144,7 @@ interface ReactDoctorConfig {
122
144
  * the redirect is stable no matter where the CLI / `diagnose()` is
123
145
  * run from. Absolute paths are used as-is.
124
146
  *
125
- * Typical use: a monorepo root holds the only `react-doctor.config.json`
147
+ * Typical use: a monorepo root holds the only `doctor.config.*`
126
148
  * (so editor tooling and child commands all find it), but the React
127
149
  * app lives in `apps/web`. Setting `"rootDir": "apps/web"` makes
128
150
  * every invocation that loads this config scan that subproject
@@ -420,8 +442,8 @@ interface DiagnoseOptions {
420
442
  respectInlineDisables?: boolean;
421
443
  /**
422
444
  * Per-call override for `ReactDoctorConfig.warnings`. See that field's
423
- * docs — `"warning"`-severity diagnostics are hidden unless this (or
424
- * the config) opts them back in.
445
+ * docs — `"warning"`-severity diagnostics surface by default unless this
446
+ * (or the config) opts out via `false`.
425
447
  */
426
448
  warnings?: boolean;
427
449
  }
@@ -444,7 +466,7 @@ interface DiagnoseResult {
444
466
  * Scan options (`deadCode`, `lint`, etc.) are flat on the entry and
445
467
  * layer on top of the global defaults — omitted fields fall through.
446
468
  * `config` is a full `ReactDoctorConfig` override that replaces the
447
- * on-disk `react-doctor.config.json` for this project's scan.
469
+ * on-disk `doctor.config.*` for this project's scan.
448
470
  */
449
471
  //#endregion
450
472
  //#region src/types/inspect.d.ts
package/dist/index.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]="df20b0d3-9e4e-52e0-8991-ce8a6349a04a")}catch(e){}}();
1
3
  import { createRequire } from "node:module";
2
4
  import * as Schema from "effect/Schema";
3
5
  import * as fs$1 from "node:fs";
@@ -16,6 +18,8 @@ import * as Otlp from "effect/unstable/observability/Otlp";
16
18
  import * as Context from "effect/Context";
17
19
  import os from "node:os";
18
20
  import * as Console from "effect/Console";
21
+ import { parseJSON5 } from "confbox";
22
+ import { createJiti } from "jiti";
19
23
  import * as Fiber from "effect/Fiber";
20
24
  import * as Filter from "effect/Filter";
21
25
  import * as Option from "effect/Option";
@@ -3289,7 +3293,14 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
3289
3293
  "tsconfig.json",
3290
3294
  "tsconfig.base.json",
3291
3295
  "package.json",
3292
- "react-doctor.config.json",
3296
+ "doctor.config.ts",
3297
+ "doctor.config.mts",
3298
+ "doctor.config.cts",
3299
+ "doctor.config.js",
3300
+ "doctor.config.mjs",
3301
+ "doctor.config.cjs",
3302
+ "doctor.config.json",
3303
+ "doctor.config.jsonc",
3293
3304
  "oxlint.json",
3294
3305
  ".oxlintrc.json"
3295
3306
  ];
@@ -4481,43 +4492,80 @@ const validateConfigTypes = (config) => {
4481
4492
  const warn = (message) => {
4482
4493
  Effect.runSync(Console.warn(message));
4483
4494
  };
4484
- const CONFIG_FILENAME = "react-doctor.config.json";
4495
+ const CONFIG_BASENAME = "doctor.config";
4496
+ const CONFIG_EXTENSIONS = [
4497
+ "ts",
4498
+ "mts",
4499
+ "cts",
4500
+ "js",
4501
+ "mjs",
4502
+ "cjs",
4503
+ "json",
4504
+ "jsonc"
4505
+ ];
4506
+ const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
4507
+ const PACKAGE_JSON_FILENAME = "package.json";
4485
4508
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
4486
- const loadConfigFromDirectory = (directory) => {
4487
- const configFilePath = path.join(directory, CONFIG_FILENAME);
4509
+ const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
4510
+ const jiti = createJiti(import.meta.url);
4511
+ const formatError = (error) => error instanceof Error ? error.message : String(error);
4512
+ const loadModuleConfig = async (filePath) => {
4513
+ const imported = await jiti.import(filePath);
4514
+ return imported?.default ?? imported;
4515
+ };
4516
+ const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
4517
+ const readEmbeddedPackageJsonConfig = (directory) => {
4518
+ const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
4519
+ if (!isFile(packageJsonPath)) return null;
4520
+ try {
4521
+ const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
4522
+ if (isPlainObject(packageJson)) {
4523
+ const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4524
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
4525
+ }
4526
+ } catch {}
4527
+ return null;
4528
+ };
4529
+ const loadPackageJsonConfig = (directory) => {
4530
+ const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
4531
+ if (!embeddedConfig) return null;
4532
+ return {
4533
+ config: validateConfigTypes(embeddedConfig),
4534
+ sourceDirectory: directory,
4535
+ configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
4536
+ format: "package-json"
4537
+ };
4538
+ };
4539
+ const loadConfigFromDirectory = async (directory) => {
4488
4540
  let sawBrokenConfigFile = false;
4489
- if (isFile(configFilePath)) {
4541
+ for (const extension of CONFIG_EXTENSIONS) {
4542
+ const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
4543
+ if (!isFile(filePath)) continue;
4544
+ const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
4490
4545
  try {
4491
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
4492
- const parsed = JSON.parse(fileContent);
4546
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
4493
4547
  if (isPlainObject(parsed)) return {
4494
4548
  status: "found",
4495
4549
  loaded: {
4496
4550
  config: validateConfigTypes(parsed),
4497
- sourceDirectory: directory
4551
+ sourceDirectory: directory,
4552
+ configFilePath: filePath,
4553
+ format: isDataFile ? "json" : "module"
4498
4554
  }
4499
4555
  };
4500
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4556
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
4557
+ sawBrokenConfigFile = true;
4501
4558
  } catch (error) {
4502
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
4559
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
4560
+ sawBrokenConfigFile = true;
4503
4561
  }
4504
- sawBrokenConfigFile = true;
4505
4562
  }
4506
- const packageJsonPath = path.join(directory, "package.json");
4507
- if (isFile(packageJsonPath)) try {
4508
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
4509
- const packageJson = JSON.parse(fileContent);
4510
- if (isPlainObject(packageJson)) {
4511
- const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4512
- if (isPlainObject(embeddedConfig)) return {
4513
- status: "found",
4514
- loaded: {
4515
- config: validateConfigTypes(embeddedConfig),
4516
- sourceDirectory: directory
4517
- }
4518
- };
4519
- }
4520
- } catch {}
4563
+ const packageJsonConfig = loadPackageJsonConfig(directory);
4564
+ if (packageJsonConfig) return {
4565
+ status: "found",
4566
+ loaded: packageJsonConfig
4567
+ };
4568
+ 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).`);
4521
4569
  return {
4522
4570
  status: sawBrokenConfigFile ? "invalid" : "absent",
4523
4571
  loaded: null
@@ -4527,34 +4575,26 @@ const cachedConfigs = /* @__PURE__ */ new Map();
4527
4575
  const clearConfigCache = () => {
4528
4576
  cachedConfigs.clear();
4529
4577
  };
4530
- const loadConfigWithSource = (rootDirectory) => {
4531
- const cached = cachedConfigs.get(rootDirectory);
4532
- if (cached !== void 0) return cached;
4533
- const localResult = loadConfigFromDirectory(rootDirectory);
4534
- if (localResult.status === "found") {
4535
- cachedConfigs.set(rootDirectory, localResult.loaded);
4536
- return localResult.loaded;
4537
- }
4538
- if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
4539
- cachedConfigs.set(rootDirectory, null);
4540
- return null;
4541
- }
4578
+ const loadConfigWalkingUp = async (rootDirectory) => {
4579
+ const localResult = await loadConfigFromDirectory(rootDirectory);
4580
+ if (localResult.status === "found") return localResult.loaded;
4581
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
4542
4582
  let ancestorDirectory = path.dirname(rootDirectory);
4543
4583
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4544
- const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
4545
- if (ancestorResult.status === "found") {
4546
- cachedConfigs.set(rootDirectory, ancestorResult.loaded);
4547
- return ancestorResult.loaded;
4548
- }
4549
- if (isProjectBoundary(ancestorDirectory)) {
4550
- cachedConfigs.set(rootDirectory, null);
4551
- return null;
4552
- }
4584
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
4585
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
4586
+ if (isProjectBoundary(ancestorDirectory)) return null;
4553
4587
  ancestorDirectory = path.dirname(ancestorDirectory);
4554
4588
  }
4555
- cachedConfigs.set(rootDirectory, null);
4556
4589
  return null;
4557
4590
  };
4591
+ const loadConfigWithSource = (rootDirectory) => {
4592
+ const cached = cachedConfigs.get(rootDirectory);
4593
+ if (cached !== void 0) return cached;
4594
+ const loadPromise = loadConfigWalkingUp(rootDirectory);
4595
+ cachedConfigs.set(rootDirectory, loadPromise);
4596
+ return loadPromise;
4597
+ };
4558
4598
  const resolveConfigRootDir = (config, configSourceDirectory) => {
4559
4599
  if (!config || !configSourceDirectory) return null;
4560
4600
  const rawRootDir = config.rootDir;
@@ -4582,8 +4622,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
4582
4622
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
4583
4623
  *
4584
4624
  * 1. Resolve the requested directory to absolute.
4585
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
4586
- * if present.
4625
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
4587
4626
  * 3. Honor `config.rootDir` to redirect the scan to a nested
4588
4627
  * project root, if configured.
4589
4628
  * 4. Walk into a nested React subproject when the requested
@@ -4601,9 +4640,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
4601
4640
  * via its own cache). Routing through `resolveScanTarget` keeps every
4602
4641
  * shell in agreement on what "the scan directory" means.
4603
4642
  */
4604
- const resolveScanTarget = (requestedDirectory, options = {}) => {
4643
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
4605
4644
  const absoluteRequested = path.resolve(requestedDirectory);
4606
- const loadedConfig = loadConfigWithSource(absoluteRequested);
4645
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
4607
4646
  const userConfig = loadedConfig?.config ?? null;
4608
4647
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4609
4648
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
@@ -4927,6 +4966,61 @@ const checkExpoPackageJsonConflicts = (context) => {
4927
4966
  }));
4928
4967
  return diagnostics;
4929
4968
  };
4969
+ const APP_CONFIG_JSON_FILES = ["app.config.json", "app.json"];
4970
+ const APP_CONFIG_DYNAMIC_FILES = [
4971
+ "app.config.ts",
4972
+ "app.config.js",
4973
+ "app.config.cjs",
4974
+ "app.config.mjs"
4975
+ ];
4976
+ const ExpoConfigSchema = Schema.Struct({
4977
+ newArchEnabled: Schema.optional(Schema.Boolean),
4978
+ updates: Schema.optional(Schema.Struct({ disableAntiBrickingMeasures: Schema.optional(Schema.Boolean) }))
4979
+ });
4980
+ const AppManifestSchema = Schema.Struct({ expo: Schema.optional(ExpoConfigSchema) });
4981
+ const NO_CONFIG = {
4982
+ config: null,
4983
+ configFile: null
4984
+ };
4985
+ const decodeExpoConfig = (filePath) => {
4986
+ let raw;
4987
+ try {
4988
+ raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
4989
+ } catch {
4990
+ return null;
4991
+ }
4992
+ return Option.getOrNull(Schema.decodeUnknownOption(AppManifestSchema)(raw))?.expo ?? null;
4993
+ };
4994
+ const readExpoAppConfig = (rootDirectory) => {
4995
+ if (APP_CONFIG_DYNAMIC_FILES.some((fileName) => isFile(path.join(rootDirectory, fileName)))) return NO_CONFIG;
4996
+ for (const fileName of APP_CONFIG_JSON_FILES) {
4997
+ const filePath = path.join(rootDirectory, fileName);
4998
+ if (!isFile(filePath)) continue;
4999
+ const config = decodeExpoConfig(filePath);
5000
+ if (config) return {
5001
+ config,
5002
+ configFile: fileName
5003
+ };
5004
+ }
5005
+ return NO_CONFIG;
5006
+ };
5007
+ const REANIMATED_PACKAGE = "react-native-reanimated";
5008
+ const WORKLETS_PACKAGE = "react-native-worklets";
5009
+ const FIRST_NEW_ARCH_ONLY_REANIMATED_MAJOR = 4;
5010
+ const checkExpoReanimatedNewArch = (context) => {
5011
+ const reanimatedSpec = context.packageJson.dependencies?.[REANIMATED_PACKAGE] ?? context.packageJson.devDependencies?.[REANIMATED_PACKAGE];
5012
+ const reanimatedMajor = reanimatedSpec === void 0 ? null : getLowestDependencyMajor(reanimatedSpec);
5013
+ if (!(reanimatedMajor !== null && reanimatedMajor >= FIRST_NEW_ARCH_ONLY_REANIMATED_MAJOR || context.directDependencyNames.has(WORKLETS_PACKAGE))) return [];
5014
+ const appConfig = readExpoAppConfig(context.rootDirectory);
5015
+ if (appConfig.config?.newArchEnabled !== false) return [];
5016
+ return [buildExpoDiagnostic({
5017
+ rule: "expo-reanimated-v4-requires-new-arch",
5018
+ severity: "error",
5019
+ filePath: appConfig.configFile ?? "app.json",
5020
+ 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.",
5021
+ 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."
5022
+ })];
5023
+ };
4930
5024
  const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
4931
5025
  const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
4932
5026
  const checkExpoRouterReactNavigation = (context) => {
@@ -4942,6 +5036,17 @@ const checkExpoRouterReactNavigation = (context) => {
4942
5036
  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/"
4943
5037
  })];
4944
5038
  };
5039
+ const checkExpoUpdatesConfig = (context) => {
5040
+ const appConfig = readExpoAppConfig(context.rootDirectory);
5041
+ if (appConfig.config?.updates?.disableAntiBrickingMeasures !== true) return [];
5042
+ return [buildExpoDiagnostic({
5043
+ rule: "expo-updates-no-unsafe-production-config",
5044
+ severity: "error",
5045
+ filePath: appConfig.configFile ?? "app.json",
5046
+ 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.",
5047
+ help: "Remove `disableAntiBrickingMeasures` from your app config's `updates` block. See https://docs.expo.dev/versions/latest/config/app/#updates"
5048
+ })];
5049
+ };
4945
5050
  const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
4946
5051
  const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
4947
5052
  const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
@@ -4968,7 +5073,9 @@ const checkExpoProject = (rootDirectory, project) => {
4968
5073
  ...checkExpoLockfile(context),
4969
5074
  ...checkExpoGitignore(context),
4970
5075
  ...checkExpoEnvLocalFiles(context),
4971
- ...checkExpoMetroConfig(context)
5076
+ ...checkExpoMetroConfig(context),
5077
+ ...checkExpoReanimatedNewArch(context),
5078
+ ...checkExpoUpdatesConfig(context)
4972
5079
  ];
4973
5080
  };
4974
5081
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
@@ -5085,6 +5192,69 @@ const checkPnpmHardening = (rootDirectory) => {
5085
5192
  }));
5086
5193
  return diagnostics;
5087
5194
  };
5195
+ const BUILDER_BOB_PACKAGE = "react-native-builder-bob";
5196
+ const isBuilderBobLibrary = (packageJson) => {
5197
+ const bobConfig = packageJson[BUILDER_BOB_PACKAGE];
5198
+ return typeof bobConfig === "object" && bobConfig !== null;
5199
+ };
5200
+ const checkReactNativeLibraryDependencies = (rootDirectory) => {
5201
+ const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
5202
+ if (!isBuilderBobLibrary(packageJson)) return [];
5203
+ const misplaced = ["react", "react-native"].filter((name) => packageJson.dependencies?.[name] !== void 0);
5204
+ if (misplaced.length === 0) return [];
5205
+ const quoted = misplaced.map((name) => `"${name}"`).join(" and ");
5206
+ return [{
5207
+ filePath: "package.json",
5208
+ plugin: "react-doctor",
5209
+ rule: "rn-library-react-in-dependencies",
5210
+ severity: "warning",
5211
+ 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.`,
5212
+ help: `Move ${quoted} to \`peerDependencies\` (keep ${misplaced.length === 1 ? "it" : "them"} in \`devDependencies\` for local development).`,
5213
+ line: 0,
5214
+ column: 0,
5215
+ category: "Correctness"
5216
+ }];
5217
+ };
5218
+ const BABEL_CONFIG_FILE_NAMES = [
5219
+ "babel.config.js",
5220
+ "babel.config.cjs",
5221
+ "babel.config.mjs",
5222
+ "babel.config.json",
5223
+ ".babelrc",
5224
+ ".babelrc.js",
5225
+ ".babelrc.json"
5226
+ ];
5227
+ const LEGACY_PRESET_SPEC = "module:metro-react-native-babel-preset";
5228
+ const checkReactNativeMetroBabelPreset = (rootDirectory) => {
5229
+ for (const fileName of BABEL_CONFIG_FILE_NAMES) {
5230
+ const filePath = path.join(rootDirectory, fileName);
5231
+ if (!isFile(filePath)) continue;
5232
+ let contents;
5233
+ try {
5234
+ contents = fs.readFileSync(filePath, "utf-8");
5235
+ } catch {
5236
+ continue;
5237
+ }
5238
+ if (!contents.includes(LEGACY_PRESET_SPEC)) continue;
5239
+ return [{
5240
+ filePath: fileName,
5241
+ plugin: "react-doctor",
5242
+ rule: "rn-no-metro-babel-preset",
5243
+ severity: "error",
5244
+ 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.",
5245
+ 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.",
5246
+ line: 0,
5247
+ column: 0,
5248
+ category: "Correctness"
5249
+ }];
5250
+ }
5251
+ return [];
5252
+ };
5253
+ const isReactNativeProject = (project) => project.framework === "react-native" || project.framework === "expo" || project.hasReactNativeWorkspace || project.expoVersion !== null;
5254
+ const checkReactNativeProject = (rootDirectory, project) => {
5255
+ if (!isReactNativeProject(project)) return [];
5256
+ return [...checkReactNativeMetroBabelPreset(rootDirectory), ...checkReactNativeLibraryDependencies(rootDirectory)];
5257
+ };
5088
5258
  const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
5089
5259
  const REDUCED_MOTION_FILE_GLOBS = [
5090
5260
  "*.ts",
@@ -5653,8 +5823,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
5653
5823
  const cache = yield* Cache.make({
5654
5824
  capacity: 16,
5655
5825
  timeToLive: CONFIG_CACHE_TTL_MS,
5656
- lookup: (directory) => Effect.sync(() => {
5657
- const loaded = loadConfigWithSource(directory);
5826
+ lookup: (directory) => Effect.promise(async () => {
5827
+ const loaded = await loadConfigWithSource(directory);
5658
5828
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
5659
5829
  return {
5660
5830
  config: loaded?.config ?? null,
@@ -7833,7 +8003,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7833
8003
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
7834
8004
  yield* beforeLint(project, lintIncludePaths ?? void 0);
7835
8005
  const isDiffMode = input.includePaths.length > 0;
7836
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
8006
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
7837
8007
  const transform = buildDiagnosticPipeline({
7838
8008
  rootDirectory: scanDirectory,
7839
8009
  userConfig: resolvedConfig.config,
@@ -7845,7 +8015,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7845
8015
  const environmentDiagnostics = isDiffMode ? [] : [
7846
8016
  ...checkReducedMotion(scanDirectory),
7847
8017
  ...checkPnpmHardening(scanDirectory),
7848
- ...checkExpoProject(scanDirectory, project)
8018
+ ...checkExpoProject(scanDirectory, project),
8019
+ ...checkReactNativeProject(scanDirectory, project)
7849
8020
  ];
7850
8021
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7851
8022
  const lintFailure = yield* Ref.make({
@@ -8392,7 +8563,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
8392
8563
  includePaths,
8393
8564
  customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
8394
8565
  respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
8395
- warnings: options.warnings ?? effectiveConfig?.warnings ?? false,
8566
+ warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
8396
8567
  adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
8397
8568
  ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
8398
8569
  runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
@@ -8414,7 +8585,7 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
8414
8585
  };
8415
8586
  const diagnose = async (directory, options = {}) => {
8416
8587
  const startTime = globalThis.performance.now();
8417
- const program = buildInspectProgram(resolveScanTarget(directory), options);
8588
+ const program = buildInspectProgram(await resolveScanTarget(directory), options);
8418
8589
  return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
8419
8590
  };
8420
8591
  //#endregion
@@ -8448,4 +8619,5 @@ const toJsonReport = (result, options) => buildJsonReport({
8448
8619
  //#endregion
8449
8620
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
8450
8621
 
8451
- //# sourceMappingURL=index.js.map
8622
+ //# sourceMappingURL=index.js.map
8623
+ //# debugId=df20b0d3-9e4e-52e0-8991-ce8a6349a04a
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: doctor-explain
3
+ description: Explain React Doctor rules and configure which ones run via doctor.config.* (or package.json#reactDoctor). Use when the user types `/doctor-explain` or `/doctor-config`, asks why a rule fired, disagrees with a rule, wants to disable/enable a rule, silence a category or tag, tune CI/PR noise, or asks "what does this rule mean". Covers the `react-doctor rules` CLI (list, explain, set, enable, disable, category, ignore-tag) and how config layers combine: ignore.tags disables matching rules before linting, rules over categories sets severity, surfaces controls visibility only.
4
+ version: "1.0.0"
5
+ ---
6
+
7
+ # Doctor Explain
8
+
9
+ Explains React Doctor rules and edits `doctor.config.*` safely. Use this when a user wants to understand a rule or change which rules run — not for fixing diagnostics (that is the `react-doctor` skill / `/doctor`).
10
+
11
+ Triggers: `/doctor-explain`, `/doctor-config`, "why did this rule fire", "I disagree with this rule", "turn this rule off", "stop flagging X", "too noisy", "disable design rules".
12
+
13
+ ## Workflow
14
+
15
+ 1. Identify the rule key from the diagnostic (e.g. `react-doctor/no-array-index-as-key`).
16
+ 2. Explain it before changing anything:
17
+
18
+ ```bash
19
+ npx react-doctor@latest rules explain react-doctor/no-array-index-as-key
20
+ ```
21
+
22
+ 3. Pick the narrowest control that matches the user's intent (see decision guide).
23
+ 4. Apply it with a `rules` subcommand (edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting).
24
+ 5. Validate the change did what they wanted:
25
+
26
+ ```bash
27
+ npx react-doctor@latest --verbose --diff
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ ```bash
33
+ npx react-doctor@latest rules list # every rule + its effective severity
34
+ npx react-doctor@latest rules list --configured # only what your config changed
35
+ npx react-doctor@latest rules list --category Performance # filter by category
36
+ npx react-doctor@latest rules explain <rule> # why it matters + how to configure
37
+ npx react-doctor@latest rules disable <rule> # rule never runs
38
+ npx react-doctor@latest rules enable <rule> # turn back on at its recommended severity
39
+ npx react-doctor@latest rules set <rule> warn # off | warn | error
40
+ npx react-doctor@latest rules category "React Native" off # whole category
41
+ npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …)
42
+ npx react-doctor@latest rules unignore-tag design
43
+ ```
44
+
45
+ Rule references accept the full key (`react-doctor/no-danger`), the bare id (`no-danger`), or a legacy key (`react/no-danger`).
46
+
47
+ ## Decision guide
48
+
49
+ Match the control to the intent — prefer the narrowest one:
50
+
51
+ - **User disagrees with one rule / it's a false positive for them** → `rules disable <rule>` (sets `rules.<key> = "off"`; the rule stops running everywhere). This is the default for "I don't want this rule".
52
+ - **Rule is fine but wrong severity** → `rules set <rule> warn` or `rules set <rule> error`.
53
+ - **A disabled-by-default rule they want on** → `rules enable <rule>`.
54
+ - **A whole area is unwanted** (e.g. all React Native rules) → `rules category "<Category>" off`.
55
+ - **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`) → `rules ignore-tag <tag>`.
56
+ - **Keep it locally but hide from PR comment / score / CI gate only** → do NOT disable. Edit `surfaces` in your config (`surfaces.prComment.excludeRules`, `surfaces.score.excludeTags`, `surfaces.ciFailure.excludeCategories`). The rule still shows in local `cli` output.
57
+
58
+ How the layers combine: `ignore.tags` disables every rule carrying that tag **before** linting, so a tagged rule stays off even if `rules`/`categories` set it to `warn`/`error` (a rule-level override cannot re-enable a tag-ignored rule). For rules that aren't tag-disabled, `rules` overrides `categories` overrides the rule's default. `surfaces` is visibility-only and never changes whether a rule runs.
59
+
60
+ ## Config shape
61
+
62
+ Config lives in `doctor.config.ts` (or `.js`/`.mjs`/`.cjs`/`.json`/`.jsonc`), or the `reactDoctor` key in `package.json`. The `rules` commands edit whichever exists — TS/JS edits preserve formatting (via magicast) — and create `doctor.config.json` when none does, stamping `$schema`:
63
+
64
+ ```ts
65
+ // doctor.config.ts
66
+ export default {
67
+ rules: { "react-doctor/no-array-index-as-key": "off" },
68
+ categories: { "React Native": "warn" },
69
+ ignore: { tags: ["design"] },
70
+ };
71
+ ```
72
+
73
+ ## Educating the user
74
+
75
+ When explaining a rule, lead with the "Why it matters" guidance from `rules explain` and, when they want depth, the per-rule recipe at `https://www.react.doctor/prompts/rules/<plugin>/<rule>.md`. Only after they understand it should you offer to disable it — many "bad" rules are catching real issues.
@@ -32,6 +32,10 @@ The playbook is the single source of truth — a scan → filter → triage →
32
32
 
33
33
  Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/rules/<plugin>/<rule>.md` (fetched on demand inside the playbook) so each fix uses the canonical, reviewer-tested recipe.
34
34
 
35
+ ## Configuring or explaining rules
36
+
37
+ When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), use the `doctor-explain` skill (alias `/doctor-config`). Start with `npx react-doctor@latest rules explain <rule>`, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`).
38
+
35
39
  ## Command
36
40
 
37
41
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor",
3
- "version": "0.2.14-dev.b9e9bcb",
3
+ "version": "0.2.14-dev.bdb9e36",
4
4
  "description": "Diagnose and fix React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -54,13 +54,16 @@
54
54
  "@sentry/node": "^10.54.0",
55
55
  "agent-install": "0.0.5",
56
56
  "conf": "^15.1.0",
57
+ "confbox": "^0.2.4",
57
58
  "deslop-js": "^0.0.14",
58
59
  "effect": "4.0.0-beta.70",
59
60
  "eslint-plugin-react-hooks": "^7.1.1",
61
+ "jiti": "^2.7.0",
62
+ "magicast": "^0.5.3",
60
63
  "oxlint": "^1.66.0",
61
64
  "prompts": "^2.4.2",
62
65
  "typescript": ">=5.0.4 <7",
63
- "oxlint-plugin-react-doctor": "0.2.14-dev.b9e9bcb"
66
+ "oxlint-plugin-react-doctor": "0.2.14-dev.bdb9e36"
64
67
  },
65
68
  "devDependencies": {
66
69
  "@types/babel__code-frame": "^7.27.0",