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/cli.js CHANGED
@@ -17,6 +17,8 @@ import * as Otlp from "effect/unstable/observability/Otlp";
17
17
  import * as Context from "effect/Context";
18
18
  import os, { tmpdir } from "node:os";
19
19
  import * as Console from "effect/Console";
20
+ import { parseJSON5 } from "confbox";
21
+ import { createJiti } from "jiti";
20
22
  import * as Fiber from "effect/Fiber";
21
23
  import * as Filter from "effect/Filter";
22
24
  import * as Option from "effect/Option";
@@ -40,6 +42,8 @@ import basePrompts from "prompts";
40
42
  import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
41
43
  import { fileURLToPath } from "node:url";
42
44
  import Conf from "conf";
45
+ import { generateCode, loadFile, writeFile } from "magicast";
46
+ import { getConfigFromVariableDeclaration, getDefaultExportOptions } from "magicast/helpers";
43
47
  //#region \0rolldown/runtime.js
44
48
  var __create$1 = Object.create;
45
49
  var __defProp$1 = Object.defineProperty;
@@ -6310,7 +6314,14 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
6310
6314
  "tsconfig.json",
6311
6315
  "tsconfig.base.json",
6312
6316
  "package.json",
6313
- "react-doctor.config.json",
6317
+ "doctor.config.ts",
6318
+ "doctor.config.mts",
6319
+ "doctor.config.cts",
6320
+ "doctor.config.js",
6321
+ "doctor.config.mjs",
6322
+ "doctor.config.cjs",
6323
+ "doctor.config.json",
6324
+ "doctor.config.jsonc",
6314
6325
  "oxlint.json",
6315
6326
  ".oxlintrc.json"
6316
6327
  ];
