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/README.md +27 -2
- package/dist/cli.js +786 -62
- package/dist/index.d.ts +24 -2
- package/dist/index.js +92 -55
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +7 -4
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
|
-
"
|
|
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
|
|
7505
|
-
const
|
|
7506
|
-
|
|
7507
|
-
|
|
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
|
-
|
|
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
|
|
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(`${
|
|
7576
|
+
warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
|
|
7577
|
+
sawBrokenConfigFile = true;
|
|
7521
7578
|
} catch (error) {
|
|
7522
|
-
warn(`Failed to
|
|
7579
|
+
warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
|
|
7580
|
+
sawBrokenConfigFile = true;
|
|
7523
7581
|
}
|
|
7524
|
-
sawBrokenConfigFile = true;
|
|
7525
7582
|
}
|
|
7526
|
-
const
|
|
7527
|
-
if (
|
|
7528
|
-
|
|
7529
|
-
|
|
7530
|
-
|
|
7531
|
-
|
|
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
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
|
|
7551
|
-
|
|
7552
|
-
|
|
7553
|
-
|
|
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
|
-
|
|
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 `
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
18282
|
-
|
|
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
|
});
|