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/README.md +40 -2
- package/dist/cli.js +2203 -458
- package/dist/index.d.ts +31 -9
- package/dist/index.js +265 -69
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +8 -4
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: `
|
|
104
|
-
* —
|
|
105
|
-
*
|
|
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 `
|
|
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 `
|
|
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
|
|
424
|
-
* the config) opts
|
|
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 `
|
|
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/
|
|
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
|
-
"
|
|
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
|
|
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
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
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
|
-
|
|
4507
|
-
|
|
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
|
-
|
|
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
|
|
4520
|
-
const
|
|
4521
|
-
if (
|
|
4522
|
-
|
|
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
|
|
4534
|
-
if (
|
|
4535
|
-
|
|
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 `
|
|
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.
|
|
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 ??
|
|
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
|
|
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 ??
|
|
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(
|
|
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
|