react-doctor 0.2.14-dev.938376 → 0.2.14-dev.9777f1a

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
  /**
@@ -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
@@ -444,7 +466,7 @@ interface DiagnoseResult {
444
466
  * Scan options (`deadCode`, `lint`, etc.) are flat on the entry and
445
467
  * layer on top of the global defaults — omitted fields fall through.
446
468
  * `config` is a full `ReactDoctorConfig` override that replaces the
447
- * on-disk `react-doctor.config.json` for this project's scan.
469
+ * on-disk `doctor.config.*` for this project's scan.
448
470
  */
449
471
  //#endregion
450
472
  //#region src/types/inspect.d.ts
package/dist/index.js CHANGED
@@ -16,6 +16,8 @@ import * as Otlp from "effect/unstable/observability/Otlp";
16
16
  import * as Context from "effect/Context";
17
17
  import os from "node:os";
18
18
  import * as Console from "effect/Console";
19
+ import { parseJSON5 } from "confbox";
20
+ import { createJiti } from "jiti";
19
21
  import * as Fiber from "effect/Fiber";
20
22
  import * as Filter from "effect/Filter";
21
23
  import * as Option from "effect/Option";
@@ -3289,7 +3291,14 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
3289
3291
  "tsconfig.json",
3290
3292
  "tsconfig.base.json",
3291
3293
  "package.json",
3292
- "react-doctor.config.json",
3294
+ "doctor.config.ts",
3295
+ "doctor.config.mts",
3296
+ "doctor.config.cts",
3297
+ "doctor.config.js",
3298
+ "doctor.config.mjs",
3299
+ "doctor.config.cjs",
3300
+ "doctor.config.json",
3301
+ "doctor.config.jsonc",
3293
3302
  "oxlint.json",
3294
3303
  ".oxlintrc.json"
3295
3304
  ];
