react-doctor 0.2.14-dev.bb15252 → 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
@@ -742,7 +764,7 @@ interface BuildJsonReportInput {
742
764
  totalElapsedMilliseconds: number;
743
765
  }
744
766
  declare const buildJsonReport: (input: BuildJsonReportInput) => JsonReport; //#endregion
745
- //#region src/can-oxlint-extend-config.d.ts
767
+ //#region src/build-skipped-checks.d.ts
746
768
  //#endregion
747
769
  //#region src/get-diff-files.d.ts
748
770
  /**
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,69 +4492,109 @@ 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);
4488
- if (isFile(configFilePath)) try {
4489
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
4490
- const parsed = JSON.parse(fileContent);
4491
- if (isPlainObject(parsed)) return {
4492
- config: validateConfigTypes(parsed),
4493
- sourceDirectory: directory
4494
- };
4495
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4496
- } catch (error) {
4497
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
4498
- }
4499
- const packageJsonPath = path.join(directory, "package.json");
4500
- if (isFile(packageJsonPath)) try {
4501
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
4502
- const packageJson = JSON.parse(fileContent);
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"));
4503
4522
  if (isPlainObject(packageJson)) {
4504
4523
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4505
- if (isPlainObject(embeddedConfig)) return {
4506
- config: validateConfigTypes(embeddedConfig),
4507
- sourceDirectory: directory
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) => {
4540
+ let sawBrokenConfigFile = false;
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);
4545
+ try {
4546
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
4547
+ if (isPlainObject(parsed)) return {
4548
+ status: "found",
4549
+ loaded: {
4550
+ config: validateConfigTypes(parsed),
4551
+ sourceDirectory: directory,
4552
+ configFilePath: filePath,
4553
+ format: isDataFile ? "json" : "module"
4554
+ }
4508
4555
  };
4556
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
4557
+ sawBrokenConfigFile = true;
4558
+ } catch (error) {
4559
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
4560
+ sawBrokenConfigFile = true;
4509
4561
  }
4510
- } catch {
4511
- return null;
4512
4562
  }
4513
- return null;
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).`);
4569
+ return {
4570
+ status: sawBrokenConfigFile ? "invalid" : "absent",
4571
+ loaded: null
4572
+ };
4514
4573
  };
4515
4574
  const cachedConfigs = /* @__PURE__ */ new Map();
4516
4575
  const clearConfigCache = () => {
4517
4576
  cachedConfigs.clear();
4518
4577
  };
4519
- const loadConfigWithSource = (rootDirectory) => {
4520
- const cached = cachedConfigs.get(rootDirectory);
4521
- if (cached !== void 0) return cached;
4522
- const localConfig = loadConfigFromDirectory(rootDirectory);
4523
- if (localConfig) {
4524
- cachedConfigs.set(rootDirectory, localConfig);
4525
- return localConfig;
4526
- }
4527
- if (isProjectBoundary(rootDirectory)) {
4528
- cachedConfigs.set(rootDirectory, null);
4529
- return null;
4530
- }
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;
4531
4582
  let ancestorDirectory = path.dirname(rootDirectory);
4532
4583
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4533
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4534
- if (ancestorConfig) {
4535
- cachedConfigs.set(rootDirectory, ancestorConfig);
4536
- return ancestorConfig;
4537
- }
4538
- if (isProjectBoundary(ancestorDirectory)) {
4539
- cachedConfigs.set(rootDirectory, null);
4540
- return null;
4541
- }
4584
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
4585
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
4586
+ if (isProjectBoundary(ancestorDirectory)) return null;
4542
4587
  ancestorDirectory = path.dirname(ancestorDirectory);
4543
4588
  }
4544
- cachedConfigs.set(rootDirectory, null);
4545
4589
  return null;
4546
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
+ };
4547
4598
  const resolveConfigRootDir = (config, configSourceDirectory) => {
4548
4599
  if (!config || !configSourceDirectory) return null;
4549
4600
  const rawRootDir = config.rootDir;
@@ -4571,8 +4622,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
4571
4622
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
4572
4623
  *
4573
4624
  * 1. Resolve the requested directory to absolute.
4574
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
4575
- * if present.
4625
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
4576
4626
  * 3. Honor `config.rootDir` to redirect the scan to a nested
4577
4627
  * project root, if configured.
4578
4628
  * 4. Walk into a nested React subproject when the requested
@@ -4590,9 +4640,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
4590
4640
  * via its own cache). Routing through `resolveScanTarget` keeps every
4591
4641
  * shell in agreement on what "the scan directory" means.
4592
4642
  */
4593
- const resolveScanTarget = (requestedDirectory, options = {}) => {
4643
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
4594
4644
  const absoluteRequested = path.resolve(requestedDirectory);
4595
- const loadedConfig = loadConfigWithSource(absoluteRequested);
4645
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
4596
4646
  const userConfig = loadedConfig?.config ?? null;
4597
4647
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4598
4648
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
@@ -4916,6 +4966,61 @@ const checkExpoPackageJsonConflicts = (context) => {
4916
4966
  }));
4917
4967
  return diagnostics;
4918
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
+ };
4919
5024
  const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
4920
5025
  const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
4921
5026
  const checkExpoRouterReactNavigation = (context) => {
@@ -4931,6 +5036,17 @@ const checkExpoRouterReactNavigation = (context) => {
4931
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/"
4932
5037
  })];
4933
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
+ };
4934
5050
  const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
4935
5051
  const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
4936
5052
  const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