@@ -7501,77 +7512,135 @@ const validateConfigTypes = (config) => {
7501
7512
  const warn = (message) => {
7502
7513
  Effect.runSync(Console.warn(message));
7503
7514
  };
7504
- const CONFIG_FILENAME = "react-doctor.config.json";
7505
- const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
7506
- const loadConfigFromDirectory = (directory) => {
7507
- const configFilePath = path.join(directory, CONFIG_FILENAME);
7515
+ const CONFIG_BASENAME = "doctor.config";
7516
+ const CONFIG_EXTENSIONS = [
7517
+ "ts",
7518
+ "mts",
7519
+ "cts",
7520
+ "js",
7521
+ "mjs",
7522
+ "cjs",
7523
+ "json",
7524
+ "jsonc"
7525
+ ];
7526
+ const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
7527
+ const PACKAGE_JSON_FILENAME = "package.json";
7528
+ const PACKAGE_JSON_CONFIG_KEY$1 = "reactDoctor";
7529
+ const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
7530
+ const jiti = createJiti(import.meta.url);
7531
+ const formatError = (error) => error instanceof Error ? error.message : String(error);
7532
+ const loadModuleConfig = async (filePath) => {
7533
+ const imported = await jiti.import(filePath);
7534
+ return imported?.default ?? imported;
7535
+ };
7536
+ const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
7537
+ const readEmbeddedPackageJsonConfig = (directory) => {
7538
+ const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
7539
+ if (!isFile(packageJsonPath)) return null;
7540
+ try {
7541
+ const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
7542
+ if (isPlainObject(packageJson)) {
7543
+ const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY$1];
7544
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
7545
+ }
7546
+ } catch {}
7547
+ return null;
7548
+ };
7549
+ const loadPackageJsonConfig = (directory) => {
7550
+ const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
7551
+ if (!embeddedConfig) return null;
7552
+ return {
7553
+ config: validateConfigTypes(embeddedConfig),
7554
+ sourceDirectory: directory,
7555
+ configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
7556
+ format: "package-json"
7557
+ };
7558
+ };
7559
+ const loadConfigFromDirectory = async (directory) => {
7508
7560
  let sawBrokenConfigFile = false;
7509
- if (isFile(configFilePath)) {
7561
+ for (const extension of CONFIG_EXTENSIONS) {
7562
+ const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
7563
+ if (!isFile(filePath)) continue;
7564
+ const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
7510
7565
  try {
7511
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
7512
- const parsed = JSON.parse(fileContent);
7566
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
7513
7567
  if (isPlainObject(parsed)) return {
7514
7568
  status: "found",
7515
7569
  loaded: {
7516
7570
  config: validateConfigTypes(parsed),
7517
- sourceDirectory: directory
7571
+ sourceDirectory: directory,
7572
+ configFilePath: filePath,
7573
+ format: isDataFile ? "json" : "module"
7518
7574
  }
7519
7575
  };
7520
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
7576
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
7577
+ sawBrokenConfigFile = true;
7521
7578
  } catch (error) {
7522
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
7579
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
7580
+ sawBrokenConfigFile = true;
7523
7581
  }
7524
- sawBrokenConfigFile = true;
7525
7582
  }
7526
- const packageJsonPath = path.join(directory, "package.json");
7527
- if (isFile(packageJsonPath)) try {
7528
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
7529
- const packageJson = JSON.parse(fileContent);
7530
- if (isPlainObject(packageJson)) {
7531
- const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
7532
- if (isPlainObject(embeddedConfig)) return {
7533
- status: "found",
7534
- loaded: {
7535
- config: validateConfigTypes(embeddedConfig),
7536
- sourceDirectory: directory
7537
- }
7538
- };
7539
- }
7540
- } catch {}
7583
+ const packageJsonConfig = loadPackageJsonConfig(directory);
7584
+ if (packageJsonConfig) return {
7585
+ status: "found",
7586
+ loaded: packageJsonConfig
7587
+ };
7588
+ 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).`);
7541
7589
  return {
7542
7590
  status: sawBrokenConfigFile ? "invalid" : "absent",
7543
7591
  loaded: null
7544
7592
  };
7545
7593
  };
7546
7594
  const cachedConfigs = /* @__PURE__ */ new Map();
7547
- const loadConfigWithSource = (rootDirectory) => {
7548
- const cached = cachedConfigs.get(rootDirectory);
7549
- if (cached !== void 0) return cached;
7550
- const localResult = loadConfigFromDirectory(rootDirectory);
7551
- if (localResult.status === "found") {
7552
- cachedConfigs.set(rootDirectory, localResult.loaded);
7553
- return localResult.loaded;
7554
- }
7555
- if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
7556
- cachedConfigs.set(rootDirectory, null);
7557
- return null;
7558
- }
7595
+ const clearConfigCache = () => {
7596
+ cachedConfigs.clear();
7597
+ };
7598
+ const loadConfigWalkingUp = async (rootDirectory) => {
7599
+ const localResult = await loadConfigFromDirectory(rootDirectory);
7600
+ if (localResult.status === "found") return localResult.loaded;
7601
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
7559
7602
  let ancestorDirectory = path.dirname(rootDirectory);
7560
7603
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
7561
- const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
7562
- if (ancestorResult.status === "found") {
7563
- cachedConfigs.set(rootDirectory, ancestorResult.loaded);
7564
- return ancestorResult.loaded;
7565
- }
7566
- if (isProjectBoundary(ancestorDirectory)) {
7567
- cachedConfigs.set(rootDirectory, null);
7568
- return null;
7569
- }
7604
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
7605
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
7606
+ if (isProjectBoundary(ancestorDirectory)) return null;
7570
7607
  ancestorDirectory = path.dirname(ancestorDirectory);
7571
7608
  }
7572
- cachedConfigs.set(rootDirectory, null);
7573
7609
  return null;
7574
7610
  };
7611
+ const loadConfigWithSource = (rootDirectory) => {
7612
+ const cached = cachedConfigs.get(rootDirectory);
7613
+ if (cached !== void 0) return cached;
7614
+ const loadPromise = loadConfigWalkingUp(rootDirectory);
7615
+ cachedConfigs.set(rootDirectory, loadPromise);
7616
+ return loadPromise;
7617
+ };
7618
+ const directoryHasCurrentConfig = (directory) => {
7619
+ for (const extension of CONFIG_EXTENSIONS) if (isFile(path.join(directory, `${CONFIG_BASENAME}.${extension}`))) return true;
7620
+ return readEmbeddedPackageJsonConfig(directory) !== null;
7621
+ };
7622
+ /**
7623
+ * Walks up from `rootDirectory` (same boundary semantics as
7624
+ * `loadConfigWithSource`) looking for a pre-migration
7625
+ * `react-doctor.config.json` that is no longer read. Returns the first one
7626
+ * found, or `null` when a current-format config supersedes it or none exists
7627
+ * before a project boundary. Detection only — the CLI performs the rename.
7628
+ */
7629
+ const findLegacyConfig = (rootDirectory) => {
7630
+ let directory = rootDirectory;
7631
+ while (true) {
7632
+ if (directoryHasCurrentConfig(directory)) return null;
7633
+ const legacyFilePath = path.join(directory, LEGACY_CONFIG_FILENAME);
7634
+ if (isFile(legacyFilePath)) return {
7635
+ legacyFilePath,
7636
+ directory
7637
+ };
7638
+ if (isProjectBoundary(directory)) return null;
7639
+ const parentDirectory = path.dirname(directory);
7640
+ if (parentDirectory === directory) return null;
7641
+ directory = parentDirectory;
7642
+ }
7643
+ };
7575
7644
  const resolveConfigRootDir = (config, configSourceDirectory) => {
7576
7645
  if (!config || !configSourceDirectory) return null;
7577
7646
  const rawRootDir = config.rootDir;
@@ -7599,8 +7668,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
7599
7668
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
7600
7669
  *
7601
7670
  * 1. Resolve the requested directory to absolute.
7602
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
7603
- * if present.
7671
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
7604
7672
  * 3. Honor `config.rootDir` to redirect the scan to a nested
7605
7673
  * project root, if configured.
7606
7674
  * 4. Walk into a nested React subproject when the requested
@@ -7618,9 +7686,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
7618
7686
  * via its own cache). Routing through `resolveScanTarget` keeps every
7619
7687
  * shell in agreement on what "the scan directory" means.
7620
7688
  */
7621
- const resolveScanTarget = (requestedDirectory, options = {}) => {
7689
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
7622
7690
  const absoluteRequested = path.resolve(requestedDirectory);
7623
- const loadedConfig = loadConfigWithSource(absoluteRequested);
7691
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
7624
7692
  const userConfig = loadedConfig?.config ?? null;
7625
7693
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
7626
7694
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
@@ -8667,8 +8735,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
8667
8735
  const cache = yield* Cache.make({
8668
8736
  capacity: 16,
8669
8737
  timeToLive: CONFIG_CACHE_TTL_MS,
8670
- lookup: (directory) => Effect.sync(() => {
8671
- const loaded = loadConfigWithSource(directory);
8738
+ lookup: (directory) => Effect.promise(async () => {
8739
+ const loaded = await loadConfigWithSource(directory);
8672
8740
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
8673
8741
  return {
8674
8742
  config: loaded?.config ?? null,
@@ -11477,7 +11545,7 @@ const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\
11477
11545
  const SENTRY_DSN = "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920";
11478
11546
  //#endregion
11479
11547
  //#region src/cli/utils/version.ts
11480
- const VERSION = "0.2.14-dev.0938376";
11548
+ const VERSION = "0.2.14-dev.9777f1a";
11481
11549
  //#endregion
11482
11550
  //#region src/instrument.ts
11483
11551
  let isInitialized = false;
@@ -15547,7 +15615,7 @@ const inspect = async (directory, inputOptions = {}) => {
15547
15615
  userConfig = inputOptions.configOverride ?? null;
15548
15616
  configSourceDirectory = null;
15549
15617
  } else {
15550
- const scanTarget = resolveScanTarget(directory);
15618
+ const scanTarget = await resolveScanTarget(directory);
15551
15619
  scanDirectory = scanTarget.resolvedDirectory;
15552
15620
  userConfig = scanTarget.userConfig;
15553
15621
  configSourceDirectory = scanTarget.configSourceDirectory;
@@ -16055,7 +16123,7 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
16055
16123
  const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
16056
16124
  const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
16057
16125
  const CURSOR_HOOKS_SCHEMA_VERSION = 1;
16058
- const JSON_INDENT_SPACES = 2;
16126
+ const JSON_INDENT_SPACES$1 = 2;
16059
16127
  const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
16060
16128
  const readJsonFile = (filePath, fallback) => {
16061
16129
  if (!existsSync(filePath)) return fallback;
@@ -16065,7 +16133,7 @@ const readJsonFile = (filePath, fallback) => {
16065
16133
  };
16066
16134
  const writeJsonFile = (filePath, value) => {
16067
16135
  mkdirSync(path.dirname(filePath), { recursive: true });
16068
- writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES)}\n`);
16136
+ writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
16069
16137
  };