@@ -4481,43 +4490,80 @@ const validateConfigTypes = (config) => {
4481
4490
  const warn = (message) => {
4482
4491
  Effect.runSync(Console.warn(message));
4483
4492
  };
4484
- const CONFIG_FILENAME = "react-doctor.config.json";
4493
+ const CONFIG_BASENAME = "doctor.config";
4494
+ const CONFIG_EXTENSIONS = [
4495
+ "ts",
4496
+ "mts",
4497
+ "cts",
4498
+ "js",
4499
+ "mjs",
4500
+ "cjs",
4501
+ "json",
4502
+ "jsonc"
4503
+ ];
4504
+ const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
4505
+ const PACKAGE_JSON_FILENAME = "package.json";
4485
4506
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
4486
- const loadConfigFromDirectory = (directory) => {
4487
- const configFilePath = path.join(directory, CONFIG_FILENAME);
4507
+ const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
4508
+ const jiti = createJiti(import.meta.url);
4509
+ const formatError = (error) => error instanceof Error ? error.message : String(error);
4510
+ const loadModuleConfig = async (filePath) => {
4511
+ const imported = await jiti.import(filePath);
4512
+ return imported?.default ?? imported;
4513
+ };
4514
+ const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
4515
+ const readEmbeddedPackageJsonConfig = (directory) => {
4516
+ const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
4517
+ if (!isFile(packageJsonPath)) return null;
4518
+ try {
4519
+ const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
4520
+ if (isPlainObject(packageJson)) {
4521
+ const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4522
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
4523
+ }
4524
+ } catch {}
4525
+ return null;
4526
+ };
4527
+ const loadPackageJsonConfig = (directory) => {
4528
+ const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
4529
+ if (!embeddedConfig) return null;
4530
+ return {
4531
+ config: validateConfigTypes(embeddedConfig),
4532
+ sourceDirectory: directory,
4533
+ configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
4534
+ format: "package-json"
4535
+ };
4536
+ };
4537
+ const loadConfigFromDirectory = async (directory) => {
4488
4538
  let sawBrokenConfigFile = false;
4489
- if (isFile(configFilePath)) {
4539
+ for (const extension of CONFIG_EXTENSIONS) {
4540
+ const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
4541
+ if (!isFile(filePath)) continue;
4542
+ const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
4490
4543
  try {
4491
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
4492
- const parsed = JSON.parse(fileContent);
4544
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
4493
4545
  if (isPlainObject(parsed)) return {
4494
4546
  status: "found",
4495
4547
  loaded: {
4496
4548
  config: validateConfigTypes(parsed),
4497
- sourceDirectory: directory
4549
+ sourceDirectory: directory,
4550
+ configFilePath: filePath,
4551
+ format: isDataFile ? "json" : "module"
4498
4552
  }
4499
4553
  };
4500
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4554
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
4555
+ sawBrokenConfigFile = true;
4501
4556
  } catch (error) {
4502
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
4557
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
4558
+ sawBrokenConfigFile = true;
4503
4559
  }
4504
- sawBrokenConfigFile = true;
4505
4560
  }
4506
- const packageJsonPath = path.join(directory, "package.json");
4507
- if (isFile(packageJsonPath)) try {
4508
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
4509
- const packageJson = JSON.parse(fileContent);
4510
- if (isPlainObject(packageJson)) {
4511
- const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4512
- if (isPlainObject(embeddedConfig)) return {
4513
- status: "found",
4514
- loaded: {
4515
- config: validateConfigTypes(embeddedConfig),
4516
- sourceDirectory: directory
4517
- }
4518
- };
4519
- }
4520
- } catch {}
4561
+ const packageJsonConfig = loadPackageJsonConfig(directory);
4562
+ if (packageJsonConfig) return {
4563
+ status: "found",
4564
+ loaded: packageJsonConfig
4565
+ };
4566
+ 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
4567
  return {
4522
4568
  status: sawBrokenConfigFile ? "invalid" : "absent",
4523
4569
  loaded: null
@@ -4527,34 +4573,26 @@ const cachedConfigs = /* @__PURE__ */ new Map();
4527
4573
  const clearConfigCache = () => {
4528
4574
  cachedConfigs.clear();
4529
4575
  };
4530
- const loadConfigWithSource = (rootDirectory) => {
4531
- const cached = cachedConfigs.get(rootDirectory);
4532
- if (cached !== void 0) return cached;
4533
- const localResult = loadConfigFromDirectory(rootDirectory);
4534
- if (localResult.status === "found") {
4535
- cachedConfigs.set(rootDirectory, localResult.loaded);
4536
- return localResult.loaded;
4537
- }
4538
- if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
4539
- cachedConfigs.set(rootDirectory, null);
4540
- return null;
4541
- }
4576
+ const loadConfigWalkingUp = async (rootDirectory) => {
4577
+ const localResult = await loadConfigFromDirectory(rootDirectory);
4578
+ if (localResult.status === "found") return localResult.loaded;
4579
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
4542
4580
  let ancestorDirectory = path.dirname(rootDirectory);
4543
4581
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4544
- const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
4545
- if (ancestorResult.status === "found") {
4546
- cachedConfigs.set(rootDirectory, ancestorResult.loaded);
4547
- return ancestorResult.loaded;
4548
- }
4549
- if (isProjectBoundary(ancestorDirectory)) {
4550
- cachedConfigs.set(rootDirectory, null);
4551
- return null;
4552
- }
4582
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
4583
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
4584
+ if (isProjectBoundary(ancestorDirectory)) return null;
4553
4585
  ancestorDirectory = path.dirname(ancestorDirectory);
4554
4586
  }
4555
- cachedConfigs.set(rootDirectory, null);
4556
4587
  return null;
4557
4588
  };
4589
+ const loadConfigWithSource = (rootDirectory) => {
4590
+ const cached = cachedConfigs.get(rootDirectory);
4591
+ if (cached !== void 0) return cached;
4592
+ const loadPromise = loadConfigWalkingUp(rootDirectory);
4593
+ cachedConfigs.set(rootDirectory, loadPromise);
4594
+ return loadPromise;
4595
+ };
4558
4596
  const resolveConfigRootDir = (config, configSourceDirectory) => {
4559
4597
  if (!config || !configSourceDirectory) return null;
4560
4598
  const rawRootDir = config.rootDir;
@@ -4582,8 +4620,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
4582
4620
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
4583
4621
  *
4584
4622
  * 1. Resolve the requested directory to absolute.
4585
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
4586
- * if present.
4623
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
4587
4624
  * 3. Honor `config.rootDir` to redirect the scan to a nested
4588
4625
  * project root, if configured.
4589
4626
  * 4. Walk into a nested React subproject when the requested
@@ -4601,9 +4638,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
4601
4638
  * via its own cache). Routing through `resolveScanTarget` keeps every
4602
4639
  * shell in agreement on what "the scan directory" means.
4603
4640
  */
4604
- const resolveScanTarget = (requestedDirectory, options = {}) => {
4641
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
4605
4642
  const absoluteRequested = path.resolve(requestedDirectory);
4606
- const loadedConfig = loadConfigWithSource(absoluteRequested);
4643
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
4607
4644
  const userConfig = loadedConfig?.config ?? null;
4608
4645
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4609
4646
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
@@ -5653,8 +5690,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
5653
5690
  const cache = yield* Cache.make({
5654
5691
  capacity: 16,
5655
5692
  timeToLive: CONFIG_CACHE_TTL_MS,
5656
- lookup: (directory) => Effect.sync(() => {
5657
- const loaded = loadConfigWithSource(directory);
5693
+ lookup: (directory) => Effect.promise(async () => {
5694
+ const loaded = await loadConfigWithSource(directory);
5658
5695
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
5659
5696
  return {
5660
5697
  config: loaded?.config ?? null,
@@ -8414,7 +8451,7 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
8414
8451
  };
8415
8452
  const diagnose = async (directory, options = {}) => {
8416
8453
  const startTime = globalThis.performance.now();
8417
- const program = buildInspectProgram(resolveScanTarget(directory), options);
8454
+ const program = buildInspectProgram(await resolveScanTarget(directory), options);
8418
8455
  return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
8419
8456
  };
8420
8457
  //#endregion
@@ -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.0938376",
3
+ "version": "0.2.14-dev.9777f1a",
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.0938376"
66
+ "oxlint-plugin-react-doctor": "0.2.14-dev.9777f1a"
64
67
  },
65
68
  "devDependencies": {
66
69
  "@types/babel__code-frame": "^7.27.0",
@@ -68,8 +71,8 @@
68
71
  "@xterm/headless": "^6.0.0",
69
72
  "commander": "^14.0.3",
70
73
  "ora": "^9.4.0",
71
- "@react-doctor/core": "0.2.14",
72
- "@react-doctor/api": "0.2.14"
74
+ "@react-doctor/api": "0.2.14",
75
+ "@react-doctor/core": "0.2.14"
73
76
  },
74
77
  "engines": {
75
78
  "node": "^20.19.0 || >=22.12.0"