@@ -4957,7 +5073,9 @@ const checkExpoProject = (rootDirectory, project) => {
4957
5073
  ...checkExpoLockfile(context),
4958
5074
  ...checkExpoGitignore(context),
4959
5075
  ...checkExpoEnvLocalFiles(context),
4960
- ...checkExpoMetroConfig(context)
5076
+ ...checkExpoMetroConfig(context),
5077
+ ...checkExpoReanimatedNewArch(context),
5078
+ ...checkExpoUpdatesConfig(context)
4961
5079
  ];
4962
5080
  };
4963
5081
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
@@ -5074,6 +5192,69 @@ const checkPnpmHardening = (rootDirectory) => {
5074
5192
  }));
5075
5193
  return diagnostics;
5076
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
+ };
5077
5258
  const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
5078
5259
  const REDUCED_MOTION_FILE_GLOBS = [
5079
5260
  "*.ts",
@@ -5642,8 +5823,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
5642
5823
  const cache = yield* Cache.make({
5643
5824
  capacity: 16,
5644
5825
  timeToLive: CONFIG_CACHE_TTL_MS,
5645
- lookup: (directory) => Effect.sync(() => {
5646
- const loaded = loadConfigWithSource(directory);
5826
+ lookup: (directory) => Effect.promise(async () => {
5827
+ const loaded = await loadConfigWithSource(directory);
5647
5828
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
5648
5829
  return {
5649
5830
  config: loaded?.config ?? null,
@@ -7822,7 +8003,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7822
8003
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
7823
8004
  yield* beforeLint(project, lintIncludePaths ?? void 0);
7824
8005
  const isDiffMode = input.includePaths.length > 0;
7825
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
8006
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
7826
8007
  const transform = buildDiagnosticPipeline({
7827
8008
  rootDirectory: scanDirectory,
7828
8009
  userConfig: resolvedConfig.config,
@@ -7834,7 +8015,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7834
8015
  const environmentDiagnostics = isDiffMode ? [] : [
7835
8016
  ...checkReducedMotion(scanDirectory),
7836
8017
  ...checkPnpmHardening(scanDirectory),
7837
- ...checkExpoProject(scanDirectory, project)
8018
+ ...checkExpoProject(scanDirectory, project),
8019
+ ...checkReactNativeProject(scanDirectory, project)
7838
8020
  ];
7839
8021
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7840
8022
  const lintFailure = yield* Ref.make({
@@ -7949,7 +8131,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7949
8131
  "inspect.isCi": input.isCi,
7950
8132
  "inspect.scoreSurface": input.scoreSurface ?? "score"
7951
8133
  } }));
7952
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7953
8134
  const parseNodeVersion = (versionString) => {
7954
8135
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
7955
8136
  return {
@@ -8248,6 +8429,26 @@ const buildJsonReport = (input) => {
8248
8429
  };
8249
8430
  };
8250
8431
  /**
8432
+ * Single source of truth for the skipped-check accounting shared by the
8433
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
8434
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
8435
+ * failed lint / dead-code pass instead of a false "all clear", so the
8436
+ * branch logic lives here once.
8437
+ */
8438
+ const buildSkippedChecks = (input) => {
8439
+ const skippedChecks = [];
8440
+ if (input.didLintFail) skippedChecks.push("lint");
8441
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
8442
+ const skippedCheckReasons = {};
8443
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
8444
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
8445
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
8446
+ return {
8447
+ skippedChecks,
8448
+ skippedCheckReasons
8449
+ };
8450
+ };
8451
+ /**
8251
8452
  * Programmatic façade over `Git.diffSelection`. Async because the
8252
8453
  * Git service runs through Effect's `ChildProcess` (true subprocess
8253
8454
  * spawn, not `spawnSync`).
@@ -8353,7 +8554,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
8353
8554
  const clearAutoSuppressionCaches = () => {};
8354
8555
  //#endregion
8355
8556
  //#region ../api/dist/index.js
8356
- const DEFAULT_LAYER = Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
8557
+ const buildDiagnoseLayer = (configLayer = Config.layerNode) => Layer.mergeAll(Project.layerNode, configLayer, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
8357
8558
  const buildInspectProgram = (scanTarget, options, configOverride) => {
8358
8559
  const effectiveConfig = configOverride ?? scanTarget.userConfig;
8359
8560
  const includePaths = options.includePaths ?? [];
@@ -8362,7 +8563,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
8362
8563
  includePaths,
8363
8564
  customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
8364
8565
  respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
8365
- warnings: options.warnings ?? effectiveConfig?.warnings ?? false,
8566
+ warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
8366
8567
  adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
8367
8568
  ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
8368
8569
  runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
@@ -8372,13 +8573,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
8372
8573
  };
8373
8574
  const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
8374
8575
  if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
8375
- const skippedChecks = [];
8376
- if (output.didLintFail) skippedChecks.push("lint");
8377
- if (output.didDeadCodeFail) skippedChecks.push("dead-code");
8378
- const skippedCheckReasons = {};
8379
- if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
8380
- else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
8381
- if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
8576
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
8382
8577
  return {
8383
8578
  diagnostics: [...output.diagnostics],
8384
8579
  score: output.score,
@@ -8390,8 +8585,8 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
8390
8585
  };
8391
8586
  const diagnose = async (directory, options = {}) => {
8392
8587
  const startTime = globalThis.performance.now();
8393
- const program = buildInspectProgram(resolveScanTarget(directory), options);
8394
- return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(DEFAULT_LAYER), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
8588
+ const program = buildInspectProgram(await resolveScanTarget(directory), options);
8589
+ return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
8395
8590
  };
8396
8591
  //#endregion
8397
8592
  //#region src/index.ts
@@ -8424,4 +8619,5 @@ const toJsonReport = (result, options) => buildJsonReport({
8424
8619
  //#endregion
8425
8620
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
8426
8621
 
8427
- //# 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