16070
16138
  const writeHookScript = (filePath) => {
16071
16139
  mkdirSync(path.dirname(filePath), { recursive: true });
@@ -16831,6 +16899,29 @@ const getSkillSourceDirectory = () => {
16831
16899
  const distDirectory = path.dirname(fileURLToPath(import.meta.url));
16832
16900
  return path.join(distDirectory, "skills", SKILL_NAME);
16833
16901
  };
16902
+ const findBundledSiblingSkills = (primarySkillDir) => {
16903
+ const skillsParent = path.dirname(primarySkillDir);
16904
+ if (!existsSync(skillsParent)) return [];
16905
+ const resolvedPrimary = path.resolve(primarySkillDir);
16906
+ return readdirSync(skillsParent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => ({
16907
+ name: entry.name,
16908
+ source: path.join(skillsParent, entry.name)
16909
+ })).filter((sibling) => path.resolve(sibling.source) !== resolvedPrimary && existsSync(path.join(sibling.source, SKILL_MANIFEST_FILE)));
16910
+ };
16911
+ const installBundledSiblingSkills = async (primarySkillDir, agents, projectRoot) => {
16912
+ const installedSkillNames = [];
16913
+ for (const sibling of findBundledSiblingSkills(primarySkillDir)) {
16914
+ const result = await installSkillsFromSource({
16915
+ source: sibling.source,
16916
+ agents: [...agents],
16917
+ cwd: projectRoot,
16918
+ mode: "copy"
16919
+ });
16920
+ if (result.failed.length > 0) throw new Error(result.failed.map((failure) => `${getSkillAgentConfig(failure.agent).displayName}: ${failure.error}`).join("\n"));
16921
+ if (result.skills.length > 0) installedSkillNames.push(sibling.name);
16922
+ }
16923
+ return installedSkillNames;
16924
+ };
16834
16925
  const canInstallNativeAgentHooks = (agents) => agents.some((agent) => agent === "claude-code" || agent === "cursor");
16835
16926
  const buildWorkflowContent = () => [
16836
16927
  "name: React Doctor",
@@ -16937,6 +17028,7 @@ const runInstallReactDoctor = async (options = {}) => {
16937
17028
  cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
16938
17029
  for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
16939
17030
  cliLogger.dim(` Source: ${sourceDir}`);
17031
+ for (const sibling of findBundledSiblingSkills(sourceDir)) cliLogger.dim(` Also installs skill: ${sibling.name}`);
16940
17032
  cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
16941
17033
  cliLogger.dim(" Dev dependency: react-doctor");
16942
17034
  if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
@@ -16959,6 +17051,12 @@ const runInstallReactDoctor = async (options = {}) => {
16959
17051
  installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
16960
17052
  throw error;
16961
17053
  }
17054
+ try {
17055
+ const installedSiblingSkills = await installBundledSiblingSkills(sourceDir, selectedAgents, projectRoot);
17056
+ if (installedSiblingSkills.length > 0) cliLogger.dim(` Also installed the ${installedSiblingSkills.join(", ")} skill.`);
17057
+ } catch {
17058
+ cliLogger.dim(" Skipped bundled sibling skills (install error).");
17059
+ }
16962
17060
  await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
16963
17061
  if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
16964
17062
  const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
@@ -17144,6 +17242,70 @@ const handoffToAgent = async (input) => {
17144
17242
  }
17145
17243
  };
17146
17244
  //#endregion
17245
+ //#region src/cli/utils/read-object-file.ts
17246
+ /**
17247
+ * Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
17248
+ * unparseable, or not an object. JSON5 parsing tolerates comments and
17249
+ * trailing commas so hand-edited config files round-trip.
17250
+ */
17251
+ const readObjectFile = (filePath) => {
17252
+ try {
17253
+ const parsed = parseJSON5(readFileSync(filePath, "utf-8"));
17254
+ return isPlainObject(parsed) ? parsed : null;
17255
+ } catch {
17256
+ return null;
17257
+ }
17258
+ };
17259
+ //#endregion
17260
+ //#region src/cli/utils/serialize-ts-object-literal.ts
17261
+ const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
17262
+ const INDENT_UNIT = " ";
17263
+ const serializeKey = (key) => SAFE_IDENTIFIER_PATTERN.test(key) ? key : JSON.stringify(key);
17264
+ /**
17265
+ * Serializes a JSON-compatible value as an idiomatic TypeScript literal:
17266
+ * identifier-shaped object keys stay unquoted, two-space indented, no blank
17267
+ * lines. Intended for JSON-sourced config values (string / number / boolean /
17268
+ * null / array / plain object); any other type falls back to its JSON form.
17269
+ */
17270
+ const serializeTsObjectLiteral = (value, depth = 0) => {
17271
+ const indent = INDENT_UNIT.repeat(depth);
17272
+ const childIndent = INDENT_UNIT.repeat(depth + 1);
17273
+ if (Array.isArray(value)) {
17274
+ if (value.length === 0) return "[]";
17275
+ return `[\n${value.map((item) => `${childIndent}${serializeTsObjectLiteral(item, depth + 1)}`).join(",\n")}\n${indent}]`;
17276
+ }
17277
+ if (isPlainObject(value)) {
17278
+ const keys = Object.keys(value);
17279
+ if (keys.length === 0) return "{}";
17280
+ return `{\n${keys.map((key) => `${childIndent}${serializeKey(key)}: ${serializeTsObjectLiteral(value[key], depth + 1)}`).join(",\n")}\n${indent}}`;
17281
+ }
17282
+ return JSON.stringify(value);
17283
+ };
17284
+ //#endregion
17285
+ //#region src/cli/utils/migrate-legacy-config.ts
17286
+ const MIGRATED_CONFIG_FILENAME = "doctor.config.ts";
17287
+ /**
17288
+ * Renames a pre-migration `react-doctor.config.json` to a typed
17289
+ * `doctor.config.ts`, preserving the user's settings as the default export.
17290
+ * `$schema` is dropped — the `ReactDoctorConfig` type supersedes it for
17291
+ * editor autocomplete. Returns the new file's absolute path, or `null` when
17292
+ * the legacy file can't be parsed as an object (left untouched so the user
17293
+ * can resolve it by hand).
17294
+ */
17295
+ const migrateLegacyConfig = (legacy) => {
17296
+ const parsed = readObjectFile(legacy.legacyFilePath);
17297
+ if (!parsed) return null;
17298
+ const config = { ...parsed };
17299
+ delete config.$schema;
17300
+ const targetPath = path.join(legacy.directory, MIGRATED_CONFIG_FILENAME);
17301
+ writeFileSync(targetPath, `import type { ReactDoctorConfig } from "react-doctor/api";
17302
+
17303
+ export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
17304
+ `);
17305
+ rmSync(legacy.legacyFilePath, { force: true });
17306
+ return targetPath;
17307
+ };
17308
+ //#endregion
17147
17309
  //#region src/cli/utils/json-mode.ts
17148
17310
  let context = null;
17149
17311
  /**
@@ -17814,6 +17976,24 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
17814
17976
  changedFiles,
17815
17977
  isCurrentChanges: false
17816
17978
  });
17979
+ /**
17980
+ * On an interactive human run, rename a pre-migration
17981
+ * `react-doctor.config.json` to `doctor.config.ts` before config is loaded,
17982
+ * so the scan reads the renamed file and the user is told once. CI, coding
17983
+ * agents, JSON/score output, pre-commit (`--staged`) hooks, and non-TTY runs
17984
+ * are left untouched — the loader's warning still nudges them — so a scan
17985
+ * never mutates the repo unattended.
17986
+ */
17987
+ const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
17988
+ if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
17989
+ const legacyConfig = findLegacyConfig(requestedDirectory);
17990
+ if (!legacyConfig) return;
17991
+ const migratedPath = migrateLegacyConfig(legacyConfig);
17992
+ if (!migratedPath) return;
17993
+ cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
17994
+ cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
17995
+ cliLogger.break();
17996
+ };
17817
17997
  const inspectAction = async (directory, flags) => {
17818
17998
  const isScoreOnly = Boolean(flags.score);
17819
17999
  const isJsonMode = Boolean(flags.json);
@@ -17826,7 +18006,11 @@ const inspectAction = async (directory, flags) => {
17826
18006
  });
17827
18007
  try {
17828
18008
  validateModeFlags(flags);
17829
- const scanTarget = resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
18009
+ maybeMigrateLegacyConfig(requestedDirectory, {
18010
+ isQuiet,
18011
+ isStaged: Boolean(flags.staged)
18012
+ });
18013
+ const scanTarget = await resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
17830
18014
  const userConfig = scanTarget.userConfig;
17831
18015
  const resolvedDirectory = scanTarget.resolvedDirectory;
17832
18016
  setJsonReportDirectory(resolvedDirectory);
@@ -18027,6 +18211,518 @@ const installAction = async (options, command) => {
18027
18211
  }
18028
18212
  };
18029
18213
  //#endregion
18214
+ //#region src/cli/utils/rule-catalog.ts
18215
+ const buildRuleCatalog = () => REACT_DOCTOR_RULES.map((entry) => ({
18216
+ key: entry.key,
18217
+ id: entry.id,
18218
+ category: entry.rule.category ?? "Other",
18219
+ defaultSeverity: entry.rule.severity,
18220
+ framework: entry.rule.framework ?? "global",
18221
+ tags: entry.rule.tags ?? [],
18222
+ recommendation: entry.rule.recommendation,
18223
+ defaultEnabled: entry.rule.defaultEnabled !== false
18224
+ }));
18225
+ /**
18226
+ * Resolves a user-supplied rule reference to a catalog entry. Accepts the
18227
+ * fully-qualified key (`react-doctor/no-danger`), the bare id (`no-danger`),
18228
+ * and legacy plugin keys (`react/no-danger`) via the shared alias map.
18229
+ */
18230
+ const findRuleInCatalog = (catalog, ruleQuery) => {
18231
+ const normalizedQuery = ruleQuery.trim();
18232
+ if (normalizedQuery.length === 0) return void 0;
18233
+ const directMatch = catalog.find((entry) => entry.key === normalizedQuery || entry.id === normalizedQuery);
18234
+ if (directMatch) return directMatch;
18235
+ return catalog.find((entry) => isSameRuleKey(entry.key, normalizedQuery));
18236
+ };
18237
+ const listRuleCategories = (catalog) => [...new Set(catalog.map((entry) => entry.category))].sort();
18238
+ const listRuleTags = (catalog) => [...new Set(catalog.flatMap((entry) => [...entry.tags]))].sort();
18239
+ //#endregion
18240
+ //#region src/cli/utils/render-rule-catalog.ts
18241
+ const SEVERITY_COLUMN_WIDTH_CHARS = 6;
18242
+ const colorizeSeverity = (severity, text) => {
18243
+ if (severity === "error") return highlighter.error(text);
18244
+ if (severity === "warn") return highlighter.warn(text);
18245
+ return highlighter.gray(text);
18246
+ };
18247
+ const formatSourceNote = (effective) => effective.source === "default" ? highlighter.dim("(default)") : highlighter.dim(`(${effective.source})`);
18248
+ const renderRuleCatalog = (rows) => {
18249
+ if (rows.length === 0) return highlighter.dim("No rules match the given filters.");
18250
+ const rowsByCategory = /* @__PURE__ */ new Map();
18251
+ for (const row of rows) {
18252
+ const bucket = rowsByCategory.get(row.entry.category) ?? [];
18253
+ bucket.push(row);
18254
+ rowsByCategory.set(row.entry.category, bucket);
18255
+ }
18256
+ const lines = [];
18257
+ for (const category of [...rowsByCategory.keys()].sort()) {
18258
+ const categoryRows = (rowsByCategory.get(category) ?? []).sort((leftRow, rightRow) => leftRow.entry.key.localeCompare(rightRow.entry.key));
18259
+ lines.push(highlighter.bold(`${category} ${highlighter.dim(`(${categoryRows.length})`)}`));
18260
+ for (const row of categoryRows) {
18261
+ const severityBadge = colorizeSeverity(row.effective.value, row.effective.value.padEnd(SEVERITY_COLUMN_WIDTH_CHARS));
18262
+ const tagSuffix = row.entry.tags.length > 0 ? highlighter.dim(` [${row.entry.tags.join(", ")}]`) : "";
18263
+ lines.push(` ${severityBadge} ${row.entry.key} ${formatSourceNote(row.effective)}${tagSuffix}`);
18264
+ }
18265
+ lines.push("");
18266
+ }
18267
+ lines.push(highlighter.dim(`${rows.length} rule${rows.length === 1 ? "" : "s"} shown.`));
18268
+ return lines.join("\n");
18269
+ };
18270
+ const DETAIL_LABEL_COLUMN_WIDTH_CHARS = 18;
18271
+ const formatDetailRow = (label, value) => ` ${highlighter.dim(label.padEnd(DETAIL_LABEL_COLUMN_WIDTH_CHARS))}${value}`;
18272
+ const renderRuleExplanation = (row) => {
18273
+ const { entry, effective } = row;
18274
+ const lines = [highlighter.bold(entry.key), ""];
18275
+ lines.push(formatDetailRow("Category", entry.category));
18276
+ lines.push(formatDetailRow("Default severity", entry.defaultSeverity));
18277
+ lines.push(formatDetailRow("Current severity", `${colorizeSeverity(effective.value, effective.value)} ${formatSourceNote(effective)}`));
18278
+ lines.push(formatDetailRow("Framework", entry.framework));
18279
+ lines.push(formatDetailRow("Tags", entry.tags.length > 0 ? entry.tags.join(", ") : "none"));
18280
+ lines.push(formatDetailRow("Default enabled", entry.defaultEnabled ? "yes" : "no (opt-in)"));
18281
+ lines.push("");
18282
+ lines.push(highlighter.bold("Why it matters"));
18283
+ lines.push(` ${entry.recommendation ?? "No additional guidance recorded for this rule yet."}`);
18284
+ lines.push("");
18285
+ lines.push(highlighter.bold("Configure"));
18286
+ lines.push(highlighter.dim(` react-doctor rules disable ${entry.key}`));
18287
+ lines.push(highlighter.dim(` react-doctor rules enable ${entry.key} --severity error`));
18288
+ lines.push(highlighter.dim(` react-doctor rules set ${entry.key} warn`));
18289
+ lines.push("");
18290
+ lines.push(highlighter.bold("Learn more"));
18291
+ lines.push(highlighter.dim(` ${buildRuleDocsUrl("react-doctor", entry.id)}`));
18292
+ return lines.join("\n");
18293
+ };
18294
+ //#endregion
18295
+ //#region src/cli/utils/rule-config-file.ts
18296
+ const NEW_CONFIG_FILENAME = "doctor.config.json";
18297
+ const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
18298
+ const JSON_INDENT_SPACES = 2;
18299
+ const MANAGED_KEYS = [
18300
+ "rules",
18301
+ "categories",
18302
+ "ignore"
18303
+ ];
18304
+ /**
18305
+ * Decides where a rule-config mutation should be written. Discovery
18306
+ * reuses `loadConfigWithSource` (the loader the scan uses) so edits land
18307
+ * in the file the scan reads — `doctor.config.{ts,js,…}` is preferred,
18308
+ * then `package.json#reactDoctor`. When nothing exists, a fresh
18309
+ * `doctor.config.json` is targeted at `projectRoot`. Data configs are
18310
+ * re-read raw so unrelated fields round-trip untouched.
18311
+ */
18312
+ const resolveRuleConfigTarget = async (projectRoot) => {
18313
+ clearConfigCache();
18314
+ const loaded = await loadConfigWithSource(projectRoot);
18315
+ if (loaded) {
18316
+ if (loaded.format === "package-json") {
18317
+ const embedded = (readObjectFile(loaded.configFilePath) ?? {})[PACKAGE_JSON_CONFIG_KEY];
18318
+ return {
18319
+ format: "package-json",
18320
+ filePath: loaded.configFilePath,
18321
+ directory: loaded.sourceDirectory,
18322
+ exists: true,
18323
+ config: isPlainObject(embedded) ? embedded : {}
18324
+ };
18325
+ }
18326
+ if (loaded.format === "json") return {
18327
+ format: "json",
18328
+ filePath: loaded.configFilePath,
18329
+ directory: loaded.sourceDirectory,
18330
+ exists: true,
18331
+ config: readObjectFile(loaded.configFilePath) ?? {}
18332
+ };
18333
+ return {
18334
+ format: "module",
18335
+ filePath: loaded.configFilePath,
18336
+ directory: loaded.sourceDirectory,
18337
+ exists: true,
18338
+ config: loaded.config
18339
+ };
18340
+ }
18341
+ return {
18342
+ format: "json",
18343
+ filePath: path.join(projectRoot, NEW_CONFIG_FILENAME),
18344
+ directory: projectRoot,
18345
+ exists: false,
18346
+ config: {}
18347
+ };
18348
+ };
18349
+ const writeJsonConfig = (filePath, nextConfig) => {
18350
+ const { $schema, ...rest } = nextConfig;
18351
+ writeFileSync(filePath, `${JSON.stringify({
18352
+ $schema: $schema ?? "https://react.doctor/schema/config.json",
18353
+ ...rest
18354
+ }, null, JSON_INDENT_SPACES)}\n`);
18355
+ };
18356
+ const writePackageJsonConfig = (filePath, nextConfig) => {
18357
+ const packageJson = readObjectFile(filePath) ?? {};
18358
+ writeFileSync(filePath, `${JSON.stringify({
18359
+ ...packageJson,
18360
+ [PACKAGE_JSON_CONFIG_KEY]: nextConfig
18361
+ }, null, JSON_INDENT_SPACES)}\n`);
18362
+ };
18363
+ const syncManagedKeys = (target, nextConfig) => {
18364
+ for (const key of MANAGED_KEYS) {
18365
+ const value = nextConfig[key];
18366
+ if (value === void 0) {
18367
+ if (target[key] !== void 0) delete target[key];
18368
+ } else target[key] = value;
18369
+ }
18370
+ };
18371
+ const assignNodeSource = (owner, key, code) => {
18372
+ owner[key] = code;
18373
+ };
18374
+ const editVariableDeclarationConfig = (declaration, config, nextConfig) => {
18375
+ syncManagedKeys(config, nextConfig);
18376
+ const initializer = declaration.init;
18377
+ if (!initializer) return false;
18378
+ const generatedSource = generateCode(config).code;
18379
+ if (initializer.type === "ObjectExpression") {
18380
+ assignNodeSource(declaration, "init", generatedSource);
18381
+ return true;
18382
+ }
18383
+ if (initializer.type === "TSSatisfiesExpression" && initializer.expression.type === "ObjectExpression") {
18384
+ assignNodeSource(initializer, "expression", generatedSource);
18385
+ return true;
18386
+ }
18387
+ return false;
18388
+ };
18389
+ const writeModuleConfig = async (filePath, nextConfig) => {
18390
+ try {
18391
+ const module = await loadFile(filePath);
18392
+ if (module.exports.default?.$type === "identifier") {
18393
+ const { declaration, config } = getConfigFromVariableDeclaration(module);
18394
+ if (!config || !editVariableDeclarationConfig(declaration, config, nextConfig)) return false;
18395
+ } else syncManagedKeys(getDefaultExportOptions(module), nextConfig);
18396
+ await writeFile(module, filePath);
18397
+ return true;
18398
+ } catch {
18399
+ return false;
18400
+ }
18401
+ };
18402
+ const writeRuleConfig = async (target, nextConfig) => {
18403
+ if (target.format === "module") {
18404
+ const written = await writeModuleConfig(target.filePath, nextConfig);
18405
+ if (written) clearConfigCache();
18406
+ return { written };
18407
+ }
18408
+ if (target.format === "package-json") writePackageJsonConfig(target.filePath, nextConfig);
18409
+ else writeJsonConfig(target.filePath, nextConfig);
18410
+ clearConfigCache();
18411
+ return { written: true };
18412
+ };
18413
+ //#endregion
18414
+ //#region src/cli/utils/resolve-effective-rule-severity.ts
18415
+ /**
18416
+ * Resolves what a rule will actually do under the current config without
18417
+ * running a scan. `ignore.tags` is a pre-lint gate: a rule carrying an
18418
+ * ignored tag is dropped (via `shouldEnableRule`) before any severity is
18419
+ * read, so it wins over every override. Among rules that survive the gate,
18420
+ * the scanner's order is `rules` > `categories` > `buckets` > the registry
18421
+ * default.
18422
+ */
18423
+ const resolveEffectiveRuleSeverity = (config, entry) => {
18424
+ const ignoredTags = config?.ignore?.tags ?? [];
18425
+ if (entry.tags.some((tag) => ignoredTags.includes(tag))) return {
18426
+ value: "off",
18427
+ source: "tag"
18428
+ };
18429
+ const ruleOverrides = config?.rules ?? {};
18430
+ for (const equivalentKey of getEquivalentRuleKeys(entry.key)) {
18431
+ const override = ruleOverrides[equivalentKey];
18432
+ if (override !== void 0) return {
18433
+ value: override,
18434
+ source: "rule"
18435
+ };
18436
+ }
18437
+ const categoryOverride = config?.categories?.[entry.category];
18438
+ if (categoryOverride !== void 0) return {
18439
+ value: categoryOverride,
18440
+ source: "category"
18441
+ };
18442
+ if (COMPILER_CLEANUP_RULE_KEYS.has(entry.key)) {
18443
+ const bucketOverride = config?.buckets?.[COMPILER_CLEANUP_BUCKET];
18444
+ if (bucketOverride !== void 0) return {
18445
+ value: bucketOverride,
18446
+ source: "bucket"
18447
+ };
18448
+ }
18449
+ return {
18450
+ value: entry.defaultEnabled ? entry.defaultSeverity : "off",
18451
+ source: "default"
18452
+ };
18453
+ };
18454
+ //#endregion
18455
+ //#region src/cli/utils/update-rule-config.ts
18456
+ /**
18457
+ * Sets a per-rule severity, replacing any existing entry for the same
18458
+ * rule (including legacy-aliased keys, so a config still targeting
18459
+ * `react/no-danger` is rewritten to the canonical key instead of
18460
+ * leaving a dead duplicate).
18461
+ */
18462
+ const setRuleSeverity = (config, ruleKey, severity) => {
18463
+ const equivalentKeys = new Set(getEquivalentRuleKeys(ruleKey));
18464
+ const nextRules = {};
18465
+ for (const [existingKey, existingSeverity] of Object.entries(config.rules ?? {})) if (!equivalentKeys.has(existingKey)) nextRules[existingKey] = existingSeverity;
18466
+ nextRules[ruleKey] = severity;
18467
+ return {
18468
+ ...config,
18469
+ rules: nextRules
18470
+ };
18471
+ };
18472
+ const setCategorySeverity = (config, category, severity) => ({
18473
+ ...config,
18474
+ categories: {
18475
+ ...config.categories,
18476
+ [category]: severity
18477
+ }
18478
+ });
18479
+ const addIgnoredTag = (config, tag) => {
18480
+ const currentTags = config.ignore?.tags ?? [];
18481
+ if (currentTags.includes(tag)) return config;
18482
+ return {
18483
+ ...config,
18484
+ ignore: {
18485
+ ...config.ignore,
18486
+ tags: [...new Set([...currentTags, tag])].sort()
18487
+ }
18488
+ };
18489
+ };
18490
+ const removeIgnoredTag = (config, tag) => {
18491
+ const currentTags = config.ignore?.tags ?? [];
18492
+ if (!currentTags.includes(tag)) return config;
18493
+ const remainingTags = currentTags.filter((existingTag) => existingTag !== tag);
18494
+ const { tags: _removed, ...remainingIgnore } = config.ignore ?? {};
18495
+ if (remainingTags.length === 0) {
18496
+ if (Object.keys(remainingIgnore).length === 0) {
18497
+ const { ignore: _ignore, ...configWithoutIgnore } = config;
18498
+ return configWithoutIgnore;
18499
+ }
18500
+ return {
18501
+ ...config,
18502
+ ignore: remainingIgnore
18503
+ };
18504
+ }
18505
+ return {
18506
+ ...config,
18507
+ ignore: {
18508
+ ...remainingIgnore,
18509
+ tags: remainingTags
18510
+ }
18511
+ };
18512
+ };
18513
+ //#endregion
18514
+ //#region src/cli/commands/rules.ts
18515
+ const SEVERITY_VALUES = [
18516
+ "off",
18517
+ "warn",
18518
+ "error"
18519
+ ];
18520
+ const resolveProjectRoot = (options) => {
18521
+ const requestedDirectory = path.resolve(options.cwd ?? process.cwd());
18522
+ return findNearestPackageDirectory(requestedDirectory) ?? requestedDirectory;
18523
+ };
18524
+ const parseSeverity = (value) => SEVERITY_VALUES.includes(value) ? value : null;
18525
+ const reportInvalidSeverity = (value) => {
18526
+ cliLogger.error(`Invalid severity "${value}". Expected one of: ${SEVERITY_VALUES.join(", ")}.`);
18527
+ process.exitCode = 1;
18528
+ };
18529
+ const reportRuleNotFound = (ruleQuery) => {
18530
+ cliLogger.error(`Unknown rule "${ruleQuery}".`);
18531
+ cliLogger.dim(" Run `react-doctor rules list` to see every available rule.");
18532
+ process.exitCode = 1;
18533
+ };
18534
+ const describeTargetPath = (target) => {
18535
+ const relativePath = path.relative(process.cwd(), target.filePath);
18536
+ const displayPath = relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : target.filePath;
18537
+ return target.exists ? displayPath : `${displayPath} ${highlighter.dim("(created)")}`;
18538
+ };
18539
+ const applyConfigChange = async (options, change) => {
18540
+ const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
18541
+ const nextConfig = change(target.config);
18542
+ const { written } = await writeRuleConfig(target, nextConfig);
18543
+ return {
18544
+ target,
18545
+ nextConfig,
18546
+ written
18547
+ };
18548
+ };
18549
+ const reportManualEdit = (target, nextConfig) => {
18550
+ const managed = {};
18551
+ for (const key of [
18552
+ "rules",
18553
+ "categories",
18554
+ "ignore"
18555
+ ]) if (nextConfig[key] !== void 0) managed[key] = nextConfig[key];
18556
+ cliLogger.error(`Couldn't automatically edit ${describeTargetPath(target)} (dynamic config).`);
18557
+ cliLogger.dim(" Apply this to your config's default export, then re-run:");
18558
+ for (const line of JSON.stringify(managed, null, 2).split("\n")) cliLogger.dim(` ${line}`);
18559
+ process.exitCode = 1;
18560
+ };
18561
+ const rulesListAction = async (options) => {
18562
+ const catalog = buildRuleCatalog();
18563
+ const config = validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config);
18564
+ const categoryFilter = options.category?.toLowerCase();
18565
+ const frameworkFilter = options.framework?.toLowerCase();
18566
+ const rows = catalog.filter((entry) => {
18567
+ if (categoryFilter && entry.category.toLowerCase() !== categoryFilter) return false;
18568
+ if (frameworkFilter && entry.framework.toLowerCase() !== frameworkFilter) return false;
18569
+ if (options.tag && !entry.tags.includes(options.tag)) return false;
18570
+ return true;
18571
+ }).map((entry) => ({
18572
+ entry,
18573
+ effective: resolveEffectiveRuleSeverity(config, entry)
18574
+ })).filter((row) => options.configured ? row.effective.source !== "default" : true);
18575
+ if (options.json) {
18576
+ const payload = rows.map((row) => ({
18577
+ key: row.entry.key,
18578
+ id: row.entry.id,
18579
+ category: row.entry.category,
18580
+ framework: row.entry.framework,
18581
+ tags: row.entry.tags,
18582
+ defaultSeverity: row.entry.defaultSeverity,
18583
+ defaultEnabled: row.entry.defaultEnabled,
18584
+ severity: row.effective.value,
18585
+ source: row.effective.source
18586
+ }));
18587
+ cliLogger.log(JSON.stringify(payload, null, 2));
18588
+ return;
18589
+ }
18590
+ cliLogger.log(renderRuleCatalog(rows));
18591
+ };
18592
+ const rulesExplainAction = async (ruleQuery, options) => {
18593
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
18594
+ if (!entry) {
18595
+ reportRuleNotFound(ruleQuery);
18596
+ return;
18597
+ }
18598
+ const effective = resolveEffectiveRuleSeverity(validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config), entry);
18599
+ if (options.json) {
18600
+ cliLogger.log(JSON.stringify({
18601
+ key: entry.key,
18602
+ id: entry.id,
18603
+ category: entry.category,
18604
+ framework: entry.framework,
18605
+ tags: entry.tags,
18606
+ defaultSeverity: entry.defaultSeverity,
18607
+ defaultEnabled: entry.defaultEnabled,
18608
+ severity: effective.value,
18609
+ source: effective.source,
18610
+ recommendation: entry.recommendation ?? null,
18611
+ learnMoreUrl: buildRuleDocsUrl("react-doctor", entry.id)
18612
+ }, null, 2));
18613
+ return;
18614
+ }
18615
+ cliLogger.log(renderRuleExplanation({
18616
+ entry,
18617
+ effective
18618
+ }));
18619
+ };
18620
+ const setRuleSeverityAndReport = async (entry, severity, options) => {
18621
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => setRuleSeverity(config, entry.key, severity));
18622
+ if (!written) {
18623
+ reportManualEdit(target, nextConfig);
18624
+ return;
18625
+ }
18626
+ cliLogger.success(`Set ${entry.key} → ${severity}`);
18627
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
18628
+ };
18629
+ const rulesSetAction = async (ruleQuery, severityValue, options) => {
18630
+ const severity = parseSeverity(severityValue);
18631
+ if (!severity) {
18632
+ reportInvalidSeverity(severityValue);
18633
+ return;
18634
+ }
18635
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
18636
+ if (!entry) {
18637
+ reportRuleNotFound(ruleQuery);
18638
+ return;
18639
+ }
18640
+ await setRuleSeverityAndReport(entry, severity, options);
18641
+ };
18642
+ const rulesEnableAction = async (ruleQuery, options) => {
18643
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
18644
+ if (!entry) {
18645
+ reportRuleNotFound(ruleQuery);
18646
+ return;
18647
+ }
18648
+ if (options.severity === void 0) {
18649
+ await setRuleSeverityAndReport(entry, entry.defaultSeverity, options);
18650
+ return;
18651
+ }
18652
+ const severity = parseSeverity(options.severity);
18653
+ if (!severity) {
18654
+ reportInvalidSeverity(options.severity);
18655
+ return;
18656
+ }
18657
+ if (severity === "off") {
18658
+ cliLogger.error("`enable` cannot set a rule to off. Use `react-doctor rules disable` instead.");
18659
+ process.exitCode = 1;
18660
+ return;
18661
+ }
18662
+ await setRuleSeverityAndReport(entry, severity, options);
18663
+ };
18664
+ const rulesDisableAction = async (ruleQuery, options) => {
18665
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
18666
+ if (!entry) {
18667
+ reportRuleNotFound(ruleQuery);
18668
+ return;
18669
+ }
18670
+ await setRuleSeverityAndReport(entry, "off", options);
18671
+ };
18672
+ const rulesCategoryAction = async (categoryQuery, severityValue, options) => {
18673
+ const severity = parseSeverity(severityValue);
18674
+ if (!severity) {
18675
+ reportInvalidSeverity(severityValue);
18676
+ return;
18677
+ }
18678
+ const knownCategories = listRuleCategories(buildRuleCatalog());
18679
+ const matchedCategory = knownCategories.find((category) => category.toLowerCase() === categoryQuery.toLowerCase());
18680
+ if (!matchedCategory) {
18681
+ cliLogger.error(`Unknown category "${categoryQuery}".`);
18682
+ cliLogger.dim(` Known categories: ${knownCategories.join(", ")}`);
18683
+ process.exitCode = 1;
18684
+ return;
18685
+ }
18686
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => setCategorySeverity(config, matchedCategory, severity));
18687
+ if (!written) {
18688
+ reportManualEdit(target, nextConfig);
18689
+ return;
18690
+ }
18691
+ cliLogger.success(`Set category "${matchedCategory}" → ${severity}`);
18692
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
18693
+ };
18694
+ const rulesIgnoreTagAction = async (tag, options) => {
18695
+ const knownTags = listRuleTags(buildRuleCatalog());
18696
+ if (!knownTags.includes(tag)) {
18697
+ cliLogger.error(`Unknown tag "${tag}".`);
18698
+ cliLogger.dim(` Known tags: ${knownTags.join(", ")}`);
18699
+ process.exitCode = 1;
18700
+ return;
18701
+ }
18702
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => addIgnoredTag(config, tag));
18703
+ if (!written) {
18704
+ reportManualEdit(target, nextConfig);
18705
+ return;
18706
+ }
18707
+ cliLogger.success(`Ignoring tag "${tag}" (rules with this tag are skipped before linting)`);
18708
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
18709
+ };
18710
+ const rulesUnignoreTagAction = async (tag, options) => {
18711
+ const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
18712
+ if (!(target.config.ignore?.tags ?? []).includes(tag)) {
18713
+ cliLogger.dim(`Tag "${tag}" was not being ignored; nothing to change.`);
18714
+ return;
18715
+ }
18716
+ const nextConfig = removeIgnoredTag(target.config, tag);
18717
+ const { written } = await writeRuleConfig(target, nextConfig);
18718
+ if (!written) {
18719
+ reportManualEdit(target, nextConfig);
18720
+ return;
18721
+ }
18722
+ cliLogger.success(`Tag "${tag}" is no longer ignored`);
18723
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
18724
+ };
18725
+ //#endregion
18030
18726
  //#region src/cli/commands/version.ts
