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/README.md +40 -2
- package/dist/cli.js +1811 -404
- package/dist/index.d.ts +30 -8
- package/dist/index.js +232 -60
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +5 -2
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
|
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,43 +4492,80 @@ 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
|
-
|
|
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
|
-
|
|
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
|
|
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(`${
|
|
4556
|
+
warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
|
|
4557
|
+
sawBrokenConfigFile = true;
|
|
4501
4558
|
} catch (error) {
|
|
4502
|
-
warn(`Failed to
|
|
4559
|
+
warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
|
|
4560
|
+
sawBrokenConfigFile = true;
|
|
4503
4561
|
}
|
|
4504
|
-
sawBrokenConfigFile = true;
|
|
4505
4562
|
}
|
|
4506
|
-
const
|
|
4507
|
-
if (
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
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
|
|
4531
|
-
const
|
|
4532
|
-
if (
|
|
4533
|
-
|
|
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
|
-
|
|
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 `
|
|
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.
|
|
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 ??
|
|
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 ??
|
|
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.
|
|
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.
|
|
66
|
+
"oxlint-plugin-react-doctor": "0.2.14-dev.bdb9e36"
|
|
64
67
|
},
|
|
65
68
|
"devDependencies": {
|
|
66
69
|
"@types/babel__code-frame": "^7.27.0",
|