18031
18727
  /**
18032
18728
  * oclif-style version line. 12-factor CLI Apps (#3, "What version am I
@@ -18186,6 +18882,25 @@ const COMMAND_FLAG_SPECS = new Map([
18186
18882
  longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
18187
18883
  shortOptionsWithoutValues: new Set(["-h"]),
18188
18884
  shortOptionsWithRequiredValues: /* @__PURE__ */ new Set()
18885
+ }],
18886
+ ["rules", {
18887
+ longOptionsWithoutValues: new Set([
18888
+ "--color",
18889
+ "--configured",
18890
+ "--help",
18891
+ "--json",
18892
+ "--no-color"
18893
+ ]),
18894
+ longOptionsWithRequiredValues: new Set([
18895
+ "--category",
18896
+ "--cwd",
18897
+ "--framework",
18898
+ "--severity",
18899
+ "--tag"
18900
+ ]),
18901
+ longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
18902
+ shortOptionsWithoutValues: new Set(["-h"]),
18903
+ shortOptionsWithRequiredValues: new Set(["-c"])
18189
18904
  }]
18190
18905
  ]);
18191
18906
  const isFlagLike = (argument) => argument.startsWith("-") && argument !== "-";
@@ -18278,8 +18993,8 @@ ${formatExampleLines([
18278
18993
  ])}
18279
18994
 
18280
18995
  ${highlighter.dim("Configuration:")}
18281
- Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
18282
- CLI flags always override config values. See the README for the full schema.
18996
+ Add a ${highlighter.info("doctor.config.ts")} (or .js/.mjs/.json — or a ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
18997
+ Use ${highlighter.info("react-doctor rules")} to list, explain, and configure rules. CLI flags always override config values.
18283
18998
 
18284
18999
  ${highlighter.dim("Feedback & bug reports:")}
18285
19000
  ${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
@@ -18303,6 +19018,15 @@ const program = new Command().name("react-doctor").description("Diagnose React c
18303
19018
  program.action(inspectAction);
18304
19019
  program.command("install").alias("setup").description("Install the react-doctor skill into your coding agents and optional git hook").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor").option("-c, --cwd <cwd>", "working directory", process.cwd()).option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderInstallHelpEpilog).action(installAction);
18305
19020
  program.command("version").description("show the version with Node and platform info").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").action(versionAction);
19021
+ const rules = program.command("rules").description("List, explain, and configure which React Doctor rules run");
19022
+ rules.command("list").description("List rules and the severity they run at under your config").option("--category <name>", "only show rules in a category (e.g. Performance)").option("--tag <name>", "only show rules with a tag (e.g. design, test-noise)").option("--framework <name>", "only show rules for a framework (e.g. global, nextjs)").option("--configured", "only show rules your config has changed from the default").option("--json", "output a structured JSON array").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((_options, command) => rulesListAction(command.optsWithGlobals()));
19023
+ rules.command("explain <rule>").description("Explain why a rule matters, its current severity, and how to configure it").option("--json", "output a structured JSON object").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesExplainAction(rule, command.optsWithGlobals()));
19024
+ rules.command("set <rule> <severity>").description("Set a rule's severity: off, warn, or error").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, severity, _options, command) => rulesSetAction(rule, severity, command.optsWithGlobals()));
19025
+ rules.command("enable <rule>").description("Enable a rule at its recommended severity (or pass --severity)").option("--severity <level>", "severity to enable at: warn or error").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesEnableAction(rule, command.optsWithGlobals()));
19026
+ rules.command("disable <rule>").description("Disable a rule so it never runs").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesDisableAction(rule, command.optsWithGlobals()));
19027
+ rules.command("category <category> <severity>").description("Set the severity for a whole category (off, warn, error)").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((category, severity, _options, command) => rulesCategoryAction(category, severity, command.optsWithGlobals()));
19028
+ rules.command("ignore-tag <tag>").description("Skip a whole rule family by tag before linting (e.g. design)").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((tag, _options, command) => rulesIgnoreTagAction(tag, command.optsWithGlobals()));
19029
+ rules.command("unignore-tag <tag>").description("Stop ignoring a tag previously skipped via ignore-tag").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((tag, _options, command) => rulesUnignoreTagAction(tag, command.optsWithGlobals()));
18306
19030
  process.stdout.on("error", (error) => {
18307
19031
  if (error.code === "EPIPE") process.exit(0);
18308
19032
  });