react-doctor 0.2.14-dev.bb15252 → 0.2.14-dev.bdb9e36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -2
- package/dist/cli.js +2203 -458
- package/dist/index.d.ts +31 -9
- package/dist/index.js +265 -69
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +8 -4
package/dist/cli.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="d012dbb2-27e9-5092-9eab-c7fa4573f0b2")}catch(e){}}();
|
|
1
3
|
import { createRequire } from "node:module";
|
|
2
4
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
3
5
|
import * as Path from "node:path";
|
|
@@ -17,6 +19,8 @@ import * as Otlp from "effect/unstable/observability/Otlp";
|
|
|
17
19
|
import * as Context from "effect/Context";
|
|
18
20
|
import os, { tmpdir } from "node:os";
|
|
19
21
|
import * as Console from "effect/Console";
|
|
22
|
+
import { parseJSON5 } from "confbox";
|
|
23
|
+
import { createJiti } from "jiti";
|
|
20
24
|
import * as Fiber from "effect/Fiber";
|
|
21
25
|
import * as Filter from "effect/Filter";
|
|
22
26
|
import * as Option from "effect/Option";
|
|
@@ -30,7 +34,10 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
|
30
34
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
31
35
|
import * as ts from "typescript";
|
|
32
36
|
import { gzipSync } from "node:zlib";
|
|
37
|
+
import * as Sentry from "@sentry/node";
|
|
33
38
|
import { performance } from "node:perf_hooks";
|
|
39
|
+
import * as Tracer from "effect/Tracer";
|
|
40
|
+
import * as Exit from "effect/Exit";
|
|
34
41
|
import { stripVTControlCharacters } from "node:util";
|
|
35
42
|
import tty from "node:tty";
|
|
36
43
|
import { codeFrameColumns } from "@babel/code-frame";
|
|
@@ -39,6 +46,8 @@ import basePrompts from "prompts";
|
|
|
39
46
|
import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
|
|
40
47
|
import { fileURLToPath } from "node:url";
|
|
41
48
|
import Conf from "conf";
|
|
49
|
+
import { generateCode, loadFile, writeFile } from "magicast";
|
|
50
|
+
import { getConfigFromVariableDeclaration, getDefaultExportOptions } from "magicast/helpers";
|
|
42
51
|
//#region \0rolldown/runtime.js
|
|
43
52
|
var __create$1 = Object.create;
|
|
44
53
|
var __defProp$1 = Object.defineProperty;
|
|
@@ -6290,7 +6299,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
|
|
|
6290
6299
|
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
6291
6300
|
const ENTERPRISE_CONTACT_URL = "https://react.doctor/enterprise";
|
|
6292
6301
|
const SHARE_BASE_URL = "https://react.doctor/share";
|
|
6293
|
-
const
|
|
6302
|
+
const DOCS_URL = "https://www.react.doctor/docs";
|
|
6303
|
+
const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
|
|
6294
6304
|
const FETCH_TIMEOUT_MS = 1e4;
|
|
6295
6305
|
const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
|
|
6296
6306
|
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
@@ -6308,11 +6318,19 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
6308
6318
|
"tsconfig.json",
|
|
6309
6319
|
"tsconfig.base.json",
|
|
6310
6320
|
"package.json",
|
|
6311
|
-
"
|
|
6321
|
+
"doctor.config.ts",
|
|
6322
|
+
"doctor.config.mts",
|
|
6323
|
+
"doctor.config.cts",
|
|
6324
|
+
"doctor.config.js",
|
|
6325
|
+
"doctor.config.mjs",
|
|
6326
|
+
"doctor.config.cjs",
|
|
6327
|
+
"doctor.config.json",
|
|
6328
|
+
"doctor.config.jsonc",
|
|
6312
6329
|
"oxlint.json",
|
|
6313
6330
|
".oxlintrc.json"
|
|
6314
6331
|
];
|
|
6315
6332
|
const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
|
|
6333
|
+
const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
|
|
6316
6334
|
const SKILL_NAME = "react-doctor";
|
|
6317
6335
|
const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
6318
6336
|
const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
|
|
@@ -7498,66 +7516,135 @@ const validateConfigTypes = (config) => {
|
|
|
7498
7516
|
const warn = (message) => {
|
|
7499
7517
|
Effect.runSync(Console.warn(message));
|
|
7500
7518
|
};
|
|
7501
|
-
const
|
|
7502
|
-
const
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
|
|
7518
|
-
|
|
7519
|
-
|
|
7519
|
+
const CONFIG_BASENAME = "doctor.config";
|
|
7520
|
+
const CONFIG_EXTENSIONS = [
|
|
7521
|
+
"ts",
|
|
7522
|
+
"mts",
|
|
7523
|
+
"cts",
|
|
7524
|
+
"js",
|
|
7525
|
+
"mjs",
|
|
7526
|
+
"cjs",
|
|
7527
|
+
"json",
|
|
7528
|
+
"jsonc"
|
|
7529
|
+
];
|
|
7530
|
+
const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
|
|
7531
|
+
const PACKAGE_JSON_FILENAME = "package.json";
|
|
7532
|
+
const PACKAGE_JSON_CONFIG_KEY$1 = "reactDoctor";
|
|
7533
|
+
const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
|
|
7534
|
+
const jiti = createJiti(import.meta.url);
|
|
7535
|
+
const formatError = (error) => error instanceof Error ? error.message : String(error);
|
|
7536
|
+
const loadModuleConfig = async (filePath) => {
|
|
7537
|
+
const imported = await jiti.import(filePath);
|
|
7538
|
+
return imported?.default ?? imported;
|
|
7539
|
+
};
|
|
7540
|
+
const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
|
|
7541
|
+
const readEmbeddedPackageJsonConfig = (directory) => {
|
|
7542
|
+
const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
|
|
7543
|
+
if (!isFile(packageJsonPath)) return null;
|
|
7544
|
+
try {
|
|
7545
|
+
const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
7520
7546
|
if (isPlainObject(packageJson)) {
|
|
7521
|
-
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
7522
|
-
if (isPlainObject(embeddedConfig)) return
|
|
7523
|
-
|
|
7524
|
-
|
|
7547
|
+
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY$1];
|
|
7548
|
+
if (isPlainObject(embeddedConfig)) return embeddedConfig;
|
|
7549
|
+
}
|
|
7550
|
+
} catch {}
|
|
7551
|
+
return null;
|
|
7552
|
+
};
|
|
7553
|
+
const loadPackageJsonConfig = (directory) => {
|
|
7554
|
+
const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
|
|
7555
|
+
if (!embeddedConfig) return null;
|
|
7556
|
+
return {
|
|
7557
|
+
config: validateConfigTypes(embeddedConfig),
|
|
7558
|
+
sourceDirectory: directory,
|
|
7559
|
+
configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
|
|
7560
|
+
format: "package-json"
|
|
7561
|
+
};
|
|
7562
|
+
};
|
|
7563
|
+
const loadConfigFromDirectory = async (directory) => {
|
|
7564
|
+
let sawBrokenConfigFile = false;
|
|
7565
|
+
for (const extension of CONFIG_EXTENSIONS) {
|
|
7566
|
+
const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
|
|
7567
|
+
if (!isFile(filePath)) continue;
|
|
7568
|
+
const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
|
|
7569
|
+
try {
|
|
7570
|
+
const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
|
|
7571
|
+
if (isPlainObject(parsed)) return {
|
|
7572
|
+
status: "found",
|
|
7573
|
+
loaded: {
|
|
7574
|
+
config: validateConfigTypes(parsed),
|
|
7575
|
+
sourceDirectory: directory,
|
|
7576
|
+
configFilePath: filePath,
|
|
7577
|
+
format: isDataFile ? "json" : "module"
|
|
7578
|
+
}
|
|
7525
7579
|
};
|
|
7580
|
+
warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
|
|
7581
|
+
sawBrokenConfigFile = true;
|
|
7582
|
+
} catch (error) {
|
|
7583
|
+
warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
|
|
7584
|
+
sawBrokenConfigFile = true;
|
|
7526
7585
|
}
|
|
7527
|
-
} catch {
|
|
7528
|
-
return null;
|
|
7529
7586
|
}
|
|
7530
|
-
|
|
7587
|
+
const packageJsonConfig = loadPackageJsonConfig(directory);
|
|
7588
|
+
if (packageJsonConfig) return {
|
|
7589
|
+
status: "found",
|
|
7590
|
+
loaded: packageJsonConfig
|
|
7591
|
+
};
|
|
7592
|
+
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).`);
|
|
7593
|
+
return {
|
|
7594
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
7595
|
+
loaded: null
|
|
7596
|
+
};
|
|
7531
7597
|
};
|
|
7532
7598
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
7533
|
-
const
|
|
7534
|
-
|
|
7535
|
-
|
|
7536
|
-
|
|
7537
|
-
|
|
7538
|
-
|
|
7539
|
-
|
|
7540
|
-
}
|
|
7541
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
7542
|
-
cachedConfigs.set(rootDirectory, null);
|
|
7543
|
-
return null;
|
|
7544
|
-
}
|
|
7599
|
+
const clearConfigCache = () => {
|
|
7600
|
+
cachedConfigs.clear();
|
|
7601
|
+
};
|
|
7602
|
+
const loadConfigWalkingUp = async (rootDirectory) => {
|
|
7603
|
+
const localResult = await loadConfigFromDirectory(rootDirectory);
|
|
7604
|
+
if (localResult.status === "found") return localResult.loaded;
|
|
7605
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
|
|
7545
7606
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
7546
7607
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
7547
|
-
const
|
|
7548
|
-
if (
|
|
7549
|
-
|
|
7550
|
-
return ancestorConfig;
|
|
7551
|
-
}
|
|
7552
|
-
if (isProjectBoundary(ancestorDirectory)) {
|
|
7553
|
-
cachedConfigs.set(rootDirectory, null);
|
|
7554
|
-
return null;
|
|
7555
|
-
}
|
|
7608
|
+
const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
|
|
7609
|
+
if (ancestorResult.status === "found") return ancestorResult.loaded;
|
|
7610
|
+
if (isProjectBoundary(ancestorDirectory)) return null;
|
|
7556
7611
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
7557
7612
|
}
|
|
7558
|
-
cachedConfigs.set(rootDirectory, null);
|
|
7559
7613
|
return null;
|
|
7560
7614
|
};
|
|
7615
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
7616
|
+
const cached = cachedConfigs.get(rootDirectory);
|
|
7617
|
+
if (cached !== void 0) return cached;
|
|
7618
|
+
const loadPromise = loadConfigWalkingUp(rootDirectory);
|
|
7619
|
+
cachedConfigs.set(rootDirectory, loadPromise);
|
|
7620
|
+
return loadPromise;
|
|
7621
|
+
};
|
|
7622
|
+
const directoryHasCurrentConfig = (directory) => {
|
|
7623
|
+
for (const extension of CONFIG_EXTENSIONS) if (isFile(path.join(directory, `${CONFIG_BASENAME}.${extension}`))) return true;
|
|
7624
|
+
return readEmbeddedPackageJsonConfig(directory) !== null;
|
|
7625
|
+
};
|
|
7626
|
+
/**
|
|
7627
|
+
* Walks up from `rootDirectory` (same boundary semantics as
|
|
7628
|
+
* `loadConfigWithSource`) looking for a pre-migration
|
|
7629
|
+
* `react-doctor.config.json` that is no longer read. Returns the first one
|
|
7630
|
+
* found, or `null` when a current-format config supersedes it or none exists
|
|
7631
|
+
* before a project boundary. Detection only — the CLI performs the rename.
|
|
7632
|
+
*/
|
|
7633
|
+
const findLegacyConfig = (rootDirectory) => {
|
|
7634
|
+
let directory = rootDirectory;
|
|
7635
|
+
while (true) {
|
|
7636
|
+
if (directoryHasCurrentConfig(directory)) return null;
|
|
7637
|
+
const legacyFilePath = path.join(directory, LEGACY_CONFIG_FILENAME);
|
|
7638
|
+
if (isFile(legacyFilePath)) return {
|
|
7639
|
+
legacyFilePath,
|
|
7640
|
+
directory
|
|
7641
|
+
};
|
|
7642
|
+
if (isProjectBoundary(directory)) return null;
|
|
7643
|
+
const parentDirectory = path.dirname(directory);
|
|
7644
|
+
if (parentDirectory === directory) return null;
|
|
7645
|
+
directory = parentDirectory;
|
|
7646
|
+
}
|
|
7647
|
+
};
|
|
7561
7648
|
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
7562
7649
|
if (!config || !configSourceDirectory) return null;
|
|
7563
7650
|
const rawRootDir = config.rootDir;
|
|
@@ -7585,8 +7672,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
|
7585
7672
|
* (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
|
|
7586
7673
|
*
|
|
7587
7674
|
* 1. Resolve the requested directory to absolute.
|
|
7588
|
-
* 2. Load `
|
|
7589
|
-
* if present.
|
|
7675
|
+
* 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
|
|
7590
7676
|
* 3. Honor `config.rootDir` to redirect the scan to a nested
|
|
7591
7677
|
* project root, if configured.
|
|
7592
7678
|
* 4. Walk into a nested React subproject when the requested
|
|
@@ -7604,9 +7690,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
|
7604
7690
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
7605
7691
|
* shell in agreement on what "the scan directory" means.
|
|
7606
7692
|
*/
|
|
7607
|
-
const resolveScanTarget = (requestedDirectory, options = {}) => {
|
|
7693
|
+
const resolveScanTarget = async (requestedDirectory, options = {}) => {
|
|
7608
7694
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
7609
|
-
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
7695
|
+
const loadedConfig = await loadConfigWithSource(absoluteRequested);
|
|
7610
7696
|
const userConfig = loadedConfig?.config ?? null;
|
|
7611
7697
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
7612
7698
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
@@ -7930,6 +8016,61 @@ const checkExpoPackageJsonConflicts = (context) => {
|
|
|
7930
8016
|
}));
|
|
7931
8017
|
return diagnostics;
|
|
7932
8018
|
};
|
|
8019
|
+
const APP_CONFIG_JSON_FILES = ["app.config.json", "app.json"];
|
|
8020
|
+
const APP_CONFIG_DYNAMIC_FILES = [
|
|
8021
|
+
"app.config.ts",
|
|
8022
|
+
"app.config.js",
|
|
8023
|
+
"app.config.cjs",
|
|
8024
|
+
"app.config.mjs"
|
|
8025
|
+
];
|
|
8026
|
+
const ExpoConfigSchema = Schema.Struct({
|
|
8027
|
+
newArchEnabled: Schema.optional(Schema.Boolean),
|
|
8028
|
+
updates: Schema.optional(Schema.Struct({ disableAntiBrickingMeasures: Schema.optional(Schema.Boolean) }))
|
|
8029
|
+
});
|
|
8030
|
+
const AppManifestSchema = Schema.Struct({ expo: Schema.optional(ExpoConfigSchema) });
|
|
8031
|
+
const NO_CONFIG = {
|
|
8032
|
+
config: null,
|
|
8033
|
+
configFile: null
|
|
8034
|
+
};
|
|
8035
|
+
const decodeExpoConfig = (filePath) => {
|
|
8036
|
+
let raw;
|
|
8037
|
+
try {
|
|
8038
|
+
raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
8039
|
+
} catch {
|
|
8040
|
+
return null;
|
|
8041
|
+
}
|
|
8042
|
+
return Option.getOrNull(Schema.decodeUnknownOption(AppManifestSchema)(raw))?.expo ?? null;
|
|
8043
|
+
};
|
|
8044
|
+
const readExpoAppConfig = (rootDirectory) => {
|
|
8045
|
+
if (APP_CONFIG_DYNAMIC_FILES.some((fileName) => isFile(path.join(rootDirectory, fileName)))) return NO_CONFIG;
|
|
8046
|
+
for (const fileName of APP_CONFIG_JSON_FILES) {
|
|
8047
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
8048
|
+
if (!isFile(filePath)) continue;
|
|
8049
|
+
const config = decodeExpoConfig(filePath);
|
|
8050
|
+
if (config) return {
|
|
8051
|
+
config,
|
|
8052
|
+
configFile: fileName
|
|
8053
|
+
};
|
|
8054
|
+
}
|
|
8055
|
+
return NO_CONFIG;
|
|
8056
|
+
};
|
|
8057
|
+
const REANIMATED_PACKAGE = "react-native-reanimated";
|
|
8058
|
+
const WORKLETS_PACKAGE = "react-native-worklets";
|
|
8059
|
+
const FIRST_NEW_ARCH_ONLY_REANIMATED_MAJOR = 4;
|
|
8060
|
+
const checkExpoReanimatedNewArch = (context) => {
|
|
8061
|
+
const reanimatedSpec = context.packageJson.dependencies?.[REANIMATED_PACKAGE] ?? context.packageJson.devDependencies?.[REANIMATED_PACKAGE];
|
|
8062
|
+
const reanimatedMajor = reanimatedSpec === void 0 ? null : getLowestDependencyMajor(reanimatedSpec);
|
|
8063
|
+
if (!(reanimatedMajor !== null && reanimatedMajor >= FIRST_NEW_ARCH_ONLY_REANIMATED_MAJOR || context.directDependencyNames.has(WORKLETS_PACKAGE))) return [];
|
|
8064
|
+
const appConfig = readExpoAppConfig(context.rootDirectory);
|
|
8065
|
+
if (appConfig.config?.newArchEnabled !== false) return [];
|
|
8066
|
+
return [buildExpoDiagnostic({
|
|
8067
|
+
rule: "expo-reanimated-v4-requires-new-arch",
|
|
8068
|
+
severity: "error",
|
|
8069
|
+
filePath: appConfig.configFile ?? "app.json",
|
|
8070
|
+
message: "react-native-reanimated v4 supports only the New Architecture, but `newArchEnabled: false` is set in your app config, so the app will crash on first launch.",
|
|
8071
|
+
help: "Remove `newArchEnabled: false` from your app config (the New Architecture is the default on SDK 52+), or pin react-native-reanimated to v3 if you must stay on the legacy architecture."
|
|
8072
|
+
})];
|
|
8073
|
+
};
|
|
7933
8074
|
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
7934
8075
|
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
7935
8076
|
const checkExpoRouterReactNavigation = (context) => {
|
|
@@ -7945,6 +8086,17 @@ const checkExpoRouterReactNavigation = (context) => {
|
|
|
7945
8086
|
help: "Remove these `@react-navigation/*` packages and replace direct imports with their expo-router equivalents. See https://docs.expo.dev/router/migrate/sdk-55-to-56/"
|
|
7946
8087
|
})];
|
|
7947
8088
|
};
|
|
8089
|
+
const checkExpoUpdatesConfig = (context) => {
|
|
8090
|
+
const appConfig = readExpoAppConfig(context.rootDirectory);
|
|
8091
|
+
if (appConfig.config?.updates?.disableAntiBrickingMeasures !== true) return [];
|
|
8092
|
+
return [buildExpoDiagnostic({
|
|
8093
|
+
rule: "expo-updates-no-unsafe-production-config",
|
|
8094
|
+
severity: "error",
|
|
8095
|
+
filePath: appConfig.configFile ?? "app.json",
|
|
8096
|
+
message: "`updates.disableAntiBrickingMeasures: true` disables expo-updates' recovery safeguards and is liable to leave installed apps in a permanently bricked state, so it must not be used in production.",
|
|
8097
|
+
help: "Remove `disableAntiBrickingMeasures` from your app config's `updates` block. See https://docs.expo.dev/versions/latest/config/app/#updates"
|
|
8098
|
+
})];
|
|
8099
|
+
};
|
|
7948
8100
|
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
7949
8101
|
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
7950
8102
|
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
@@ -7971,7 +8123,9 @@ const checkExpoProject = (rootDirectory, project) => {
|
|
|
7971
8123
|
...checkExpoLockfile(context),
|
|
7972
8124
|
...checkExpoGitignore(context),
|
|
7973
8125
|
...checkExpoEnvLocalFiles(context),
|
|
7974
|
-
...checkExpoMetroConfig(context)
|
|
8126
|
+
...checkExpoMetroConfig(context),
|
|
8127
|
+
...checkExpoReanimatedNewArch(context),
|
|
8128
|
+
...checkExpoUpdatesConfig(context)
|
|
7975
8129
|
];
|
|
7976
8130
|
};
|
|
7977
8131
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
@@ -8088,6 +8242,69 @@ const checkPnpmHardening = (rootDirectory) => {
|
|
|
8088
8242
|
}));
|
|
8089
8243
|
return diagnostics;
|
|
8090
8244
|
};
|
|
8245
|
+
const BUILDER_BOB_PACKAGE = "react-native-builder-bob";
|
|
8246
|
+
const isBuilderBobLibrary = (packageJson) => {
|
|
8247
|
+
const bobConfig = packageJson[BUILDER_BOB_PACKAGE];
|
|
8248
|
+
return typeof bobConfig === "object" && bobConfig !== null;
|
|
8249
|
+
};
|
|
8250
|
+
const checkReactNativeLibraryDependencies = (rootDirectory) => {
|
|
8251
|
+
const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
|
|
8252
|
+
if (!isBuilderBobLibrary(packageJson)) return [];
|
|
8253
|
+
const misplaced = ["react", "react-native"].filter((name) => packageJson.dependencies?.[name] !== void 0);
|
|
8254
|
+
if (misplaced.length === 0) return [];
|
|
8255
|
+
const quoted = misplaced.map((name) => `"${name}"`).join(" and ");
|
|
8256
|
+
return [{
|
|
8257
|
+
filePath: "package.json",
|
|
8258
|
+
plugin: "react-doctor",
|
|
8259
|
+
rule: "rn-library-react-in-dependencies",
|
|
8260
|
+
severity: "warning",
|
|
8261
|
+
message: `This react-native-builder-bob library lists ${quoted} in \`dependencies\` — that ships a second copy into consumer apps, causing "Invalid hook call" (duplicate React) and duplicate-native-module crashes.`,
|
|
8262
|
+
help: `Move ${quoted} to \`peerDependencies\` (keep ${misplaced.length === 1 ? "it" : "them"} in \`devDependencies\` for local development).`,
|
|
8263
|
+
line: 0,
|
|
8264
|
+
column: 0,
|
|
8265
|
+
category: "Correctness"
|
|
8266
|
+
}];
|
|
8267
|
+
};
|
|
8268
|
+
const BABEL_CONFIG_FILE_NAMES = [
|
|
8269
|
+
"babel.config.js",
|
|
8270
|
+
"babel.config.cjs",
|
|
8271
|
+
"babel.config.mjs",
|
|
8272
|
+
"babel.config.json",
|
|
8273
|
+
".babelrc",
|
|
8274
|
+
".babelrc.js",
|
|
8275
|
+
".babelrc.json"
|
|
8276
|
+
];
|
|
8277
|
+
const LEGACY_PRESET_SPEC = "module:metro-react-native-babel-preset";
|
|
8278
|
+
const checkReactNativeMetroBabelPreset = (rootDirectory) => {
|
|
8279
|
+
for (const fileName of BABEL_CONFIG_FILE_NAMES) {
|
|
8280
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
8281
|
+
if (!isFile(filePath)) continue;
|
|
8282
|
+
let contents;
|
|
8283
|
+
try {
|
|
8284
|
+
contents = fs.readFileSync(filePath, "utf-8");
|
|
8285
|
+
} catch {
|
|
8286
|
+
continue;
|
|
8287
|
+
}
|
|
8288
|
+
if (!contents.includes(LEGACY_PRESET_SPEC)) continue;
|
|
8289
|
+
return [{
|
|
8290
|
+
filePath: fileName,
|
|
8291
|
+
plugin: "react-doctor",
|
|
8292
|
+
rule: "rn-no-metro-babel-preset",
|
|
8293
|
+
severity: "error",
|
|
8294
|
+
message: "`module:metro-react-native-babel-preset` was renamed to `@react-native/babel-preset` and is no longer installed by React Native 0.73+ — this preset reference fails to resolve and breaks the Metro/Babel transform.",
|
|
8295
|
+
help: "Replace the preset with `module:@react-native/babel-preset` (or `babel-preset-expo` on Expo) and remove the old `metro-react-native-babel-preset` dependency.",
|
|
8296
|
+
line: 0,
|
|
8297
|
+
column: 0,
|
|
8298
|
+
category: "Correctness"
|
|
8299
|
+
}];
|
|
8300
|
+
}
|
|
8301
|
+
return [];
|
|
8302
|
+
};
|
|
8303
|
+
const isReactNativeProject = (project) => project.framework === "react-native" || project.framework === "expo" || project.hasReactNativeWorkspace || project.expoVersion !== null;
|
|
8304
|
+
const checkReactNativeProject = (rootDirectory, project) => {
|
|
8305
|
+
if (!isReactNativeProject(project)) return [];
|
|
8306
|
+
return [...checkReactNativeMetroBabelPreset(rootDirectory), ...checkReactNativeLibraryDependencies(rootDirectory)];
|
|
8307
|
+
};
|
|
8091
8308
|
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
|
|
8092
8309
|
const REDUCED_MOTION_FILE_GLOBS = [
|
|
8093
8310
|
"*.ts",
|
|
@@ -8653,8 +8870,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
|
8653
8870
|
const cache = yield* Cache.make({
|
|
8654
8871
|
capacity: 16,
|
|
8655
8872
|
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
8656
|
-
lookup: (directory) => Effect.
|
|
8657
|
-
const loaded = loadConfigWithSource(directory);
|
|
8873
|
+
lookup: (directory) => Effect.promise(async () => {
|
|
8874
|
+
const loaded = await loadConfigWithSource(directory);
|
|
8658
8875
|
const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
|
|
8659
8876
|
return {
|
|
8660
8877
|
config: loaded?.config ?? null,
|
|
@@ -10833,7 +11050,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10833
11050
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
10834
11051
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
10835
11052
|
const isDiffMode = input.includePaths.length > 0;
|
|
10836
|
-
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ??
|
|
11053
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
10837
11054
|
const transform = buildDiagnosticPipeline({
|
|
10838
11055
|
rootDirectory: scanDirectory,
|
|
10839
11056
|
userConfig: resolvedConfig.config,
|
|
@@ -10845,7 +11062,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10845
11062
|
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10846
11063
|
...checkReducedMotion(scanDirectory),
|
|
10847
11064
|
...checkPnpmHardening(scanDirectory),
|
|
10848
|
-
...checkExpoProject(scanDirectory, project)
|
|
11065
|
+
...checkExpoProject(scanDirectory, project),
|
|
11066
|
+
...checkReactNativeProject(scanDirectory, project)
|
|
10849
11067
|
];
|
|
10850
11068
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
10851
11069
|
const lintFailure = yield* Ref.make({
|
|
@@ -10960,7 +11178,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10960
11178
|
"inspect.isCi": input.isCi,
|
|
10961
11179
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
10962
11180
|
} }));
|
|
10963
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
10964
11181
|
const parseNodeVersion = (versionString) => {
|
|
10965
11182
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
10966
11183
|
return {
|
|
@@ -11283,6 +11500,26 @@ const buildJsonReport = (input) => {
|
|
|
11283
11500
|
};
|
|
11284
11501
|
};
|
|
11285
11502
|
/**
|
|
11503
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
11504
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
11505
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
11506
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
11507
|
+
* branch logic lives here once.
|
|
11508
|
+
*/
|
|
11509
|
+
const buildSkippedChecks = (input) => {
|
|
11510
|
+
const skippedChecks = [];
|
|
11511
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
11512
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
11513
|
+
const skippedCheckReasons = {};
|
|
11514
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
11515
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
11516
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
11517
|
+
return {
|
|
11518
|
+
skippedChecks,
|
|
11519
|
+
skippedCheckReasons
|
|
11520
|
+
};
|
|
11521
|
+
};
|
|
11522
|
+
/**
|
|
11286
11523
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
11287
11524
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
11288
11525
|
* spawn, not `spawnSync`).
|
|
@@ -11387,12 +11624,32 @@ const highlighter = {
|
|
|
11387
11624
|
bold: import_picocolors.default.bold
|
|
11388
11625
|
};
|
|
11389
11626
|
/**
|
|
11390
|
-
*
|
|
11391
|
-
* `
|
|
11392
|
-
*
|
|
11393
|
-
*
|
|
11627
|
+
* Override picocolors' automatic color detection. picocolors decides
|
|
11628
|
+
* once, at import time, from `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY.
|
|
11629
|
+
* This lets the CLI honor an explicit `--color` / `--no-color` flag
|
|
11630
|
+
* (clig.dev, Output: "Disable color … if the user requested it") by
|
|
11631
|
+
* swapping in a fresh set of formatters. Call it before any colored
|
|
11632
|
+
* output is produced. Every call site reads `highlighter.<method>` at
|
|
11633
|
+
* call time, so reassigning the properties propagates everywhere.
|
|
11634
|
+
*/
|
|
11635
|
+
const setColorEnabled = (enabled) => {
|
|
11636
|
+
const colors = import_picocolors.default.createColors(enabled);
|
|
11637
|
+
highlighter.error = colors.red;
|
|
11638
|
+
highlighter.warn = colors.yellow;
|
|
11639
|
+
highlighter.info = colors.cyan;
|
|
11640
|
+
highlighter.success = colors.green;
|
|
11641
|
+
highlighter.dim = colors.dim;
|
|
11642
|
+
highlighter.gray = colors.gray;
|
|
11643
|
+
highlighter.bold = colors.bold;
|
|
11644
|
+
};
|
|
11645
|
+
/**
|
|
11646
|
+
* Canonical URL for a rule's documentation page — its reviewer-tested fix
|
|
11647
|
+
* recipe rendered for humans — served at
|
|
11648
|
+
* `https://www.react.doctor/docs/rules/<plugin>/<rule>`. The CLI links here
|
|
11649
|
+
* from its fix-recipe directive so each fix follows the canonical recipe
|
|
11650
|
+
* instead of being improvised per diagnostic.
|
|
11394
11651
|
*/
|
|
11395
|
-
const
|
|
11652
|
+
const buildRuleDocsUrl = (plugin, rule) => `${DOCS_RULES_BASE_URL}/${plugin}/${rule}`;
|
|
11396
11653
|
const groupBy = (items, keyFn) => {
|
|
11397
11654
|
const groups = /* @__PURE__ */ new Map();
|
|
11398
11655
|
for (const item of items) {
|
|
@@ -11405,8 +11662,8 @@ const groupBy = (items, keyFn) => {
|
|
|
11405
11662
|
};
|
|
11406
11663
|
/**
|
|
11407
11664
|
* Whether a diagnostic's rule has a published per-rule fix recipe at
|
|
11408
|
-
* `${
|
|
11409
|
-
* (see `
|
|
11665
|
+
* `${DOCS_RULES_BASE_URL}/react-doctor/<rule>`
|
|
11666
|
+
* (see `buildRuleDocsUrl`).
|
|
11410
11667
|
*
|
|
11411
11668
|
* Recipes are generated from react-doctor's own engine rules, so only
|
|
11412
11669
|
* those resolve. Dead-code (`deslop`), the synthetic environment and
|
|
@@ -11418,151 +11675,796 @@ const groupBy = (items, keyFn) => {
|
|
|
11418
11675
|
*/
|
|
11419
11676
|
const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
|
|
11420
11677
|
//#endregion
|
|
11421
|
-
//#region
|
|
11422
|
-
const
|
|
11423
|
-
|
|
11424
|
-
|
|
11425
|
-
|
|
11426
|
-
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
|
|
11432
|
-
|
|
11433
|
-
|
|
11434
|
-
|
|
11435
|
-
|
|
11436
|
-
|
|
11437
|
-
|
|
11438
|
-
|
|
11439
|
-
|
|
11440
|
-
|
|
11441
|
-
|
|
11442
|
-
|
|
11443
|
-
|
|
11444
|
-
|
|
11445
|
-
|
|
11446
|
-
|
|
11447
|
-
|
|
11448
|
-
|
|
11449
|
-
|
|
11450
|
-
|
|
11451
|
-
|
|
11452
|
-
|
|
11453
|
-
|
|
11454
|
-
|
|
11455
|
-
|
|
11456
|
-
|
|
11457
|
-
|
|
11458
|
-
|
|
11459
|
-
|
|
11460
|
-
|
|
11461
|
-
|
|
11462
|
-
|
|
11463
|
-
|
|
11464
|
-
|
|
11465
|
-
bgCyan: [46, 49],
|
|
11466
|
-
bgWhite: [47, 49],
|
|
11467
|
-
bgBlackBright: [100, 49],
|
|
11468
|
-
bgGray: [100, 49],
|
|
11469
|
-
bgGrey: [100, 49],
|
|
11470
|
-
bgRedBright: [101, 49],
|
|
11471
|
-
bgGreenBright: [102, 49],
|
|
11472
|
-
bgYellowBright: [103, 49],
|
|
11473
|
-
bgBlueBright: [104, 49],
|
|
11474
|
-
bgMagentaBright: [105, 49],
|
|
11475
|
-
bgCyanBright: [106, 49],
|
|
11476
|
-
bgWhiteBright: [107, 49]
|
|
11477
|
-
}
|
|
11678
|
+
//#region src/cli/utils/is-ci-environment.ts
|
|
11679
|
+
const CI_ENVIRONMENT_VARIABLES = [
|
|
11680
|
+
"GITHUB_ACTIONS",
|
|
11681
|
+
"GITLAB_CI",
|
|
11682
|
+
"CIRCLECI"
|
|
11683
|
+
];
|
|
11684
|
+
const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
|
|
11685
|
+
["GITHUB_ACTIONS", "github-actions"],
|
|
11686
|
+
["GITLAB_CI", "gitlab-ci"],
|
|
11687
|
+
["CIRCLECI", "circleci"],
|
|
11688
|
+
["BUILDKITE", "buildkite"],
|
|
11689
|
+
["JENKINS_URL", "jenkins"],
|
|
11690
|
+
["TF_BUILD", "azure-pipelines"],
|
|
11691
|
+
["CODEBUILD_BUILD_ID", "aws-codebuild"],
|
|
11692
|
+
["TEAMCITY_VERSION", "teamcity"],
|
|
11693
|
+
["BITBUCKET_BUILD_NUMBER", "bitbucket"],
|
|
11694
|
+
["TRAVIS", "travis"],
|
|
11695
|
+
["DRONE", "drone"]
|
|
11696
|
+
];
|
|
11697
|
+
const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
|
|
11698
|
+
["CLAUDECODE", "claude-code"],
|
|
11699
|
+
["CLAUDE_CODE", "claude-code"],
|
|
11700
|
+
["CURSOR_AGENT", "cursor"],
|
|
11701
|
+
["CODEX_CI", "codex"],
|
|
11702
|
+
["CODEX_SANDBOX", "codex"],
|
|
11703
|
+
["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
|
|
11704
|
+
["OPENCODE", "opencode"],
|
|
11705
|
+
["GOOSE_TERMINAL", "goose"],
|
|
11706
|
+
["AMP_THREAD_ID", "amp"]
|
|
11707
|
+
];
|
|
11708
|
+
const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
|
|
11709
|
+
const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
|
|
11710
|
+
const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
|
|
11711
|
+
[...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
|
|
11712
|
+
const FALSY_CI_FLAG_VALUES = new Set([
|
|
11713
|
+
"",
|
|
11714
|
+
"0",
|
|
11715
|
+
"false"
|
|
11716
|
+
]);
|
|
11717
|
+
const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
|
|
11718
|
+
const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
|
|
11719
|
+
const detectCiProvider = () => {
|
|
11720
|
+
for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
|
|
11721
|
+
return isCiFlagSet(process.env.CI) ? "unknown" : null;
|
|
11478
11722
|
};
|
|
11479
|
-
|
|
11480
|
-
const
|
|
11481
|
-
const
|
|
11482
|
-
[
|
|
11483
|
-
function assembleStyles() {
|
|
11484
|
-
const codes = /* @__PURE__ */ new Map();
|
|
11485
|
-
for (const [groupName, group] of Object.entries(styles$1)) {
|
|
11486
|
-
for (const [styleName, style] of Object.entries(group)) {
|
|
11487
|
-
styles$1[styleName] = {
|
|
11488
|
-
open: `\u001B[${style[0]}m`,
|
|
11489
|
-
close: `\u001B[${style[1]}m`
|
|
11490
|
-
};
|
|
11491
|
-
group[styleName] = styles$1[styleName];
|
|
11492
|
-
codes.set(style[0], style[1]);
|
|
11493
|
-
}
|
|
11494
|
-
Object.defineProperty(styles$1, groupName, {
|
|
11495
|
-
value: group,
|
|
11496
|
-
enumerable: false
|
|
11497
|
-
});
|
|
11723
|
+
const detectCodingAgentFromValue = () => {
|
|
11724
|
+
for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
|
|
11725
|
+
const value = process.env[environmentVariable]?.toLowerCase();
|
|
11726
|
+
if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
|
|
11498
11727
|
}
|
|
11499
|
-
|
|
11500
|
-
|
|
11501
|
-
|
|
11502
|
-
|
|
11503
|
-
|
|
11504
|
-
|
|
11505
|
-
|
|
11506
|
-
|
|
11507
|
-
|
|
11508
|
-
|
|
11509
|
-
|
|
11510
|
-
|
|
11511
|
-
|
|
11512
|
-
|
|
11513
|
-
|
|
11514
|
-
|
|
11515
|
-
|
|
11516
|
-
|
|
11517
|
-
|
|
11518
|
-
|
|
11519
|
-
|
|
11520
|
-
|
|
11521
|
-
|
|
11522
|
-
|
|
11523
|
-
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
11529
|
-
|
|
11530
|
-
|
|
11531
|
-
|
|
11532
|
-
|
|
11533
|
-
|
|
11534
|
-
|
|
11535
|
-
|
|
11536
|
-
|
|
11537
|
-
|
|
11538
|
-
|
|
11539
|
-
|
|
11540
|
-
|
|
11541
|
-
|
|
11542
|
-
|
|
11543
|
-
|
|
11544
|
-
|
|
11545
|
-
|
|
11546
|
-
|
|
11547
|
-
|
|
11548
|
-
|
|
11549
|
-
|
|
11550
|
-
|
|
11551
|
-
|
|
11552
|
-
|
|
11553
|
-
|
|
11554
|
-
|
|
11555
|
-
|
|
11556
|
-
|
|
11557
|
-
|
|
11558
|
-
|
|
11559
|
-
|
|
11560
|
-
|
|
11561
|
-
|
|
11562
|
-
|
|
11563
|
-
|
|
11564
|
-
|
|
11565
|
-
|
|
11728
|
+
return null;
|
|
11729
|
+
};
|
|
11730
|
+
const detectCodingAgent = () => {
|
|
11731
|
+
for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
|
|
11732
|
+
const agentFromValue = detectCodingAgentFromValue();
|
|
11733
|
+
if (agentFromValue) return agentFromValue;
|
|
11734
|
+
if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
|
|
11735
|
+
return null;
|
|
11736
|
+
};
|
|
11737
|
+
const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
|
|
11738
|
+
const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
|
|
11739
|
+
//#endregion
|
|
11740
|
+
//#region src/cli/utils/is-non-interactive-environment.ts
|
|
11741
|
+
const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
|
|
11742
|
+
"CI",
|
|
11743
|
+
"GITHUB_ACTIONS",
|
|
11744
|
+
"GITLAB_CI",
|
|
11745
|
+
"BUILDKITE",
|
|
11746
|
+
"JENKINS_URL",
|
|
11747
|
+
"TF_BUILD",
|
|
11748
|
+
"CODEBUILD_BUILD_ID",
|
|
11749
|
+
"TEAMCITY_VERSION",
|
|
11750
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
11751
|
+
"CIRCLECI",
|
|
11752
|
+
"TRAVIS",
|
|
11753
|
+
"DRONE",
|
|
11754
|
+
"GIT_DIR"
|
|
11755
|
+
];
|
|
11756
|
+
const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
|
|
11757
|
+
//#endregion
|
|
11758
|
+
//#region src/cli/utils/constants.ts
|
|
11759
|
+
const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
|
|
11760
|
+
const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
|
|
11761
|
+
const SENTRY_FLUSH_TIMEOUT_MS = 2e3;
|
|
11762
|
+
const NANOSECONDS_PER_SECOND = 1000000000n;
|
|
11763
|
+
//#endregion
|
|
11764
|
+
//#region src/cli/utils/noop-console.ts
|
|
11765
|
+
/**
|
|
11766
|
+
* A concrete `Console.Console` whose methods are all no-ops.
|
|
11767
|
+
*
|
|
11768
|
+
* Used by `--silent` (provided via
|
|
11769
|
+
* `Effect.provideService(Console.Console, makeNoopConsole())`) and by
|
|
11770
|
+
* `enableJsonMode` (assigned over the relevant slots on
|
|
11771
|
+
* `globalThis.console` so imperative legacy callsites that aren't
|
|
11772
|
+
* Effect-typed also fall silent). Sourcing both from a single concrete
|
|
11773
|
+
* object keeps "what is a no-op console" answered in one place; the
|
|
11774
|
+
* earlier `new Proxy({} as Console.Console, { get: () => () => undefined })`
|
|
11775
|
+
* combined a cast with a Proxy to do the same thing implicitly.
|
|
11776
|
+
*
|
|
11777
|
+
* The interface mirrors Effect v4's `Console.Console` shape exactly so
|
|
11778
|
+
* `Effect.provideService(Console.Console, makeNoopConsole())` requires
|
|
11779
|
+
* no cast.
|
|
11780
|
+
*/
|
|
11781
|
+
const makeNoopConsole = () => ({
|
|
11782
|
+
assert: () => {},
|
|
11783
|
+
clear: () => {},
|
|
11784
|
+
count: () => {},
|
|
11785
|
+
countReset: () => {},
|
|
11786
|
+
debug: () => {},
|
|
11787
|
+
dir: () => {},
|
|
11788
|
+
dirxml: () => {},
|
|
11789
|
+
error: () => {},
|
|
11790
|
+
group: () => {},
|
|
11791
|
+
groupCollapsed: () => {},
|
|
11792
|
+
groupEnd: () => {},
|
|
11793
|
+
info: () => {},
|
|
11794
|
+
log: () => {},
|
|
11795
|
+
table: () => {},
|
|
11796
|
+
time: () => {},
|
|
11797
|
+
timeEnd: () => {},
|
|
11798
|
+
timeLog: () => {},
|
|
11799
|
+
trace: () => {},
|
|
11800
|
+
warn: () => {}
|
|
11801
|
+
});
|
|
11802
|
+
//#endregion
|
|
11803
|
+
//#region src/cli/utils/version.ts
|
|
11804
|
+
const VERSION = "0.2.14-dev.bdb9e36";
|
|
11805
|
+
//#endregion
|
|
11806
|
+
//#region src/cli/utils/json-mode.ts
|
|
11807
|
+
let context = null;
|
|
11808
|
+
/**
|
|
11809
|
+
* JSON mode writes the report payload to stdout; any incidental log
|
|
11810
|
+
* line printed by an Effect program would corrupt the JSON. Effect's
|
|
11811
|
+
* `Console` module resolves to `globalThis.console` by default (see
|
|
11812
|
+
* `effect/internal/effect.ts` → `ConsoleRef`), so copying the methods
|
|
11813
|
+
* from `makeNoopConsole()` onto the global is enough to silence every
|
|
11814
|
+
* `yield* Console.log(...)` and `cliLogger.*` call sourced from
|
|
11815
|
+
* react-doctor or its services.
|
|
11816
|
+
*
|
|
11817
|
+
* We use the same `makeNoopConsole()` source as the `--silent` path
|
|
11818
|
+
* (which provides the Effect Console via
|
|
11819
|
+
* `Effect.provideService(Console.Console, makeNoopConsole())`) — one
|
|
11820
|
+
* canonical "no-op console" definition shared by the two silent
|
|
11821
|
+
* mechanisms. The two routes still differ in how they install the
|
|
11822
|
+
* noop: silent mode swaps the Effect Console reference inside the
|
|
11823
|
+
* program; JSON mode patches the global because the surrounding CLI
|
|
11824
|
+
* command body is still imperative. Both will collapse into the
|
|
11825
|
+
* Effect-typed route once the command body finishes its migration.
|
|
11826
|
+
*
|
|
11827
|
+
* JSON mode is one-shot per CLI invocation, so we never restore.
|
|
11828
|
+
*/
|
|
11829
|
+
const installSilentConsole = () => {
|
|
11830
|
+
const noopConsole = makeNoopConsole();
|
|
11831
|
+
const target = globalThis.console;
|
|
11832
|
+
const source = noopConsole;
|
|
11833
|
+
for (const key of [
|
|
11834
|
+
"log",
|
|
11835
|
+
"error",
|
|
11836
|
+
"warn",
|
|
11837
|
+
"info",
|
|
11838
|
+
"debug",
|
|
11839
|
+
"trace"
|
|
11840
|
+
]) target[key] = source[key];
|
|
11841
|
+
};
|
|
11842
|
+
const enableJsonMode = ({ compact, directory }) => {
|
|
11843
|
+
context = {
|
|
11844
|
+
compact,
|
|
11845
|
+
directory,
|
|
11846
|
+
startTime: performance.now(),
|
|
11847
|
+
mode: "full"
|
|
11848
|
+
};
|
|
11849
|
+
installSilentConsole();
|
|
11850
|
+
};
|
|
11851
|
+
const isJsonModeActive = () => context !== null;
|
|
11852
|
+
const setJsonReportDirectory = (directory) => {
|
|
11853
|
+
if (context) context.directory = directory;
|
|
11854
|
+
};
|
|
11855
|
+
const setJsonReportMode = (mode) => {
|
|
11856
|
+
if (context) context.mode = mode;
|
|
11857
|
+
};
|
|
11858
|
+
const writeJsonReport = (report) => {
|
|
11859
|
+
const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
|
|
11860
|
+
process.stdout.write(`${serialized}\n`);
|
|
11861
|
+
};
|
|
11862
|
+
const writeJsonErrorReport = (error) => {
|
|
11863
|
+
if (!context) return;
|
|
11864
|
+
try {
|
|
11865
|
+
writeJsonReport(buildJsonReportError({
|
|
11866
|
+
version: VERSION,
|
|
11867
|
+
directory: context.directory,
|
|
11868
|
+
error,
|
|
11869
|
+
elapsedMilliseconds: performance.now() - context.startTime,
|
|
11870
|
+
mode: context.mode
|
|
11871
|
+
}));
|
|
11872
|
+
} catch {
|
|
11873
|
+
process.stdout.write(INTERNAL_ERROR_JSON_FALLBACK);
|
|
11874
|
+
}
|
|
11875
|
+
};
|
|
11876
|
+
//#endregion
|
|
11877
|
+
//#region src/cli/utils/scrub-sensitive-text.ts
|
|
11878
|
+
const HOME_DIRECTORY = os.homedir();
|
|
11879
|
+
const USER_HOME_PATTERNS = [/[A-Za-z]:[\\/]Users[\\/][^\\/]+/gi, /(?:\/Users\/|\/home\/)[^/\\]+/gi];
|
|
11880
|
+
/**
|
|
11881
|
+
* Replaces the user's home directory (and generic `/Users|/home|C:\Users\<name>`
|
|
11882
|
+
* roots) with `~` so absolute paths can't be tied back to an individual. Keeps
|
|
11883
|
+
* the path's relative structure intact, which stays useful for debugging while
|
|
11884
|
+
* dropping the personally-identifying prefix. Idempotent — re-running on an
|
|
11885
|
+
* already-scrubbed `~/...` path is a no-op.
|
|
11886
|
+
*/
|
|
11887
|
+
const scrubSensitivePaths = (text) => {
|
|
11888
|
+
let scrubbed = text;
|
|
11889
|
+
if (HOME_DIRECTORY.length > 1) scrubbed = scrubbed.split(HOME_DIRECTORY).join("~");
|
|
11890
|
+
for (const pattern of USER_HOME_PATTERNS) scrubbed = scrubbed.replace(pattern, "~");
|
|
11891
|
+
return scrubbed;
|
|
11892
|
+
};
|
|
11893
|
+
//#endregion
|
|
11894
|
+
//#region src/cli/utils/build-run-context.ts
|
|
11895
|
+
const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
|
|
11896
|
+
const detectInvokedVia = () => {
|
|
11897
|
+
const userAgent = process.env.npm_config_user_agent;
|
|
11898
|
+
if (!userAgent) return "unknown";
|
|
11899
|
+
return userAgent.split("/", 1)[0]?.trim() || "unknown";
|
|
11900
|
+
};
|
|
11901
|
+
const detectNodeMajor = () => {
|
|
11902
|
+
const major = Number.parseInt(process.versions.node.split(".", 1)[0] ?? "", 10);
|
|
11903
|
+
return Number.isNaN(major) ? 0 : major;
|
|
11904
|
+
};
|
|
11905
|
+
const detectOrigin = () => {
|
|
11906
|
+
if (process.env.GIT_DIR) return "git-hook";
|
|
11907
|
+
if (isCodingAgentEnvironment()) return "agent";
|
|
11908
|
+
if (isCiEnvironment()) return "ci";
|
|
11909
|
+
return "cli";
|
|
11910
|
+
};
|
|
11911
|
+
const detectCommand = (userArguments) => {
|
|
11912
|
+
for (const argument of userArguments) {
|
|
11913
|
+
if (argument === "--") break;
|
|
11914
|
+
if (argument.startsWith("-")) continue;
|
|
11915
|
+
return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
|
|
11916
|
+
}
|
|
11917
|
+
return "inspect";
|
|
11918
|
+
};
|
|
11919
|
+
/**
|
|
11920
|
+
* Snapshot of the current invocation, attached to Sentry events as the
|
|
11921
|
+
* `run` context to make crashes triage-able (which version, platform,
|
|
11922
|
+
* CI/agent, how it was invoked). Every field is cheap, synchronous, and
|
|
11923
|
+
* safe to read at any point — cwd reads fall back, env reads are
|
|
11924
|
+
* booleans — so it's rebuilt lazily at capture time when runtime-only
|
|
11925
|
+
* signals like `jsonMode` are finally known.
|
|
11926
|
+
*/
|
|
11927
|
+
const buildRunContext = () => {
|
|
11928
|
+
const userArguments = process.argv.slice(2);
|
|
11929
|
+
return {
|
|
11930
|
+
version: VERSION,
|
|
11931
|
+
origin: detectOrigin(),
|
|
11932
|
+
command: detectCommand(userArguments),
|
|
11933
|
+
argv: scrubSensitivePaths(userArguments.join(" ")),
|
|
11934
|
+
cwd: scrubSensitivePaths(process.cwd()),
|
|
11935
|
+
node: process.version,
|
|
11936
|
+
nodeMajor: detectNodeMajor(),
|
|
11937
|
+
platform: process.platform,
|
|
11938
|
+
arch: process.arch,
|
|
11939
|
+
ci: isCiEnvironment(),
|
|
11940
|
+
ciProvider: detectCiProvider(),
|
|
11941
|
+
codingAgent: detectCodingAgent(),
|
|
11942
|
+
interactive: !isNonInteractiveEnvironment(),
|
|
11943
|
+
jsonMode: isJsonModeActive(),
|
|
11944
|
+
invokedVia: detectInvokedVia()
|
|
11945
|
+
};
|
|
11946
|
+
};
|
|
11947
|
+
//#endregion
|
|
11948
|
+
//#region src/cli/utils/build-sentry-project-context.ts
|
|
11949
|
+
/**
|
|
11950
|
+
* Projects the {@link ProjectInfo} we already detect during a scan into the
|
|
11951
|
+
* Sentry scope shape: a handful of searchable `project.*` tags plus the
|
|
11952
|
+
* anonymous project *shape* as a `project` context block. Lets crash/transaction
|
|
11953
|
+
* triage answer "which kind of project hit this?" (framework, React/Expo
|
|
11954
|
+
* version, TypeScript, size) without sending source code — and deliberately
|
|
11955
|
+
* omits `projectName` and `rootDirectory`, the two identifying fields, so the
|
|
11956
|
+
* project can't be tied back to a specific company/repo.
|
|
11957
|
+
*/
|
|
11958
|
+
const buildSentryProjectContext = (projectInfo) => ({
|
|
11959
|
+
tags: {
|
|
11960
|
+
"project.framework": projectInfo.framework,
|
|
11961
|
+
"project.reactMajor": projectInfo.reactMajorVersion,
|
|
11962
|
+
"project.typescript": projectInfo.hasTypeScript,
|
|
11963
|
+
"project.reactCompiler": projectInfo.hasReactCompiler,
|
|
11964
|
+
"project.expo": projectInfo.expoVersion !== null,
|
|
11965
|
+
"project.reactNative": projectInfo.hasReactNativeWorkspace
|
|
11966
|
+
},
|
|
11967
|
+
context: {
|
|
11968
|
+
framework: projectInfo.framework,
|
|
11969
|
+
reactVersion: projectInfo.reactVersion,
|
|
11970
|
+
reactMajorVersion: projectInfo.reactMajorVersion,
|
|
11971
|
+
hasTypeScript: projectInfo.hasTypeScript,
|
|
11972
|
+
hasReactCompiler: projectInfo.hasReactCompiler,
|
|
11973
|
+
hasTanStackQuery: projectInfo.hasTanStackQuery,
|
|
11974
|
+
tailwindVersion: projectInfo.tailwindVersion,
|
|
11975
|
+
zodVersion: projectInfo.zodVersion,
|
|
11976
|
+
preactVersion: projectInfo.preactVersion,
|
|
11977
|
+
hasReactNativeWorkspace: projectInfo.hasReactNativeWorkspace,
|
|
11978
|
+
expoVersion: projectInfo.expoVersion,
|
|
11979
|
+
hasReanimated: projectInfo.hasReanimated,
|
|
11980
|
+
sourceFileCount: projectInfo.sourceFileCount
|
|
11981
|
+
}
|
|
11982
|
+
});
|
|
11983
|
+
let currentProjectInfo = null;
|
|
11984
|
+
const setSentryProjectInfo = (projectInfo) => {
|
|
11985
|
+
currentProjectInfo = projectInfo;
|
|
11986
|
+
};
|
|
11987
|
+
const getSentryProjectInfo = () => currentProjectInfo;
|
|
11988
|
+
//#endregion
|
|
11989
|
+
//#region src/cli/utils/build-sentry-scope.ts
|
|
11990
|
+
/**
|
|
11991
|
+
* Projects a {@link RunContext} snapshot (plus the current run's
|
|
11992
|
+
* {@link getSentryProjectInfo project info}, when a scan has discovered it) into
|
|
11993
|
+
* the Sentry scope shape — the searchable `tags` that make crashes/transactions
|
|
11994
|
+
* filterable (which command, origin, CI provider, coding agent, Node major,
|
|
11995
|
+
* package manager, project framework/React major) plus the full `run` and
|
|
11996
|
+
* `project` context blocks for deep triage.
|
|
11997
|
+
*
|
|
11998
|
+
* Shared by `instrument.ts` (seeded as `initialScope` so *every* event,
|
|
11999
|
+
* including performance transactions, carries it) and `report-error.ts` (a
|
|
12000
|
+
* capture-time refresh, since runtime-only signals like `jsonMode` and the
|
|
12001
|
+
* scanned project are only known once a command has begun).
|
|
12002
|
+
*/
|
|
12003
|
+
const buildSentryScope = (runContext = buildRunContext()) => {
|
|
12004
|
+
const tags = {
|
|
12005
|
+
origin: runContext.origin,
|
|
12006
|
+
command: runContext.command,
|
|
12007
|
+
ci: runContext.ci,
|
|
12008
|
+
ciProvider: runContext.ciProvider,
|
|
12009
|
+
codingAgent: runContext.codingAgent,
|
|
12010
|
+
interactive: runContext.interactive,
|
|
12011
|
+
jsonMode: runContext.jsonMode,
|
|
12012
|
+
invokedVia: runContext.invokedVia,
|
|
12013
|
+
nodeMajor: runContext.nodeMajor
|
|
12014
|
+
};
|
|
12015
|
+
const contexts = { run: { ...runContext } };
|
|
12016
|
+
const projectInfo = getSentryProjectInfo();
|
|
12017
|
+
if (projectInfo) {
|
|
12018
|
+
const project = buildSentryProjectContext(projectInfo);
|
|
12019
|
+
Object.assign(tags, project.tags);
|
|
12020
|
+
contexts.project = project.context;
|
|
12021
|
+
}
|
|
12022
|
+
return {
|
|
12023
|
+
tags,
|
|
12024
|
+
contexts
|
|
12025
|
+
};
|
|
12026
|
+
};
|
|
12027
|
+
//#endregion
|
|
12028
|
+
//#region src/cli/utils/scrub-sentry-event.ts
|
|
12029
|
+
const anonymizeText = (text) => redactSensitiveText(scrubSensitivePaths(text));
|
|
12030
|
+
/**
|
|
12031
|
+
* Recursively rewrites every string within an arbitrary value (object / array /
|
|
12032
|
+
* primitive) through {@link anonymizeText}, mutating in place. Used to sweep the
|
|
12033
|
+
* unstructured corners of an event (contexts, extra, tags, breadcrumb data,
|
|
12034
|
+
* span attributes) where a path or secret could hide.
|
|
12035
|
+
*/
|
|
12036
|
+
const anonymizeInPlace = (value) => {
|
|
12037
|
+
if (Array.isArray(value)) {
|
|
12038
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
12039
|
+
const item = value[index];
|
|
12040
|
+
if (typeof item === "string") value[index] = anonymizeText(item);
|
|
12041
|
+
else anonymizeInPlace(item);
|
|
12042
|
+
}
|
|
12043
|
+
return;
|
|
12044
|
+
}
|
|
12045
|
+
if (!isPlainObject(value)) return;
|
|
12046
|
+
for (const key of Object.keys(value)) {
|
|
12047
|
+
const inner = value[key];
|
|
12048
|
+
if (typeof inner === "string") value[key] = anonymizeText(inner);
|
|
12049
|
+
else anonymizeInPlace(inner);
|
|
12050
|
+
}
|
|
12051
|
+
};
|
|
12052
|
+
/**
|
|
12053
|
+
* Anonymizes a Sentry event (error or transaction) before it leaves the
|
|
12054
|
+
* machine. Strips identity the SDK attaches automatically — the IP-bearing
|
|
12055
|
+
* `user`, the `server_name`, and the device `name` (all hostnames) — drops
|
|
12056
|
+
* captured local variables (unbounded, un-anonymizable user data), and scrubs
|
|
12057
|
+
* home-directory paths + known secrets/emails from every remaining string:
|
|
12058
|
+
* messages, stack frames, breadcrumbs, contexts/extra/tags, and span
|
|
12059
|
+
* attributes (e.g. the `inspect.directory` path on the bridged `runInspect`
|
|
12060
|
+
* span).
|
|
12061
|
+
*
|
|
12062
|
+
* Wired into both `beforeSend` and `beforeSendTransaction`. If scrubbing ever
|
|
12063
|
+
* throws, the event is dropped (`null`) rather than risk sending un-anonymized
|
|
12064
|
+
* data — telemetry is best-effort, privacy is not.
|
|
12065
|
+
*/
|
|
12066
|
+
const scrubSentryEvent = (event) => {
|
|
12067
|
+
try {
|
|
12068
|
+
delete event.server_name;
|
|
12069
|
+
delete event.user;
|
|
12070
|
+
const device = event.contexts?.device;
|
|
12071
|
+
if (device) delete device.name;
|
|
12072
|
+
if (event.contexts) anonymizeInPlace(event.contexts);
|
|
12073
|
+
if (event.extra) anonymizeInPlace(event.extra);
|
|
12074
|
+
if (event.tags) anonymizeInPlace(event.tags);
|
|
12075
|
+
if (typeof event.message === "string") event.message = anonymizeText(event.message);
|
|
12076
|
+
for (const breadcrumb of event.breadcrumbs ?? []) {
|
|
12077
|
+
if (typeof breadcrumb.message === "string") breadcrumb.message = anonymizeText(breadcrumb.message);
|
|
12078
|
+
if (breadcrumb.data) anonymizeInPlace(breadcrumb.data);
|
|
12079
|
+
}
|
|
12080
|
+
for (const exception of event.exception?.values ?? []) {
|
|
12081
|
+
if (typeof exception.value === "string") exception.value = anonymizeText(exception.value);
|
|
12082
|
+
for (const frame of exception.stacktrace?.frames ?? []) {
|
|
12083
|
+
delete frame.vars;
|
|
12084
|
+
if (typeof frame.filename === "string") frame.filename = scrubSensitivePaths(frame.filename);
|
|
12085
|
+
if (typeof frame.abs_path === "string") frame.abs_path = scrubSensitivePaths(frame.abs_path);
|
|
12086
|
+
if (typeof frame.module === "string") frame.module = scrubSensitivePaths(frame.module);
|
|
12087
|
+
}
|
|
12088
|
+
}
|
|
12089
|
+
for (const span of event.spans ?? []) {
|
|
12090
|
+
if (typeof span.description === "string") span.description = anonymizeText(span.description);
|
|
12091
|
+
if (span.data) anonymizeInPlace(span.data);
|
|
12092
|
+
}
|
|
12093
|
+
return event;
|
|
12094
|
+
} catch {
|
|
12095
|
+
return null;
|
|
12096
|
+
}
|
|
12097
|
+
};
|
|
12098
|
+
//#endregion
|
|
12099
|
+
//#region src/instrument.ts
|
|
12100
|
+
let isInitialized = false;
|
|
12101
|
+
let resolvedTracesSampleRate = 0;
|
|
12102
|
+
const shouldEnableSentry = () => {
|
|
12103
|
+
if (process.argv.includes("--no-score") || process.argv.includes("--no-telemetry")) return false;
|
|
12104
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
|
|
12105
|
+
return true;
|
|
12106
|
+
};
|
|
12107
|
+
const isEnvFlagEnabled = (value) => value === "1" || value?.toLowerCase() === "true";
|
|
12108
|
+
/**
|
|
12109
|
+
* A version is a "dev" build when it's the unbuilt placeholder (`0.0.0`) or
|
|
12110
|
+
* carries a prerelease suffix (e.g. the `-dev.<sha>` snapshots published from
|
|
12111
|
+
* CI). Everything else is a real, tagged release.
|
|
12112
|
+
*/
|
|
12113
|
+
const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
|
|
12114
|
+
/**
|
|
12115
|
+
* Sentry release identifier. `react-doctor@<version>` keeps it unique within
|
|
12116
|
+
* the org and — crucially — matches the value `scripts/sentry-sourcemaps.mjs`
|
|
12117
|
+
* uploads source-map artifacts under, so stack frames symbolicate. Honors the
|
|
12118
|
+
* standard `SENTRY_RELEASE` override.
|
|
12119
|
+
*/
|
|
12120
|
+
const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.2.14-dev.bdb9e36`;
|
|
12121
|
+
/**
|
|
12122
|
+
* Deployment environment shown in Sentry's environment filter. Defaults to
|
|
12123
|
+
* `production` for tagged releases and `development` for dev/unbuilt versions,
|
|
12124
|
+
* overridable via the standard `SENTRY_ENVIRONMENT` env var.
|
|
12125
|
+
*/
|
|
12126
|
+
const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.2.14-dev.bdb9e36") ? "development" : "production");
|
|
12127
|
+
/**
|
|
12128
|
+
* Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
|
|
12129
|
+
* (set to `0` to disable tracing) and falls back to
|
|
12130
|
+
* {@link SENTRY_DEFAULT_TRACES_SAMPLE_RATE}. Invalid / out-of-range values fall
|
|
12131
|
+
* back to the default rather than silently disabling tracing.
|
|
12132
|
+
*/
|
|
12133
|
+
const resolveTracesSampleRate = () => {
|
|
12134
|
+
const raw = process.env.SENTRY_TRACES_SAMPLE_RATE;
|
|
12135
|
+
if (raw === void 0 || raw.trim() === "") return 1;
|
|
12136
|
+
const parsed = Number(raw);
|
|
12137
|
+
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) return 1;
|
|
12138
|
+
return parsed;
|
|
12139
|
+
};
|
|
12140
|
+
/**
|
|
12141
|
+
* Whether performance traces will actually be recorded — Sentry is live and the
|
|
12142
|
+
* resolved sample rate is above zero. Used to gate the per-run root span and
|
|
12143
|
+
* the Effect→Sentry tracer bridge so they're true no-ops when tracing is off.
|
|
12144
|
+
*/
|
|
12145
|
+
const isSentryTracingEnabled = () => Sentry.isInitialized() && resolvedTracesSampleRate > 0;
|
|
12146
|
+
/**
|
|
12147
|
+
* Flushes queued Sentry events (errors + transactions) before the CLI exits, so
|
|
12148
|
+
* the success-path transaction is delivered. A no-op when Sentry was never
|
|
12149
|
+
* initialized, and it swallows transport failures so telemetry can never mask
|
|
12150
|
+
* the user's result.
|
|
12151
|
+
*/
|
|
12152
|
+
const flushSentry = async () => {
|
|
12153
|
+
if (!Sentry.isInitialized()) return;
|
|
12154
|
+
try {
|
|
12155
|
+
await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
|
|
12156
|
+
} catch {}
|
|
12157
|
+
};
|
|
12158
|
+
/**
|
|
12159
|
+
* Initializes the Sentry Node SDK for CLI crash reporting and performance
|
|
12160
|
+
* tracing. Invoked as the first statement of the CLI entry (`cli/index.ts`) so
|
|
12161
|
+
* the SDK's global `uncaughtException` / `unhandledRejection` handlers and OTel
|
|
12162
|
+
* auto-instrumentation are armed before any command runs.
|
|
12163
|
+
*
|
|
12164
|
+
* Exported as a function rather than a bare side-effecting import because the
|
|
12165
|
+
* package declares `"sideEffects": false`, which lets the bundler tree-shake
|
|
12166
|
+
* side-effect-only modules. An explicit call keeps the initialization in the
|
|
12167
|
+
* published `dist/cli.js`.
|
|
12168
|
+
*
|
|
12169
|
+
* Scoped to the CLI application only — the programmatic `@react-doctor/api`
|
|
12170
|
+
* library never initializes Sentry, so importing `diagnose()` into a consumer
|
|
12171
|
+
* app can't hijack their telemetry.
|
|
12172
|
+
*
|
|
12173
|
+
* Configuration is environment-overridable for self-hosting and tuning:
|
|
12174
|
+
* `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`,
|
|
12175
|
+
* `SENTRY_TRACES_SAMPLE_RATE` (`0` disables tracing), and `SENTRY_DEBUG`.
|
|
12176
|
+
*/
|
|
12177
|
+
const initializeSentry = () => {
|
|
12178
|
+
if (isInitialized || !shouldEnableSentry()) return;
|
|
12179
|
+
isInitialized = true;
|
|
12180
|
+
resolvedTracesSampleRate = resolveTracesSampleRate();
|
|
12181
|
+
const { tags, contexts } = buildSentryScope();
|
|
12182
|
+
Sentry.init({
|
|
12183
|
+
dsn: process.env.SENTRY_DSN || "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920",
|
|
12184
|
+
release: resolveSentryRelease(),
|
|
12185
|
+
environment: resolveSentryEnvironment(),
|
|
12186
|
+
sendDefaultPii: false,
|
|
12187
|
+
tracesSampleRate: resolvedTracesSampleRate,
|
|
12188
|
+
debug: isEnvFlagEnabled(process.env.SENTRY_DEBUG),
|
|
12189
|
+
initialScope: {
|
|
12190
|
+
tags,
|
|
12191
|
+
contexts
|
|
12192
|
+
},
|
|
12193
|
+
beforeSend: (event) => scrubSentryEvent(event),
|
|
12194
|
+
beforeSendTransaction: (event) => scrubSentryEvent(event)
|
|
12195
|
+
});
|
|
12196
|
+
};
|
|
12197
|
+
//#endregion
|
|
12198
|
+
//#region src/cli/utils/sentry-tracer.ts
|
|
12199
|
+
const toHrTime = (epochNanoseconds) => [Number(epochNanoseconds / NANOSECONDS_PER_SECOND), Number(epochNanoseconds % NANOSECONDS_PER_SECOND)];
|
|
12200
|
+
const SPAN_KIND_TO_OTEL = {
|
|
12201
|
+
internal: 0,
|
|
12202
|
+
server: 1,
|
|
12203
|
+
client: 2,
|
|
12204
|
+
producer: 3,
|
|
12205
|
+
consumer: 4
|
|
12206
|
+
};
|
|
12207
|
+
const toSentryAttributeValue = (value) => {
|
|
12208
|
+
if (value === null || value === void 0) return void 0;
|
|
12209
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
12210
|
+
return String(value);
|
|
12211
|
+
};
|
|
12212
|
+
const normalizeAttributes = (attributes) => {
|
|
12213
|
+
const normalized = {};
|
|
12214
|
+
if (!attributes) return normalized;
|
|
12215
|
+
for (const [key, value] of Object.entries(attributes)) normalized[key] = toSentryAttributeValue(value);
|
|
12216
|
+
return normalized;
|
|
12217
|
+
};
|
|
12218
|
+
const isSentryBackedSpan = (span) => span._tag === "Span" && "sentrySpan" in span;
|
|
12219
|
+
const spanContextFor = (span) => isSentryBackedSpan(span) ? span.sentrySpan.spanContext() : {
|
|
12220
|
+
traceId: span.traceId,
|
|
12221
|
+
spanId: span.spanId,
|
|
12222
|
+
traceFlags: span.sampled ? 1 : 0
|
|
12223
|
+
};
|
|
12224
|
+
/**
|
|
12225
|
+
* Builds an Effect {@link Tracer.Tracer} that materializes every Effect span
|
|
12226
|
+
* (`Effect.withSpan(...)` / `Effect.fn("Service.method")`) as a child Sentry
|
|
12227
|
+
* span, producing one unified per-run trace in Sentry. The CLI already
|
|
12228
|
+
* instruments `runInspect` and each core service method, so this bridge lights
|
|
12229
|
+
* all of that up in Sentry for free.
|
|
12230
|
+
*
|
|
12231
|
+
* `rootSpan` is the active per-run transaction; Effect spans without an Effect
|
|
12232
|
+
* parent attach to it, so nesting is correct even if async-context propagation
|
|
12233
|
+
* is interrupted by Effect's fiber scheduler. Provided to a program via
|
|
12234
|
+
* `Effect.withTracer(...)`.
|
|
12235
|
+
*/
|
|
12236
|
+
const makeSentryTracer = (rootSpan, startInactiveSpan = Sentry.startInactiveSpan) => Tracer.make({ span: (options) => {
|
|
12237
|
+
const parentSpan = Option.isSome(options.parent) && isSentryBackedSpan(options.parent.value) ? options.parent.value.sentrySpan : rootSpan;
|
|
12238
|
+
const sentrySpan = startInactiveSpan({
|
|
12239
|
+
name: options.name,
|
|
12240
|
+
startTime: toHrTime(options.startTime),
|
|
12241
|
+
parentSpan,
|
|
12242
|
+
kind: SPAN_KIND_TO_OTEL[options.kind]
|
|
12243
|
+
});
|
|
12244
|
+
const { traceId, spanId } = sentrySpan.spanContext();
|
|
12245
|
+
const attributes = /* @__PURE__ */ new Map();
|
|
12246
|
+
let status = {
|
|
12247
|
+
_tag: "Started",
|
|
12248
|
+
startTime: options.startTime
|
|
12249
|
+
};
|
|
12250
|
+
return {
|
|
12251
|
+
_tag: "Span",
|
|
12252
|
+
sentrySpan,
|
|
12253
|
+
name: options.name,
|
|
12254
|
+
spanId,
|
|
12255
|
+
traceId,
|
|
12256
|
+
parent: options.parent,
|
|
12257
|
+
annotations: options.annotations,
|
|
12258
|
+
links: options.links,
|
|
12259
|
+
sampled: options.sampled,
|
|
12260
|
+
kind: options.kind,
|
|
12261
|
+
get status() {
|
|
12262
|
+
return status;
|
|
12263
|
+
},
|
|
12264
|
+
get attributes() {
|
|
12265
|
+
return attributes;
|
|
12266
|
+
},
|
|
12267
|
+
end: (endTime, exit) => {
|
|
12268
|
+
status = {
|
|
12269
|
+
_tag: "Ended",
|
|
12270
|
+
startTime: options.startTime,
|
|
12271
|
+
endTime,
|
|
12272
|
+
exit
|
|
12273
|
+
};
|
|
12274
|
+
sentrySpan.setStatus({ code: Exit.isSuccess(exit) ? 1 : 2 });
|
|
12275
|
+
sentrySpan.end(toHrTime(endTime));
|
|
12276
|
+
},
|
|
12277
|
+
attribute: (key, value) => {
|
|
12278
|
+
attributes.set(key, value);
|
|
12279
|
+
sentrySpan.setAttribute(key, toSentryAttributeValue(value));
|
|
12280
|
+
},
|
|
12281
|
+
event: (name, startTime, eventAttributes) => {
|
|
12282
|
+
sentrySpan.addEvent(name, normalizeAttributes(eventAttributes), toHrTime(startTime));
|
|
12283
|
+
},
|
|
12284
|
+
addLinks: (links) => {
|
|
12285
|
+
for (const link of links) sentrySpan.addLink({
|
|
12286
|
+
context: spanContextFor(link.span),
|
|
12287
|
+
attributes: normalizeAttributes(link.attributes)
|
|
12288
|
+
});
|
|
12289
|
+
}
|
|
12290
|
+
};
|
|
12291
|
+
} });
|
|
12292
|
+
//#endregion
|
|
12293
|
+
//#region src/cli/utils/apply-observability.ts
|
|
12294
|
+
const isOtlpExportConfigured = () => Boolean(process.env.REACT_DOCTOR_OTLP_ENDPOINT) && Boolean(process.env.REACT_DOCTOR_OTLP_AUTH_HEADER);
|
|
12295
|
+
const externalSpanFrom = (sentrySpan) => {
|
|
12296
|
+
const { traceId, spanId, traceFlags } = sentrySpan.spanContext();
|
|
12297
|
+
return Tracer.externalSpan({
|
|
12298
|
+
traceId,
|
|
12299
|
+
spanId,
|
|
12300
|
+
sampled: (traceFlags & 1) === 1
|
|
12301
|
+
});
|
|
12302
|
+
};
|
|
12303
|
+
/**
|
|
12304
|
+
* Installs the tracing backend for the inspect program. Effect's tracer is a
|
|
12305
|
+
* single reference, so the backends are mutually exclusive — we pick by
|
|
12306
|
+
* precedence:
|
|
12307
|
+
*
|
|
12308
|
+
* 1. **User OTLP backend** (`REACT_DOCTOR_OTLP_*` set) wins; we additionally
|
|
12309
|
+
* parent the Effect trace under the active Sentry trace via an
|
|
12310
|
+
* `ExternalSpan` so a trace exported to the user's backend shares its
|
|
12311
|
+
* `trace_id` with the corresponding Sentry trace.
|
|
12312
|
+
* 2. **Sentry tracing active** (and no user OTLP): route Effect's existing
|
|
12313
|
+
* span instrumentation straight into Sentry as one unified per-run trace.
|
|
12314
|
+
* 3. **Neither**: provide the (no-op) OTLP layer, leaving Effect's native
|
|
12315
|
+
* in-memory tracer — identical to the prior default behavior.
|
|
12316
|
+
*/
|
|
12317
|
+
const applyObservability = (program, rootSentrySpan) => {
|
|
12318
|
+
if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(Effect.provideService(Tracer.ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(Effect.provide(layerOtlp));
|
|
12319
|
+
if (rootSentrySpan) return program.pipe(Effect.withTracer(makeSentryTracer(rootSentrySpan)));
|
|
12320
|
+
return program.pipe(Effect.provide(layerOtlp));
|
|
12321
|
+
};
|
|
12322
|
+
//#endregion
|
|
12323
|
+
//#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
12324
|
+
const ANSI_BACKGROUND_OFFSET = 10;
|
|
12325
|
+
const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
|
|
12326
|
+
const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
|
|
12327
|
+
const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
|
|
12328
|
+
const styles$1 = {
|
|
12329
|
+
modifier: {
|
|
12330
|
+
reset: [0, 0],
|
|
12331
|
+
bold: [1, 22],
|
|
12332
|
+
dim: [2, 22],
|
|
12333
|
+
italic: [3, 23],
|
|
12334
|
+
underline: [4, 24],
|
|
12335
|
+
overline: [53, 55],
|
|
12336
|
+
inverse: [7, 27],
|
|
12337
|
+
hidden: [8, 28],
|
|
12338
|
+
strikethrough: [9, 29]
|
|
12339
|
+
},
|
|
12340
|
+
color: {
|
|
12341
|
+
black: [30, 39],
|
|
12342
|
+
red: [31, 39],
|
|
12343
|
+
green: [32, 39],
|
|
12344
|
+
yellow: [33, 39],
|
|
12345
|
+
blue: [34, 39],
|
|
12346
|
+
magenta: [35, 39],
|
|
12347
|
+
cyan: [36, 39],
|
|
12348
|
+
white: [37, 39],
|
|
12349
|
+
blackBright: [90, 39],
|
|
12350
|
+
gray: [90, 39],
|
|
12351
|
+
grey: [90, 39],
|
|
12352
|
+
redBright: [91, 39],
|
|
12353
|
+
greenBright: [92, 39],
|
|
12354
|
+
yellowBright: [93, 39],
|
|
12355
|
+
blueBright: [94, 39],
|
|
12356
|
+
magentaBright: [95, 39],
|
|
12357
|
+
cyanBright: [96, 39],
|
|
12358
|
+
whiteBright: [97, 39]
|
|
12359
|
+
},
|
|
12360
|
+
bgColor: {
|
|
12361
|
+
bgBlack: [40, 49],
|
|
12362
|
+
bgRed: [41, 49],
|
|
12363
|
+
bgGreen: [42, 49],
|
|
12364
|
+
bgYellow: [43, 49],
|
|
12365
|
+
bgBlue: [44, 49],
|
|
12366
|
+
bgMagenta: [45, 49],
|
|
12367
|
+
bgCyan: [46, 49],
|
|
12368
|
+
bgWhite: [47, 49],
|
|
12369
|
+
bgBlackBright: [100, 49],
|
|
12370
|
+
bgGray: [100, 49],
|
|
12371
|
+
bgGrey: [100, 49],
|
|
12372
|
+
bgRedBright: [101, 49],
|
|
12373
|
+
bgGreenBright: [102, 49],
|
|
12374
|
+
bgYellowBright: [103, 49],
|
|
12375
|
+
bgBlueBright: [104, 49],
|
|
12376
|
+
bgMagentaBright: [105, 49],
|
|
12377
|
+
bgCyanBright: [106, 49],
|
|
12378
|
+
bgWhiteBright: [107, 49]
|
|
12379
|
+
}
|
|
12380
|
+
};
|
|
12381
|
+
Object.keys(styles$1.modifier);
|
|
12382
|
+
const foregroundColorNames = Object.keys(styles$1.color);
|
|
12383
|
+
const backgroundColorNames = Object.keys(styles$1.bgColor);
|
|
12384
|
+
[...foregroundColorNames, ...backgroundColorNames];
|
|
12385
|
+
function assembleStyles() {
|
|
12386
|
+
const codes = /* @__PURE__ */ new Map();
|
|
12387
|
+
for (const [groupName, group] of Object.entries(styles$1)) {
|
|
12388
|
+
for (const [styleName, style] of Object.entries(group)) {
|
|
12389
|
+
styles$1[styleName] = {
|
|
12390
|
+
open: `\u001B[${style[0]}m`,
|
|
12391
|
+
close: `\u001B[${style[1]}m`
|
|
12392
|
+
};
|
|
12393
|
+
group[styleName] = styles$1[styleName];
|
|
12394
|
+
codes.set(style[0], style[1]);
|
|
12395
|
+
}
|
|
12396
|
+
Object.defineProperty(styles$1, groupName, {
|
|
12397
|
+
value: group,
|
|
12398
|
+
enumerable: false
|
|
12399
|
+
});
|
|
12400
|
+
}
|
|
12401
|
+
Object.defineProperty(styles$1, "codes", {
|
|
12402
|
+
value: codes,
|
|
12403
|
+
enumerable: false
|
|
12404
|
+
});
|
|
12405
|
+
styles$1.color.close = "\x1B[39m";
|
|
12406
|
+
styles$1.bgColor.close = "\x1B[49m";
|
|
12407
|
+
styles$1.color.ansi = wrapAnsi16();
|
|
12408
|
+
styles$1.color.ansi256 = wrapAnsi256();
|
|
12409
|
+
styles$1.color.ansi16m = wrapAnsi16m();
|
|
12410
|
+
styles$1.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET);
|
|
12411
|
+
styles$1.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET);
|
|
12412
|
+
styles$1.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
|
|
12413
|
+
Object.defineProperties(styles$1, {
|
|
12414
|
+
rgbToAnsi256: {
|
|
12415
|
+
value(red, green, blue) {
|
|
12416
|
+
if (red === green && green === blue) {
|
|
12417
|
+
if (red < 8) return 16;
|
|
12418
|
+
if (red > 248) return 231;
|
|
12419
|
+
return Math.round((red - 8) / 247 * 24) + 232;
|
|
12420
|
+
}
|
|
12421
|
+
return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5);
|
|
12422
|
+
},
|
|
12423
|
+
enumerable: false
|
|
12424
|
+
},
|
|
12425
|
+
hexToRgb: {
|
|
12426
|
+
value(hex) {
|
|
12427
|
+
const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
|
|
12428
|
+
if (!matches) return [
|
|
12429
|
+
0,
|
|
12430
|
+
0,
|
|
12431
|
+
0
|
|
12432
|
+
];
|
|
12433
|
+
let [colorString] = matches;
|
|
12434
|
+
if (colorString.length === 3) colorString = [...colorString].map((character) => character + character).join("");
|
|
12435
|
+
const integer = Number.parseInt(colorString, 16);
|
|
12436
|
+
return [
|
|
12437
|
+
integer >> 16 & 255,
|
|
12438
|
+
integer >> 8 & 255,
|
|
12439
|
+
integer & 255
|
|
12440
|
+
];
|
|
12441
|
+
},
|
|
12442
|
+
enumerable: false
|
|
12443
|
+
},
|
|
12444
|
+
hexToAnsi256: {
|
|
12445
|
+
value: (hex) => styles$1.rgbToAnsi256(...styles$1.hexToRgb(hex)),
|
|
12446
|
+
enumerable: false
|
|
12447
|
+
},
|
|
12448
|
+
ansi256ToAnsi: {
|
|
12449
|
+
value(code) {
|
|
12450
|
+
if (code < 8) return 30 + code;
|
|
12451
|
+
if (code < 16) return 90 + (code - 8);
|
|
12452
|
+
let red;
|
|
12453
|
+
let green;
|
|
12454
|
+
let blue;
|
|
12455
|
+
if (code >= 232) {
|
|
12456
|
+
red = ((code - 232) * 10 + 8) / 255;
|
|
12457
|
+
green = red;
|
|
12458
|
+
blue = red;
|
|
12459
|
+
} else {
|
|
12460
|
+
code -= 16;
|
|
12461
|
+
const remainder = code % 36;
|
|
12462
|
+
red = Math.floor(code / 36) / 5;
|
|
12463
|
+
green = Math.floor(remainder / 6) / 5;
|
|
12464
|
+
blue = remainder % 6 / 5;
|
|
12465
|
+
}
|
|
12466
|
+
const value = Math.max(red, green, blue) * 2;
|
|
12467
|
+
if (value === 0) return 30;
|
|
11566
12468
|
let result = 30 + (Math.round(blue) << 2 | Math.round(green) << 1 | Math.round(red));
|
|
11567
12469
|
if (value === 2) result += 60;
|
|
11568
12470
|
return result;
|
|
@@ -14365,49 +15267,6 @@ function ora(options) {
|
|
|
14365
15267
|
return new Ora(options);
|
|
14366
15268
|
}
|
|
14367
15269
|
//#endregion
|
|
14368
|
-
//#region src/cli/utils/is-ci-environment.ts
|
|
14369
|
-
const CI_ENVIRONMENT_VARIABLES = [
|
|
14370
|
-
"GITHUB_ACTIONS",
|
|
14371
|
-
"GITLAB_CI",
|
|
14372
|
-
"CIRCLECI"
|
|
14373
|
-
];
|
|
14374
|
-
const CODING_AGENT_ENVIRONMENT_VARIABLES = [
|
|
14375
|
-
"CLAUDECODE",
|
|
14376
|
-
"CLAUDE_CODE",
|
|
14377
|
-
"CURSOR_AGENT",
|
|
14378
|
-
"CODEX_CI",
|
|
14379
|
-
"CODEX_SANDBOX",
|
|
14380
|
-
"CODEX_SANDBOX_NETWORK_DISABLED",
|
|
14381
|
-
"OPENCODE",
|
|
14382
|
-
"GOOSE_TERMINAL",
|
|
14383
|
-
"AGENT_SESSION_ID",
|
|
14384
|
-
"AMP_THREAD_ID",
|
|
14385
|
-
"AGENT_THREAD_ID"
|
|
14386
|
-
];
|
|
14387
|
-
const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
|
|
14388
|
-
const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
|
|
14389
|
-
const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
|
|
14390
|
-
const isCodingAgentEnvironment = () => CODING_AGENT_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES.some((envVariable) => CODING_AGENT_ENVIRONMENT_VALUES[envVariable].some((value) => process.env[envVariable]?.toLowerCase() === value));
|
|
14391
|
-
const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
|
|
14392
|
-
//#endregion
|
|
14393
|
-
//#region src/cli/utils/is-non-interactive-environment.ts
|
|
14394
|
-
const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
|
|
14395
|
-
"CI",
|
|
14396
|
-
"GITHUB_ACTIONS",
|
|
14397
|
-
"GITLAB_CI",
|
|
14398
|
-
"BUILDKITE",
|
|
14399
|
-
"JENKINS_URL",
|
|
14400
|
-
"TF_BUILD",
|
|
14401
|
-
"CODEBUILD_BUILD_ID",
|
|
14402
|
-
"TEAMCITY_VERSION",
|
|
14403
|
-
"BITBUCKET_BUILD_NUMBER",
|
|
14404
|
-
"CIRCLECI",
|
|
14405
|
-
"TRAVIS",
|
|
14406
|
-
"DRONE",
|
|
14407
|
-
"GIT_DIR"
|
|
14408
|
-
];
|
|
14409
|
-
const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
|
|
14410
|
-
//#endregion
|
|
14411
15270
|
//#region src/cli/utils/is-spinner-interactive.ts
|
|
14412
15271
|
const isSpinnerInteractive = (stream = process.stderr) => {
|
|
14413
15272
|
if (stream.isTTY !== true) return false;
|
|
@@ -14489,9 +15348,8 @@ const buildSpinnerProgressHandle = (text) => {
|
|
|
14489
15348
|
* construction and post-scan rendering — layer wiring is its own
|
|
14490
15349
|
* concern with its own contract.
|
|
14491
15350
|
*
|
|
14492
|
-
* Same shape as
|
|
14493
|
-
*
|
|
14494
|
-
* differences specific to the CLI path:
|
|
15351
|
+
* Same service shape as `@react-doctor/api → diagnose()`'s
|
|
15352
|
+
* `buildDiagnoseLayer`, with the differences specific to the CLI path:
|
|
14495
15353
|
*
|
|
14496
15354
|
* - **Config**: when the caller passes `configOverride`, the
|
|
14497
15355
|
* already-loaded config is provided via `Config.layerOf` instead
|
|
@@ -14521,44 +15379,89 @@ const buildRuntimeLayers = (input) => {
|
|
|
14521
15379
|
return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
|
|
14522
15380
|
};
|
|
14523
15381
|
//#endregion
|
|
14524
|
-
//#region src/cli/utils/
|
|
15382
|
+
//#region src/cli/utils/active-run-trace.ts
|
|
15383
|
+
let activeRunTrace = null;
|
|
15384
|
+
const setActiveRunTrace = (trace) => {
|
|
15385
|
+
activeRunTrace = trace;
|
|
15386
|
+
};
|
|
15387
|
+
const getActiveRunTrace = () => activeRunTrace;
|
|
15388
|
+
//#endregion
|
|
15389
|
+
//#region src/cli/utils/to-span-attributes.ts
|
|
14525
15390
|
/**
|
|
14526
|
-
*
|
|
15391
|
+
* Converts a Sentry tag map (which permits `null` to denote an absent signal)
|
|
15392
|
+
* into Sentry/OTel span attributes, which only accept primitives. `null` values
|
|
15393
|
+
* are dropped rather than coerced, so an absent signal doesn't become a
|
|
15394
|
+
* misleading `"null"` attribute.
|
|
15395
|
+
*/
|
|
15396
|
+
const toSpanAttributes = (tags) => {
|
|
15397
|
+
const attributes = {};
|
|
15398
|
+
for (const [key, value] of Object.entries(tags)) if (value !== null) attributes[key] = value;
|
|
15399
|
+
return attributes;
|
|
15400
|
+
};
|
|
15401
|
+
//#endregion
|
|
15402
|
+
//#region src/cli/utils/with-sentry-run-span.ts
|
|
15403
|
+
/**
|
|
15404
|
+
* Clears the module-level run-scoped Sentry state — the current scanned project
|
|
15405
|
+
* and the active run trace. `inspect()` calls this at the start of every run and
|
|
15406
|
+
* again after a clean one (it's invoked once per project in a workspace scan),
|
|
15407
|
+
* so a prior or just-finished scan can't attach its project tags / trace to a
|
|
15408
|
+
* later run or to a non-scan error (e.g. inspectAction's post-loop
|
|
15409
|
+
* finalize/handoff steps). A thrown scan error skips the post-run reset, leaving
|
|
15410
|
+
* the state for the command catch to attribute and link the crash. Safe to call
|
|
15411
|
+
* when Sentry is off (the refs are read only when an event is built).
|
|
15412
|
+
*/
|
|
15413
|
+
const resetSentryRunState = () => {
|
|
15414
|
+
setSentryProjectInfo(null);
|
|
15415
|
+
setActiveRunTrace(null);
|
|
15416
|
+
};
|
|
15417
|
+
/**
|
|
15418
|
+
* Runs an inspect invocation inside a Sentry root span (transaction) so each
|
|
15419
|
+
* `react-doctor` run is a first-class trace with timing and the run snapshot as
|
|
15420
|
+
* attributes. The span is handed to `run` so the Effect→Sentry tracer bridge
|
|
15421
|
+
* can parent its spans under it.
|
|
14527
15422
|
*
|
|
14528
|
-
*
|
|
14529
|
-
*
|
|
14530
|
-
* `
|
|
14531
|
-
* `globalThis.console` so imperative legacy callsites that aren't
|
|
14532
|
-
* Effect-typed also fall silent). Sourcing both from a single concrete
|
|
14533
|
-
* object keeps "what is a no-op console" answered in one place; the
|
|
14534
|
-
* earlier `new Proxy({} as Console.Console, { get: () => () => undefined })`
|
|
14535
|
-
* combined a cast with a Proxy to do the same thing implicitly.
|
|
15423
|
+
* A no-op pass-through when Sentry performance tracing is off (Sentry disabled,
|
|
15424
|
+
* `--no-score`, tests, or `SENTRY_TRACES_SAMPLE_RATE=0`) — `run` receives
|
|
15425
|
+
* `undefined` and no transaction is created, so there's no added exit latency.
|
|
14536
15426
|
*
|
|
14537
|
-
*
|
|
14538
|
-
* `
|
|
14539
|
-
*
|
|
15427
|
+
* While the span runs, its trace context is recorded as the active run trace so
|
|
15428
|
+
* `reportErrorToSentry` can attach a crash thrown during the scan back to this
|
|
15429
|
+
* transaction's trace (errors surface in the command catch, after the span has
|
|
15430
|
+
* ended). `inspect()` owns clearing it (and the scanned project): it resets the
|
|
15431
|
+
* state right after a clean run and at the start of the next one, so the trace
|
|
15432
|
+
* is never attached to a non-scan error; on a thrown error the state is left in
|
|
15433
|
+
* place for the command catch, then the process exits.
|
|
14540
15434
|
*/
|
|
14541
|
-
const
|
|
14542
|
-
|
|
14543
|
-
|
|
14544
|
-
|
|
14545
|
-
|
|
14546
|
-
|
|
14547
|
-
|
|
14548
|
-
|
|
14549
|
-
|
|
14550
|
-
|
|
14551
|
-
|
|
14552
|
-
|
|
14553
|
-
|
|
14554
|
-
|
|
14555
|
-
|
|
14556
|
-
|
|
14557
|
-
|
|
14558
|
-
|
|
14559
|
-
|
|
14560
|
-
|
|
14561
|
-
|
|
15435
|
+
const withSentryRunSpan = (run) => {
|
|
15436
|
+
if (!isSentryTracingEnabled()) return run(void 0);
|
|
15437
|
+
const { tags } = buildSentryScope();
|
|
15438
|
+
const command = typeof tags.command === "string" ? tags.command : "inspect";
|
|
15439
|
+
return Sentry.startSpan({
|
|
15440
|
+
name: `react-doctor ${command}`,
|
|
15441
|
+
op: "cli.inspect",
|
|
15442
|
+
attributes: toSpanAttributes(tags)
|
|
15443
|
+
}, (rootSpan) => {
|
|
15444
|
+
const spanContext = rootSpan.spanContext();
|
|
15445
|
+
setActiveRunTrace({
|
|
15446
|
+
traceId: spanContext.traceId,
|
|
15447
|
+
spanId: spanContext.spanId,
|
|
15448
|
+
sampled: (spanContext.traceFlags & 1) === 1
|
|
15449
|
+
});
|
|
15450
|
+
return run(rootSpan);
|
|
15451
|
+
});
|
|
15452
|
+
};
|
|
15453
|
+
/**
|
|
15454
|
+
* Records the scanned project (discovered in the `beforeLint` hook) for Sentry:
|
|
15455
|
+
* remembers it for the lazy error-capture path (`buildSentryScope` folds it into
|
|
15456
|
+
* exception events) and, when tracing is live, sets it as attributes on the
|
|
15457
|
+
* run's root span so the transaction/trace carries the project shape too.
|
|
15458
|
+
* Always cheap — the span attribute set is skipped when `rootSpan` is absent
|
|
15459
|
+
* (tracing off), and storing the info is a plain assignment.
|
|
15460
|
+
*/
|
|
15461
|
+
const recordSentryProjectContext = (projectInfo, rootSpan) => {
|
|
15462
|
+
setSentryProjectInfo(projectInfo);
|
|
15463
|
+
rootSpan?.setAttributes(toSpanAttributes(buildSentryProjectContext(projectInfo).tags));
|
|
15464
|
+
};
|
|
14562
15465
|
//#endregion
|
|
14563
15466
|
//#region src/cli/utils/build-no-score-message.ts
|
|
14564
15467
|
const ENTERPRISE_CONTACT_HINT = `Want something custom to your company? Contact us at ${ENTERPRISE_CONTACT_URL}.`;
|
|
@@ -14605,8 +15508,10 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
|
|
|
14605
15508
|
return priorityB - priorityA;
|
|
14606
15509
|
};
|
|
14607
15510
|
const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
|
|
14608
|
-
const
|
|
14609
|
-
const
|
|
15511
|
+
const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
|
|
15512
|
+
const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
|
|
15513
|
+
const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
|
|
15514
|
+
const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
|
|
14610
15515
|
//#endregion
|
|
14611
15516
|
//#region src/cli/utils/box-text.ts
|
|
14612
15517
|
const ESCAPE = String.fromCharCode(27);
|
|
@@ -14737,15 +15642,17 @@ const buildVerboseSiteMap = (diagnostics) => {
|
|
|
14737
15642
|
return fileSites;
|
|
14738
15643
|
};
|
|
14739
15644
|
const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
|
|
15645
|
+
const formatTrailingSiteBadge = (count) => {
|
|
15646
|
+
const badge = formatSiteCountBadge(count);
|
|
15647
|
+
return badge.length > 0 ? ` ${highlighter.gray(badge)}` : "";
|
|
15648
|
+
};
|
|
14740
15649
|
const categoryTopRuleKey = (categoryGroup) => categoryGroup.ruleGroups[0][0];
|
|
14741
15650
|
const buildCategoryDiagnosticGroups = (diagnostics, rulePriority) => {
|
|
14742
|
-
return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
|
|
14743
|
-
|
|
14744
|
-
|
|
14745
|
-
|
|
14746
|
-
|
|
14747
|
-
};
|
|
14748
|
-
}).toSorted((categoryGroupA, categoryGroupB) => {
|
|
15651
|
+
return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => ({
|
|
15652
|
+
category,
|
|
15653
|
+
diagnostics: categoryDiagnostics,
|
|
15654
|
+
ruleGroups: buildSortedRuleGroups(categoryDiagnostics, rulePriority)
|
|
15655
|
+
})).toSorted((categoryGroupA, categoryGroupB) => {
|
|
14749
15656
|
const priorityDelta = compareByRulePriority(categoryTopRuleKey(categoryGroupA), categoryTopRuleKey(categoryGroupB), rulePriority);
|
|
14750
15657
|
if (priorityDelta !== 0) return priorityDelta;
|
|
14751
15658
|
return categoryGroupA.category.localeCompare(categoryGroupB.category);
|
|
@@ -14761,6 +15668,7 @@ const buildCompactCategoryLine = (categoryGroup) => {
|
|
|
14761
15668
|
};
|
|
14762
15669
|
const TOP_ERROR_DETAIL_INDENT = " ";
|
|
14763
15670
|
const pickRepresentativeDiagnostic = (ruleDiagnostics) => ruleDiagnostics.find((diagnostic) => diagnostic.line > 0) ?? ruleDiagnostics[0];
|
|
15671
|
+
const isErrorRuleGroup = (ruleDiagnostics) => pickRepresentativeDiagnostic(ruleDiagnostics).severity === "error";
|
|
14764
15672
|
const FRAME_CONTEXT_REACH_LINES = 3;
|
|
14765
15673
|
const clusterNearbyDiagnostics = (diagnostics) => {
|
|
14766
15674
|
const byFile = groupBy(diagnostics, (diagnostic) => diagnostic.filePath);
|
|
@@ -14792,17 +15700,17 @@ const formatClusterLocation = (cluster) => {
|
|
|
14792
15700
|
if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
|
|
14793
15701
|
return `${filePath}:${cluster.startLine}`;
|
|
14794
15702
|
};
|
|
14795
|
-
const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
|
|
15703
|
+
const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
|
|
14796
15704
|
const lead = cluster.diagnostics[0];
|
|
14797
15705
|
const isMultiSite = cluster.diagnostics.length > 1;
|
|
14798
15706
|
const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
|
|
14799
|
-
const codeFrame = buildCodeFrame({
|
|
15707
|
+
const codeFrame = renderCodeFrame ? buildCodeFrame({
|
|
14800
15708
|
filePath: lead.filePath,
|
|
14801
15709
|
line: cluster.startLine,
|
|
14802
15710
|
column: isMultiSite ? 0 : lead.column,
|
|
14803
15711
|
endLine: isMultiSite ? cluster.endLine : void 0,
|
|
14804
15712
|
rootDirectory: resolveSourceRoot(lead)
|
|
14805
|
-
});
|
|
15713
|
+
}) : null;
|
|
14806
15714
|
if (codeFrame) lines.push(indentMultilineText(boxText(codeFrame, 60), TOP_ERROR_DETAIL_INDENT));
|
|
14807
15715
|
const seenHints = /* @__PURE__ */ new Set();
|
|
14808
15716
|
for (const diagnostic of cluster.diagnostics) if (diagnostic.suppressionHint && !seenHints.has(diagnostic.suppressionHint)) {
|
|
@@ -14814,23 +15722,60 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
|
|
|
14814
15722
|
const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite) => {
|
|
14815
15723
|
const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
|
|
14816
15724
|
const { severity } = representative;
|
|
14817
|
-
const
|
|
14818
|
-
const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
|
|
15725
|
+
const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
|
|
14819
15726
|
const headline = colorizeBySeverity(`${representative.category}: ${representative.title ?? ruleKey}`, severity);
|
|
14820
15727
|
const lines = [` ${colorizeBySeverity(severity === "error" ? "✗" : "⚠", severity)} ${headline}${trailingBadge}`];
|
|
14821
15728
|
if (!renderEverySite) for (const explanationLine of wrapTextToWidth(representative.message, 60, { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
|
|
14822
15729
|
if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, 60, { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
|
|
15730
|
+
const renderCodeFrame = severity === "error";
|
|
14823
15731
|
const sites = renderEverySite ? ruleDiagnostics : [representative];
|
|
14824
|
-
for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot));
|
|
15732
|
+
for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
|
|
15733
|
+
return lines;
|
|
15734
|
+
};
|
|
15735
|
+
const WARNING_DETAIL_INDENT = " ";
|
|
15736
|
+
const computeRuleNameColumnWidth = (ruleKeys) => ruleKeys.reduce((widest, ruleKey) => Math.max(widest, ruleKey.length), 36);
|
|
15737
|
+
const padRuleNameToColumn = (ruleName, columnWidth) => ruleName.length >= columnWidth ? ruleName : ruleName + " ".repeat(columnWidth - ruleName.length);
|
|
15738
|
+
const buildWarningHeaderLine = (ruleKey, siteCount, ruleNameColumnWidth) => {
|
|
15739
|
+
const ruleName = formatSiteCountBadge(siteCount).length > 0 ? padRuleNameToColumn(ruleKey, ruleNameColumnWidth) : ruleKey;
|
|
15740
|
+
return ` ${highlighter.warn("⚠")} ${ruleName}${formatTrailingSiteBadge(siteCount)}`;
|
|
15741
|
+
};
|
|
15742
|
+
const buildWarningRuleBlock = (ruleKey, ruleDiagnostics, ruleNameColumnWidth, isAgentEnvironment) => {
|
|
15743
|
+
const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
|
|
15744
|
+
const lines = [buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth)];
|
|
15745
|
+
if (!isAgentEnvironment) {
|
|
15746
|
+
const learnMoreLine = formatLearnMoreLine(representative);
|
|
15747
|
+
if (learnMoreLine) lines.push(`${WARNING_DETAIL_INDENT}${highlighter.info(learnMoreLine)}`);
|
|
15748
|
+
}
|
|
15749
|
+
lines.push(highlighter.gray(indentMultilineText(representative.message, WARNING_DETAIL_INDENT)));
|
|
15750
|
+
if (representative.help) lines.push(highlighter.gray(indentMultilineText(`→ ${representative.help}`, WARNING_DETAIL_INDENT)));
|
|
15751
|
+
if (isAgentEnvironment) {
|
|
15752
|
+
const fixRecipeLine = formatFixRecipeLine(representative);
|
|
15753
|
+
if (fixRecipeLine) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${fixRecipeLine}`));
|
|
15754
|
+
}
|
|
15755
|
+
for (const [filePath, sites] of buildVerboseSiteMap(ruleDiagnostics)) {
|
|
15756
|
+
if (sites.length === 0) {
|
|
15757
|
+
lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}`));
|
|
15758
|
+
continue;
|
|
15759
|
+
}
|
|
15760
|
+
for (const site of sites) {
|
|
15761
|
+
lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}:${site.line}`));
|
|
15762
|
+
if (site.suppressionHint) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT} ↳ ${site.suppressionHint}`));
|
|
15763
|
+
}
|
|
15764
|
+
}
|
|
14825
15765
|
return lines;
|
|
14826
15766
|
};
|
|
14827
|
-
const
|
|
14828
|
-
|
|
15767
|
+
const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
|
|
15768
|
+
const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => selectErrorRuleGroups(diagnostics, rulePriority).slice(0, limit);
|
|
15769
|
+
const buildMoreRulesLine = (hiddenRuleCount, severityNoun, accent) => {
|
|
15770
|
+
const ruleNoun = hiddenRuleCount === 1 ? "rule" : "rules";
|
|
15771
|
+
return ` ${highlighter.bold(accent(`+${hiddenRuleCount} more ${ruleNoun}`))} ${highlighter.dim("— run")} ${highlighter.bold(highlighter.info("--verbose"))} ${highlighter.dim(`to view the rest of the ${severityNoun} and details about each`)}`;
|
|
14829
15772
|
};
|
|
14830
15773
|
const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
|
|
14831
15774
|
const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
|
|
14832
|
-
const
|
|
15775
|
+
const errorRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority);
|
|
15776
|
+
const topRuleGroups = errorRuleGroups.slice(0, 3);
|
|
14833
15777
|
if (topRuleGroups.length === 0) return [];
|
|
15778
|
+
const hiddenRuleCount = errorRuleGroups.length - topRuleGroups.length;
|
|
14834
15779
|
const lines = [
|
|
14835
15780
|
highlighter.dim(` ${"─".repeat(60)}`),
|
|
14836
15781
|
` ${highlighter.bold(`Top ${topRuleGroups.length} ${topRuleGroups.length === 1 ? "error" : "errors"} you should fix`)}`,
|
|
@@ -14840,6 +15785,23 @@ const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
|
|
|
14840
15785
|
lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false));
|
|
14841
15786
|
lines.push("");
|
|
14842
15787
|
}
|
|
15788
|
+
if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "errors", highlighter.error));
|
|
15789
|
+
return lines;
|
|
15790
|
+
};
|
|
15791
|
+
const buildWarningsListLines = (diagnostics, rulePriority) => {
|
|
15792
|
+
const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
|
|
15793
|
+
if (warningDiagnostics.length === 0) return [];
|
|
15794
|
+
const sortedRuleGroups = buildSortedRuleGroups(warningDiagnostics, rulePriority);
|
|
15795
|
+
const shownRuleGroups = sortedRuleGroups.slice(0, 10);
|
|
15796
|
+
const hiddenRuleCount = sortedRuleGroups.length - shownRuleGroups.length;
|
|
15797
|
+
const ruleNameColumnWidth = computeRuleNameColumnWidth(shownRuleGroups.map(([ruleKey]) => ruleKey));
|
|
15798
|
+
const lines = [
|
|
15799
|
+
highlighter.dim(` ${"─".repeat(60)}`),
|
|
15800
|
+
` ${highlighter.bold(`${warningDiagnostics.length} ${warningDiagnostics.length === 1 ? "warning" : "warnings"}`)}`,
|
|
15801
|
+
""
|
|
15802
|
+
];
|
|
15803
|
+
for (const [ruleKey, ruleDiagnostics] of shownRuleGroups) lines.push(buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth));
|
|
15804
|
+
if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "warnings", highlighter.warn));
|
|
14843
15805
|
return lines;
|
|
14844
15806
|
};
|
|
14845
15807
|
const buildCategoryBreakdownLines = (diagnostics, rulePriority) => buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCompactCategoryLine);
|
|
@@ -14866,12 +15828,18 @@ const buildCountsSummaryLines = (diagnostics) => {
|
|
|
14866
15828
|
* single Effect.forEach over Console.log so failures or fiber
|
|
14867
15829
|
* interruption produce predictable partial output.
|
|
14868
15830
|
*/
|
|
14869
|
-
const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority) => Effect.gen(function* () {
|
|
15831
|
+
const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false) => Effect.gen(function* () {
|
|
14870
15832
|
const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
|
|
14871
15833
|
let detailLines;
|
|
14872
15834
|
if (!isVerbose) detailLines = buildTopErrorsLines(diagnostics, resolveSourceRoot, rulePriority);
|
|
14873
|
-
else
|
|
14874
|
-
|
|
15835
|
+
else {
|
|
15836
|
+
const sortedRuleGroups = buildSortedRuleGroups(diagnostics, rulePriority);
|
|
15837
|
+
const warningRuleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.filter(([, ruleDiagnostics]) => !isErrorRuleGroup(ruleDiagnostics)).map(([ruleKey]) => ruleKey));
|
|
15838
|
+
detailLines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => {
|
|
15839
|
+
return [...isErrorRuleGroup(ruleDiagnostics) ? buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true) : buildWarningRuleBlock(ruleKey, ruleDiagnostics, warningRuleNameColumnWidth, isAgentEnvironment), ""];
|
|
15840
|
+
});
|
|
15841
|
+
}
|
|
15842
|
+
const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines, isVerbose ? [] : buildWarningsListLines(diagnostics, rulePriority));
|
|
14875
15843
|
for (const line of lines) yield* Console.log(line);
|
|
14876
15844
|
});
|
|
14877
15845
|
const formatElapsedTime = (elapsedMilliseconds) => {
|
|
@@ -14921,10 +15889,6 @@ const colorizeByScore = (text, score) => {
|
|
|
14921
15889
|
return highlighter.error(text);
|
|
14922
15890
|
};
|
|
14923
15891
|
//#endregion
|
|
14924
|
-
//#region src/cli/utils/constants.ts
|
|
14925
|
-
const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
|
|
14926
|
-
const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
|
|
14927
|
-
//#endregion
|
|
14928
15892
|
//#region src/cli/utils/render-score-header.ts
|
|
14929
15893
|
const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
|
|
14930
15894
|
const RAINBOW_GRADIENT_WIDTH = 80;
|
|
@@ -15117,8 +16081,7 @@ const printNoScoreHeader = (noScoreMessage) => Effect.gen(function* () {
|
|
|
15117
16081
|
const writeDiagnosticsDirectory = (diagnostics) => {
|
|
15118
16082
|
const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
|
|
15119
16083
|
mkdirSync(outputDirectory, { recursive: true });
|
|
15120
|
-
const
|
|
15121
|
-
for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
|
|
16084
|
+
for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(diagnostics)) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
|
|
15122
16085
|
writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
|
|
15123
16086
|
return outputDirectory;
|
|
15124
16087
|
};
|
|
@@ -15138,7 +16101,14 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
|
|
|
15138
16101
|
};
|
|
15139
16102
|
const printVerboseTip = (diagnostics, isVerbose) => Effect.gen(function* () {
|
|
15140
16103
|
if (isVerbose || diagnostics.length === 0) return;
|
|
15141
|
-
|
|
16104
|
+
const command = highlighter.info("npx react-doctor@latest --verbose");
|
|
16105
|
+
const message = diagnostics.some((diagnostic) => diagnostic.severity === "warning") ? `Run ${command} to see each warning explained with its fix` : `Run ${command} to see each issue explained with its fix`;
|
|
16106
|
+
yield* Console.log(highlighter.dim(` Tip: ${message}`));
|
|
16107
|
+
});
|
|
16108
|
+
const printDocsNote = () => Effect.gen(function* () {
|
|
16109
|
+
yield* Console.log("");
|
|
16110
|
+
yield* Console.log(` ${highlighter.bold("Docs:")} ${highlighter.info(DOCS_URL)}`);
|
|
16111
|
+
yield* Console.log(highlighter.dim(" Set up CI/CD, suppress rules with a config file, and scan diffs or PRs."));
|
|
15142
16112
|
});
|
|
15143
16113
|
const printSummary = (input) => Effect.gen(function* () {
|
|
15144
16114
|
if (input.scoreResult) {
|
|
@@ -15309,9 +16279,6 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
|
|
|
15309
16279
|
});
|
|
15310
16280
|
const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
|
|
15311
16281
|
//#endregion
|
|
15312
|
-
//#region src/cli/utils/version.ts
|
|
15313
|
-
const VERSION = "0.2.14-dev.bb15252";
|
|
15314
|
-
//#endregion
|
|
15315
16282
|
//#region src/inspect.ts
|
|
15316
16283
|
const silentConsole = makeNoopConsole();
|
|
15317
16284
|
const runConsole = (effect) => {
|
|
@@ -15336,7 +16303,7 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
15336
16303
|
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
15337
16304
|
share: userConfig?.share ?? true,
|
|
15338
16305
|
respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
|
|
15339
|
-
warnings: inputOptions.warnings ?? userConfig?.warnings ??
|
|
16306
|
+
warnings: inputOptions.warnings ?? userConfig?.warnings ?? true,
|
|
15340
16307
|
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
15341
16308
|
ignoredTags: buildIgnoredTags(userConfig),
|
|
15342
16309
|
outputSurface: inputOptions.outputSurface ?? "cli",
|
|
@@ -15345,6 +16312,7 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
15345
16312
|
});
|
|
15346
16313
|
const inspect = async (directory, inputOptions = {}) => {
|
|
15347
16314
|
const startTime = performance.now();
|
|
16315
|
+
resetSentryRunState();
|
|
15348
16316
|
const hasConfigOverride = inputOptions.configOverride !== void 0;
|
|
15349
16317
|
let scanDirectory;
|
|
15350
16318
|
let userConfig;
|
|
@@ -15354,7 +16322,7 @@ const inspect = async (directory, inputOptions = {}) => {
|
|
|
15354
16322
|
userConfig = inputOptions.configOverride ?? null;
|
|
15355
16323
|
configSourceDirectory = null;
|
|
15356
16324
|
} else {
|
|
15357
|
-
const scanTarget = resolveScanTarget(directory);
|
|
16325
|
+
const scanTarget = await resolveScanTarget(directory);
|
|
15358
16326
|
scanDirectory = scanTarget.resolvedDirectory;
|
|
15359
16327
|
userConfig = scanTarget.userConfig;
|
|
15360
16328
|
configSourceDirectory = scanTarget.configSourceDirectory;
|
|
@@ -15363,12 +16331,14 @@ const inspect = async (directory, inputOptions = {}) => {
|
|
|
15363
16331
|
const wasSpinnerSilent = isSpinnerSilent();
|
|
15364
16332
|
if (options.silent) setSpinnerSilent(true);
|
|
15365
16333
|
try {
|
|
15366
|
-
|
|
16334
|
+
const result = await withSentryRunSpan((rootSentrySpan) => runInspectWithRuntime(scanDirectory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, rootSentrySpan));
|
|
16335
|
+
resetSentryRunState();
|
|
16336
|
+
return result;
|
|
15367
16337
|
} finally {
|
|
15368
16338
|
if (options.silent) setSpinnerSilent(wasSpinnerSilent);
|
|
15369
16339
|
}
|
|
15370
16340
|
};
|
|
15371
|
-
const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime) => {
|
|
16341
|
+
const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, rootSentrySpan) => {
|
|
15372
16342
|
const isDiffMode = options.includePaths.length > 0;
|
|
15373
16343
|
const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
|
|
15374
16344
|
const lintBindingMissing = options.lint && !resolvedNodeBinaryPath;
|
|
@@ -15399,6 +16369,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
15399
16369
|
resolveLocalGithubViewerPermission: !options.noScore,
|
|
15400
16370
|
suppressScanSummary: options.suppressRendering
|
|
15401
16371
|
}, { beforeLint: (projectInfo, lintIncludePaths) => Effect.gen(function* () {
|
|
16372
|
+
recordSentryProjectContext(projectInfo, rootSentrySpan);
|
|
15402
16373
|
if (options.scoreOnly || options.suppressRendering) return;
|
|
15403
16374
|
const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
|
|
15404
16375
|
yield* printProjectDetection({
|
|
@@ -15409,7 +16380,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
15409
16380
|
lintSourceFileCount
|
|
15410
16381
|
});
|
|
15411
16382
|
}) });
|
|
15412
|
-
const programWithLayers = options.silent ? program.pipe(Effect.provide(layers), Effect.provideService(Console.Console, silentConsole)
|
|
16383
|
+
const programWithLayers = applyObservability(options.silent ? program.pipe(Effect.provide(layers), Effect.provideService(Console.Console, silentConsole)) : program.pipe(Effect.provide(layers)), rootSentrySpan);
|
|
15413
16384
|
const output = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
|
|
15414
16385
|
const didLintFail = lintBindingMissing || output.didLintFail;
|
|
15415
16386
|
const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
|
|
@@ -15438,15 +16409,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
15438
16409
|
};
|
|
15439
16410
|
const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
15440
16411
|
const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
|
|
15441
|
-
const skippedChecks =
|
|
15442
|
-
|
|
15443
|
-
|
|
16412
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
|
|
16413
|
+
didLintFail,
|
|
16414
|
+
lintFailureReason,
|
|
16415
|
+
lintPartialFailures,
|
|
16416
|
+
didDeadCodeFail,
|
|
16417
|
+
deadCodeFailureReason
|
|
16418
|
+
});
|
|
15444
16419
|
const hasSkippedChecks = skippedChecks.length > 0;
|
|
15445
16420
|
const noScoreMessage = buildNoScoreMessage(options.noScore);
|
|
15446
|
-
const skippedCheckReasons = {};
|
|
15447
|
-
if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
|
|
15448
|
-
else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
|
|
15449
|
-
if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
|
|
15450
16421
|
const buildResult = () => ({
|
|
15451
16422
|
diagnostics: [...diagnostics],
|
|
15452
16423
|
score,
|
|
@@ -15482,7 +16453,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
15482
16453
|
return buildResult();
|
|
15483
16454
|
}
|
|
15484
16455
|
yield* Console.log("");
|
|
15485
|
-
yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]));
|
|
16456
|
+
yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment());
|
|
15486
16457
|
if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
|
|
15487
16458
|
if (demotedDiagnosticCount > 0) {
|
|
15488
16459
|
yield* Console.log(highlighter.gray(` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`));
|
|
@@ -15507,6 +16478,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
15507
16478
|
yield* Console.warn(highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`));
|
|
15508
16479
|
}
|
|
15509
16480
|
yield* printVerboseTip([...surfaceDiagnostics], options.verbose);
|
|
16481
|
+
yield* printDocsNote();
|
|
15510
16482
|
return buildResult();
|
|
15511
16483
|
});
|
|
15512
16484
|
//#endregion
|
|
@@ -15551,7 +16523,7 @@ const getErrorReportContext = () => ({
|
|
|
15551
16523
|
isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE])
|
|
15552
16524
|
});
|
|
15553
16525
|
const formatConfiguredState = (isConfigured) => isConfigured ? "yes" : "no";
|
|
15554
|
-
const buildErrorIssueBody = (error, context) => {
|
|
16526
|
+
const buildErrorIssueBody = (error, context, sentryEventId) => {
|
|
15555
16527
|
const formattedError = formatErrorForReport(error) || "(empty error)";
|
|
15556
16528
|
const isOtlpExporterEnabled = context.isOtlpEndpointConfigured && context.isOtlpAuthHeaderConfigured;
|
|
15557
16529
|
return [
|
|
@@ -15568,6 +16540,7 @@ const buildErrorIssueBody = (error, context) => {
|
|
|
15568
16540
|
`- platform: ${context.platform} ${context.architecture}`,
|
|
15569
16541
|
`- cwd: ${context.cwd}`,
|
|
15570
16542
|
`- command: ${context.command}`,
|
|
16543
|
+
...sentryEventId ? [`- Sentry reference: ${sentryEventId}`] : [],
|
|
15571
16544
|
"",
|
|
15572
16545
|
"## OpenTelemetry",
|
|
15573
16546
|
"",
|
|
@@ -15581,12 +16554,12 @@ const buildErrorIssueBody = (error, context) => {
|
|
|
15581
16554
|
"Please add reproduction steps and any relevant repository details."
|
|
15582
16555
|
].join("\n");
|
|
15583
16556
|
};
|
|
15584
|
-
const buildErrorIssueUrl = (error) => {
|
|
16557
|
+
const buildErrorIssueUrl = (error, sentryEventId) => {
|
|
15585
16558
|
const formattedError = formatSingleLine(formatErrorForReport(error));
|
|
15586
16559
|
const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
|
|
15587
16560
|
issueUrl.searchParams.set("title", formattedError ? `CLI error: ${formattedError}` : "CLI error");
|
|
15588
16561
|
issueUrl.searchParams.set("labels", "bug");
|
|
15589
|
-
issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext()));
|
|
16562
|
+
issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext(), sentryEventId));
|
|
15590
16563
|
return issueUrl.toString();
|
|
15591
16564
|
};
|
|
15592
16565
|
/**
|
|
@@ -15596,10 +16569,12 @@ const buildErrorIssueUrl = (error) => {
|
|
|
15596
16569
|
* red-highlighted (matches the historical `consoleLogger.error`
|
|
15597
16570
|
* contract) so the user sees a clearly distinguished error block.
|
|
15598
16571
|
*/
|
|
15599
|
-
const handleErrorEffect = (error) => Effect.gen(function* () {
|
|
16572
|
+
const handleErrorEffect = (error, sentryEventId) => Effect.gen(function* () {
|
|
15600
16573
|
yield* Console.error("");
|
|
15601
16574
|
yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
|
|
15602
|
-
yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
|
|
16575
|
+
yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error, sentryEventId)}`));
|
|
16576
|
+
yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
|
|
16577
|
+
if (sentryEventId) yield* Console.error(highlighter.error(`Reference (mention this when reporting): ${sentryEventId}`));
|
|
15603
16578
|
yield* Console.error("");
|
|
15604
16579
|
yield* Console.error(highlighter.error(formatErrorForReport(error)));
|
|
15605
16580
|
yield* Console.error("");
|
|
@@ -15609,15 +16584,15 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
|
|
|
15609
16584
|
* aren't yet Effect-typed). Bridges via `Effect.runSync` so the
|
|
15610
16585
|
* underlying Console writes happen exactly like the Effect path.
|
|
15611
16586
|
*/
|
|
15612
|
-
const handleError = (error, options = {
|
|
15613
|
-
Effect.runSync(handleErrorEffect(error));
|
|
16587
|
+
const handleError = (error, options = {}) => {
|
|
16588
|
+
Effect.runSync(handleErrorEffect(error, options.sentryEventId));
|
|
15614
16589
|
if (options.shouldExit !== false) process.exit(1);
|
|
15615
16590
|
process.exitCode = 1;
|
|
15616
16591
|
};
|
|
15617
16592
|
//#endregion
|
|
15618
16593
|
//#region src/cli/utils/build-handoff-payload.ts
|
|
15619
16594
|
const buildHandoffPayload = (input) => {
|
|
15620
|
-
const topGroups =
|
|
16595
|
+
const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
|
|
15621
16596
|
let diagnosticsDirectory = null;
|
|
15622
16597
|
try {
|
|
15623
16598
|
diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
|
|
@@ -15860,7 +16835,7 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
|
|
|
15860
16835
|
const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
|
|
15861
16836
|
const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
|
|
15862
16837
|
const CURSOR_HOOKS_SCHEMA_VERSION = 1;
|
|
15863
|
-
const JSON_INDENT_SPACES = 2;
|
|
16838
|
+
const JSON_INDENT_SPACES$1 = 2;
|
|
15864
16839
|
const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
|
|
15865
16840
|
const readJsonFile = (filePath, fallback) => {
|
|
15866
16841
|
if (!existsSync(filePath)) return fallback;
|
|
@@ -15870,7 +16845,7 @@ const readJsonFile = (filePath, fallback) => {
|
|
|
15870
16845
|
};
|
|
15871
16846
|
const writeJsonFile = (filePath, value) => {
|
|
15872
16847
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
15873
|
-
writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES)}\n`);
|
|
16848
|
+
writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
|
|
15874
16849
|
};
|
|
15875
16850
|
const writeHookScript = (filePath) => {
|
|
15876
16851
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -16636,6 +17611,29 @@ const getSkillSourceDirectory = () => {
|
|
|
16636
17611
|
const distDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
16637
17612
|
return path.join(distDirectory, "skills", SKILL_NAME);
|
|
16638
17613
|
};
|
|
17614
|
+
const findBundledSiblingSkills = (primarySkillDir) => {
|
|
17615
|
+
const skillsParent = path.dirname(primarySkillDir);
|
|
17616
|
+
if (!existsSync(skillsParent)) return [];
|
|
17617
|
+
const resolvedPrimary = path.resolve(primarySkillDir);
|
|
17618
|
+
return readdirSync(skillsParent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => ({
|
|
17619
|
+
name: entry.name,
|
|
17620
|
+
source: path.join(skillsParent, entry.name)
|
|
17621
|
+
})).filter((sibling) => path.resolve(sibling.source) !== resolvedPrimary && existsSync(path.join(sibling.source, SKILL_MANIFEST_FILE)));
|
|
17622
|
+
};
|
|
17623
|
+
const installBundledSiblingSkills = async (primarySkillDir, agents, projectRoot) => {
|
|
17624
|
+
const installedSkillNames = [];
|
|
17625
|
+
for (const sibling of findBundledSiblingSkills(primarySkillDir)) {
|
|
17626
|
+
const result = await installSkillsFromSource({
|
|
17627
|
+
source: sibling.source,
|
|
17628
|
+
agents: [...agents],
|
|
17629
|
+
cwd: projectRoot,
|
|
17630
|
+
mode: "copy"
|
|
17631
|
+
});
|
|
17632
|
+
if (result.failed.length > 0) throw new Error(result.failed.map((failure) => `${getSkillAgentConfig(failure.agent).displayName}: ${failure.error}`).join("\n"));
|
|
17633
|
+
if (result.skills.length > 0) installedSkillNames.push(sibling.name);
|
|
17634
|
+
}
|
|
17635
|
+
return installedSkillNames;
|
|
17636
|
+
};
|
|
16639
17637
|
const canInstallNativeAgentHooks = (agents) => agents.some((agent) => agent === "claude-code" || agent === "cursor");
|
|
16640
17638
|
const buildWorkflowContent = () => [
|
|
16641
17639
|
"name: React Doctor",
|
|
@@ -16742,6 +17740,7 @@ const runInstallReactDoctor = async (options = {}) => {
|
|
|
16742
17740
|
cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
|
|
16743
17741
|
for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
|
|
16744
17742
|
cliLogger.dim(` Source: ${sourceDir}`);
|
|
17743
|
+
for (const sibling of findBundledSiblingSkills(sourceDir)) cliLogger.dim(` Also installs skill: ${sibling.name}`);
|
|
16745
17744
|
cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
|
|
16746
17745
|
cliLogger.dim(" Dev dependency: react-doctor");
|
|
16747
17746
|
if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
|
|
@@ -16764,6 +17763,12 @@ const runInstallReactDoctor = async (options = {}) => {
|
|
|
16764
17763
|
installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
|
|
16765
17764
|
throw error;
|
|
16766
17765
|
}
|
|
17766
|
+
try {
|
|
17767
|
+
const installedSiblingSkills = await installBundledSiblingSkills(sourceDir, selectedAgents, projectRoot);
|
|
17768
|
+
if (installedSiblingSkills.length > 0) cliLogger.dim(` Also installed the ${installedSiblingSkills.join(", ")} skill.`);
|
|
17769
|
+
} catch {
|
|
17770
|
+
cliLogger.dim(" Skipped bundled sibling skills (install error).");
|
|
17771
|
+
}
|
|
16767
17772
|
await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
|
|
16768
17773
|
if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
|
|
16769
17774
|
const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
|
|
@@ -16917,107 +17922,100 @@ const handoffToAgent = async (input) => {
|
|
|
16917
17922
|
],
|
|
16918
17923
|
initial: 0
|
|
16919
17924
|
}, { onCancel: () => true });
|
|
16920
|
-
if (handoffTarget === void 0 || handoffTarget === SKIP_CHOICE) return;
|
|
16921
|
-
const payload = buildHandoffPayload({
|
|
16922
|
-
diagnostics: input.diagnostics,
|
|
16923
|
-
projectName: input.projectName
|
|
16924
|
-
});
|
|
16925
|
-
if (handoffTarget === PRINT_CHOICE) {
|
|
16926
|
-
printPayload(payload);
|
|
16927
|
-
return;
|
|
16928
|
-
}
|
|
16929
|
-
if (handoffTarget === CLIPBOARD_CHOICE) {
|
|
16930
|
-
if (await copyToClipboard(payload)) cliLogger.log("Copied the prompt to your clipboard.");
|
|
16931
|
-
else printPayload(payload);
|
|
16932
|
-
return;
|
|
16933
|
-
}
|
|
16934
|
-
const agentId = handoffTarget;
|
|
16935
|
-
const displayName = getSkillAgentConfig(agentId).displayName;
|
|
16936
|
-
const skillSpinner = spinner(`Installing the /react-doctor skill for ${displayName}...`).start();
|
|
16937
|
-
try {
|
|
16938
|
-
if (await installReactDoctorSkillForAgent(agentId, input.rootDirectory)) skillSpinner.succeed(`Installed the /react-doctor skill for ${displayName}.`);
|
|
16939
|
-
else skillSpinner.stop();
|
|
16940
|
-
} catch {
|
|
16941
|
-
skillSpinner.stop();
|
|
16942
|
-
}
|
|
16943
|
-
cliLogger.log(highlighter.dim(`Handing off to ${displayName}...`));
|
|
16944
|
-
try {
|
|
16945
|
-
await launchCliAgent(agentId, payload, input.rootDirectory);
|
|
16946
|
-
} catch {
|
|
16947
|
-
cliLogger.warn(`Couldn't launch ${CLI_AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
|
|
16948
|
-
printPayload(payload);
|
|
16949
|
-
}
|
|
16950
|
-
};
|
|
16951
|
-
//#endregion
|
|
16952
|
-
//#region src/cli/utils/json-mode.ts
|
|
16953
|
-
let context = null;
|
|
16954
|
-
/**
|
|
16955
|
-
* JSON mode writes the report payload to stdout; any incidental log
|
|
16956
|
-
* line printed by an Effect program would corrupt the JSON. Effect's
|
|
16957
|
-
* `Console` module resolves to `globalThis.console` by default (see
|
|
16958
|
-
* `effect/internal/effect.ts` → `ConsoleRef`), so copying the methods
|
|
16959
|
-
* from `makeNoopConsole()` onto the global is enough to silence every
|
|
16960
|
-
* `yield* Console.log(...)` and `cliLogger.*` call sourced from
|
|
16961
|
-
* react-doctor or its services.
|
|
16962
|
-
*
|
|
16963
|
-
* We use the same `makeNoopConsole()` source as the `--silent` path
|
|
16964
|
-
* (which provides the Effect Console via
|
|
16965
|
-
* `Effect.provideService(Console.Console, makeNoopConsole())`) — one
|
|
16966
|
-
* canonical "no-op console" definition shared by the two silent
|
|
16967
|
-
* mechanisms. The two routes still differ in how they install the
|
|
16968
|
-
* noop: silent mode swaps the Effect Console reference inside the
|
|
16969
|
-
* program; JSON mode patches the global because the surrounding CLI
|
|
16970
|
-
* command body is still imperative. Both will collapse into the
|
|
16971
|
-
* Effect-typed route once the command body finishes its migration.
|
|
16972
|
-
*
|
|
16973
|
-
* JSON mode is one-shot per CLI invocation, so we never restore.
|
|
16974
|
-
*/
|
|
16975
|
-
const installSilentConsole = () => {
|
|
16976
|
-
const noopConsole = makeNoopConsole();
|
|
16977
|
-
const target = globalThis.console;
|
|
16978
|
-
const source = noopConsole;
|
|
16979
|
-
for (const key of [
|
|
16980
|
-
"log",
|
|
16981
|
-
"error",
|
|
16982
|
-
"warn",
|
|
16983
|
-
"info",
|
|
16984
|
-
"debug",
|
|
16985
|
-
"trace"
|
|
16986
|
-
]) target[key] = source[key];
|
|
16987
|
-
};
|
|
16988
|
-
const enableJsonMode = ({ compact, directory }) => {
|
|
16989
|
-
context = {
|
|
16990
|
-
compact,
|
|
16991
|
-
directory,
|
|
16992
|
-
startTime: performance.now(),
|
|
16993
|
-
mode: "full"
|
|
16994
|
-
};
|
|
16995
|
-
installSilentConsole();
|
|
16996
|
-
};
|
|
16997
|
-
const isJsonModeActive = () => context !== null;
|
|
16998
|
-
const setJsonReportDirectory = (directory) => {
|
|
16999
|
-
if (context) context.directory = directory;
|
|
17000
|
-
};
|
|
17001
|
-
const setJsonReportMode = (mode) => {
|
|
17002
|
-
if (context) context.mode = mode;
|
|
17003
|
-
};
|
|
17004
|
-
const writeJsonReport = (report) => {
|
|
17005
|
-
const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
|
|
17006
|
-
process.stdout.write(`${serialized}\n`);
|
|
17925
|
+
if (handoffTarget === void 0 || handoffTarget === SKIP_CHOICE) return;
|
|
17926
|
+
const payload = buildHandoffPayload({
|
|
17927
|
+
diagnostics: input.diagnostics,
|
|
17928
|
+
projectName: input.projectName
|
|
17929
|
+
});
|
|
17930
|
+
if (handoffTarget === PRINT_CHOICE) {
|
|
17931
|
+
printPayload(payload);
|
|
17932
|
+
return;
|
|
17933
|
+
}
|
|
17934
|
+
if (handoffTarget === CLIPBOARD_CHOICE) {
|
|
17935
|
+
if (await copyToClipboard(payload)) cliLogger.log("Copied the prompt to your clipboard.");
|
|
17936
|
+
else printPayload(payload);
|
|
17937
|
+
return;
|
|
17938
|
+
}
|
|
17939
|
+
const agentId = handoffTarget;
|
|
17940
|
+
const displayName = getSkillAgentConfig(agentId).displayName;
|
|
17941
|
+
const skillSpinner = spinner(`Installing the /react-doctor skill for ${displayName}...`).start();
|
|
17942
|
+
try {
|
|
17943
|
+
if (await installReactDoctorSkillForAgent(agentId, input.rootDirectory)) skillSpinner.succeed(`Installed the /react-doctor skill for ${displayName}.`);
|
|
17944
|
+
else skillSpinner.stop();
|
|
17945
|
+
} catch {
|
|
17946
|
+
skillSpinner.stop();
|
|
17947
|
+
}
|
|
17948
|
+
cliLogger.log(highlighter.dim(`Handing off to ${displayName}...`));
|
|
17949
|
+
try {
|
|
17950
|
+
await launchCliAgent(agentId, payload, input.rootDirectory);
|
|
17951
|
+
} catch {
|
|
17952
|
+
cliLogger.warn(`Couldn't launch ${CLI_AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
|
|
17953
|
+
printPayload(payload);
|
|
17954
|
+
}
|
|
17007
17955
|
};
|
|
17008
|
-
|
|
17009
|
-
|
|
17956
|
+
//#endregion
|
|
17957
|
+
//#region src/cli/utils/read-object-file.ts
|
|
17958
|
+
/**
|
|
17959
|
+
* Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
|
|
17960
|
+
* unparseable, or not an object. JSON5 parsing tolerates comments and
|
|
17961
|
+
* trailing commas so hand-edited config files round-trip.
|
|
17962
|
+
*/
|
|
17963
|
+
const readObjectFile = (filePath) => {
|
|
17010
17964
|
try {
|
|
17011
|
-
|
|
17012
|
-
|
|
17013
|
-
directory: context.directory,
|
|
17014
|
-
error,
|
|
17015
|
-
elapsedMilliseconds: performance.now() - context.startTime,
|
|
17016
|
-
mode: context.mode
|
|
17017
|
-
}));
|
|
17965
|
+
const parsed = parseJSON5(readFileSync(filePath, "utf-8"));
|
|
17966
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
17018
17967
|
} catch {
|
|
17019
|
-
|
|
17968
|
+
return null;
|
|
17969
|
+
}
|
|
17970
|
+
};
|
|
17971
|
+
//#endregion
|
|
17972
|
+
//#region src/cli/utils/serialize-ts-object-literal.ts
|
|
17973
|
+
const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
17974
|
+
const INDENT_UNIT = " ";
|
|
17975
|
+
const serializeKey = (key) => SAFE_IDENTIFIER_PATTERN.test(key) ? key : JSON.stringify(key);
|
|
17976
|
+
/**
|
|
17977
|
+
* Serializes a JSON-compatible value as an idiomatic TypeScript literal:
|
|
17978
|
+
* identifier-shaped object keys stay unquoted, two-space indented, no blank
|
|
17979
|
+
* lines. Intended for JSON-sourced config values (string / number / boolean /
|
|
17980
|
+
* null / array / plain object); any other type falls back to its JSON form.
|
|
17981
|
+
*/
|
|
17982
|
+
const serializeTsObjectLiteral = (value, depth = 0) => {
|
|
17983
|
+
const indent = INDENT_UNIT.repeat(depth);
|
|
17984
|
+
const childIndent = INDENT_UNIT.repeat(depth + 1);
|
|
17985
|
+
if (Array.isArray(value)) {
|
|
17986
|
+
if (value.length === 0) return "[]";
|
|
17987
|
+
return `[\n${value.map((item) => `${childIndent}${serializeTsObjectLiteral(item, depth + 1)}`).join(",\n")}\n${indent}]`;
|
|
17988
|
+
}
|
|
17989
|
+
if (isPlainObject(value)) {
|
|
17990
|
+
const keys = Object.keys(value);
|
|
17991
|
+
if (keys.length === 0) return "{}";
|
|
17992
|
+
return `{\n${keys.map((key) => `${childIndent}${serializeKey(key)}: ${serializeTsObjectLiteral(value[key], depth + 1)}`).join(",\n")}\n${indent}}`;
|
|
17020
17993
|
}
|
|
17994
|
+
return JSON.stringify(value);
|
|
17995
|
+
};
|
|
17996
|
+
//#endregion
|
|
17997
|
+
//#region src/cli/utils/migrate-legacy-config.ts
|
|
17998
|
+
const MIGRATED_CONFIG_FILENAME = "doctor.config.ts";
|
|
17999
|
+
/**
|
|
18000
|
+
* Renames a pre-migration `react-doctor.config.json` to a typed
|
|
18001
|
+
* `doctor.config.ts`, preserving the user's settings as the default export.
|
|
18002
|
+
* `$schema` is dropped — the `ReactDoctorConfig` type supersedes it for
|
|
18003
|
+
* editor autocomplete. Returns the new file's absolute path, or `null` when
|
|
18004
|
+
* the legacy file can't be parsed as an object (left untouched so the user
|
|
18005
|
+
* can resolve it by hand).
|
|
18006
|
+
*/
|
|
18007
|
+
const migrateLegacyConfig = (legacy) => {
|
|
18008
|
+
const parsed = readObjectFile(legacy.legacyFilePath);
|
|
18009
|
+
if (!parsed) return null;
|
|
18010
|
+
const config = { ...parsed };
|
|
18011
|
+
delete config.$schema;
|
|
18012
|
+
const targetPath = path.join(legacy.directory, MIGRATED_CONFIG_FILENAME);
|
|
18013
|
+
writeFileSync(targetPath, `import type { ReactDoctorConfig } from "react-doctor/api";
|
|
18014
|
+
|
|
18015
|
+
export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
|
|
18016
|
+
`);
|
|
18017
|
+
rmSync(legacy.legacyFilePath, { force: true });
|
|
18018
|
+
return targetPath;
|
|
17021
18019
|
};
|
|
17022
18020
|
//#endregion
|
|
17023
18021
|
//#region src/cli/utils/annotation-encoding.ts
|
|
@@ -17049,6 +18047,45 @@ const printBrandedHeader = Effect.gen(function* () {
|
|
|
17049
18047
|
yield* Console.log("");
|
|
17050
18048
|
});
|
|
17051
18049
|
//#endregion
|
|
18050
|
+
//#region src/cli/utils/report-error.ts
|
|
18051
|
+
/**
|
|
18052
|
+
* Sends an error to Sentry — enriched with a fresh snapshot of the current run
|
|
18053
|
+
* (version, platform, CI/agent, invocation, scanned project) and, when a run
|
|
18054
|
+
* transaction is in flight, linked to its trace via the scope's propagation
|
|
18055
|
+
* context so the crash and its transaction share a `trace_id` — then waits for
|
|
18056
|
+
* delivery before the caller exits. The CLI tears down synchronously after
|
|
18057
|
+
* rendering an error, so the awaited `flush` is what actually gets the event
|
|
18058
|
+
* (and any in-flight transaction) off the machine.
|
|
18059
|
+
*
|
|
18060
|
+
* Returns the Sentry event id so the caller can surface it as a reference the
|
|
18061
|
+
* user can quote when reporting the bug; returns `undefined` when Sentry was
|
|
18062
|
+
* never initialized (`--no-score`, tests, or a missing DSN) or delivery failed.
|
|
18063
|
+
* Swallows any transport failure so telemetry can never mask the user's
|
|
18064
|
+
* original error.
|
|
18065
|
+
*/
|
|
18066
|
+
const reportErrorToSentry = async (error) => {
|
|
18067
|
+
if (!Sentry.isInitialized()) return void 0;
|
|
18068
|
+
try {
|
|
18069
|
+
const { tags, contexts } = buildSentryScope();
|
|
18070
|
+
const runTrace = getActiveRunTrace();
|
|
18071
|
+
const eventId = Sentry.withScope((scope) => {
|
|
18072
|
+
for (const [name, context] of Object.entries(contexts)) scope.setContext(name, context);
|
|
18073
|
+
scope.setTags(tags);
|
|
18074
|
+
if (runTrace) scope.setPropagationContext({
|
|
18075
|
+
traceId: runTrace.traceId,
|
|
18076
|
+
parentSpanId: runTrace.spanId,
|
|
18077
|
+
sampled: runTrace.sampled,
|
|
18078
|
+
sampleRand: Math.random()
|
|
18079
|
+
});
|
|
18080
|
+
return Sentry.captureException(error);
|
|
18081
|
+
});
|
|
18082
|
+
await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
|
|
18083
|
+
return eventId;
|
|
18084
|
+
} catch {
|
|
18085
|
+
return;
|
|
18086
|
+
}
|
|
18087
|
+
};
|
|
18088
|
+
//#endregion
|
|
17052
18089
|
//#region src/cli/utils/path-format.ts
|
|
17053
18090
|
const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
|
|
17054
18091
|
//#endregion
|
|
@@ -17116,7 +18153,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
|
|
|
17116
18153
|
yield* Console.log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalScanElapsedMilliseconds)}`);
|
|
17117
18154
|
if (surfaceDiagnostics.length > 0) {
|
|
17118
18155
|
yield* Console.log("");
|
|
17119
|
-
yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)));
|
|
18156
|
+
yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment());
|
|
17120
18157
|
}
|
|
17121
18158
|
const lowestScoredScan = findLowestScoredScan(completedScans);
|
|
17122
18159
|
const aggregateScore = lowestScoredScan?.result.score ?? null;
|
|
@@ -17146,6 +18183,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
|
|
|
17146
18183
|
for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
|
|
17147
18184
|
yield* Console.log("");
|
|
17148
18185
|
yield* printVerboseTip(surfaceDiagnostics, verbose);
|
|
18186
|
+
yield* printDocsNote();
|
|
17149
18187
|
});
|
|
17150
18188
|
//#endregion
|
|
17151
18189
|
//#region src/cli/utils/prompt-install-setup.ts
|
|
@@ -17243,7 +18281,7 @@ const resolveCliInspectOptions = (flags, userConfig) => {
|
|
|
17243
18281
|
respectInlineDisables: flags.respectInlineDisables,
|
|
17244
18282
|
warnings: flags.warnings ?? (wantsWarningGate ? true : void 0),
|
|
17245
18283
|
scoreOnly: flags.score === true,
|
|
17246
|
-
noScore: flags.score === false || (userConfig?.noScore ?? false),
|
|
18284
|
+
noScore: flags.score === false || flags.telemetry === false || (userConfig?.noScore ?? false),
|
|
17247
18285
|
isCi: isCiEnvironment(),
|
|
17248
18286
|
silent: Boolean(flags.json),
|
|
17249
18287
|
outputSurface: flags.prComment ? "prComment" : "cli",
|
|
@@ -17512,6 +18550,7 @@ const validateModeFlags = (flags) => {
|
|
|
17512
18550
|
if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
|
|
17513
18551
|
if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
|
|
17514
18552
|
if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
|
|
18553
|
+
if (flags.score && flags.telemetry === false) throw new Error("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
|
|
17515
18554
|
if (flags.prComment && (flags.json || flags.score)) throw new Error("--pr-comment cannot be combined with --json or --score.");
|
|
17516
18555
|
if (flags.annotations && flags.score) throw new Error("--annotations cannot be combined with --score.");
|
|
17517
18556
|
if (flags.explain !== void 0 && flags.why !== void 0) throw new Error("Use --explain or --why, not both — they're aliases of the same flag.");
|
|
@@ -17545,6 +18584,24 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
|
|
|
17545
18584
|
changedFiles,
|
|
17546
18585
|
isCurrentChanges: false
|
|
17547
18586
|
});
|
|
18587
|
+
/**
|
|
18588
|
+
* On an interactive human run, rename a pre-migration
|
|
18589
|
+
* `react-doctor.config.json` to `doctor.config.ts` before config is loaded,
|
|
18590
|
+
* so the scan reads the renamed file and the user is told once. CI, coding
|
|
18591
|
+
* agents, JSON/score output, pre-commit (`--staged`) hooks, and non-TTY runs
|
|
18592
|
+
* are left untouched — the loader's warning still nudges them — so a scan
|
|
18593
|
+
* never mutates the repo unattended.
|
|
18594
|
+
*/
|
|
18595
|
+
const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
|
|
18596
|
+
if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
|
|
18597
|
+
const legacyConfig = findLegacyConfig(requestedDirectory);
|
|
18598
|
+
if (!legacyConfig) return;
|
|
18599
|
+
const migratedPath = migrateLegacyConfig(legacyConfig);
|
|
18600
|
+
if (!migratedPath) return;
|
|
18601
|
+
cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
|
|
18602
|
+
cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
|
|
18603
|
+
cliLogger.break();
|
|
18604
|
+
};
|
|
17548
18605
|
const inspectAction = async (directory, flags) => {
|
|
17549
18606
|
const isScoreOnly = Boolean(flags.score);
|
|
17550
18607
|
const isJsonMode = Boolean(flags.json);
|
|
@@ -17557,7 +18614,11 @@ const inspectAction = async (directory, flags) => {
|
|
|
17557
18614
|
});
|
|
17558
18615
|
try {
|
|
17559
18616
|
validateModeFlags(flags);
|
|
17560
|
-
|
|
18617
|
+
maybeMigrateLegacyConfig(requestedDirectory, {
|
|
18618
|
+
isQuiet,
|
|
18619
|
+
isStaged: Boolean(flags.staged)
|
|
18620
|
+
});
|
|
18621
|
+
const scanTarget = await resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
|
|
17561
18622
|
const userConfig = scanTarget.userConfig;
|
|
17562
18623
|
const resolvedDirectory = scanTarget.resolvedDirectory;
|
|
17563
18624
|
setJsonReportDirectory(resolvedDirectory);
|
|
@@ -17731,12 +18792,13 @@ const inspectAction = async (directory, flags) => {
|
|
|
17731
18792
|
})) printAgentInstallHint();
|
|
17732
18793
|
}
|
|
17733
18794
|
} catch (error) {
|
|
18795
|
+
const sentryEventId = await reportErrorToSentry(error);
|
|
17734
18796
|
if (isJsonMode) {
|
|
17735
18797
|
writeJsonErrorReport(error);
|
|
17736
18798
|
process.exitCode = 1;
|
|
17737
18799
|
return;
|
|
17738
18800
|
}
|
|
17739
|
-
handleError(error);
|
|
18801
|
+
handleError(error, { sentryEventId });
|
|
17740
18802
|
}
|
|
17741
18803
|
};
|
|
17742
18804
|
//#endregion
|
|
@@ -17752,8 +18814,570 @@ const installAction = async (options, command) => {
|
|
|
17752
18814
|
projectRoot: options.cwd ?? process.cwd()
|
|
17753
18815
|
});
|
|
17754
18816
|
} catch (error) {
|
|
17755
|
-
handleError(error);
|
|
18817
|
+
handleError(error, { sentryEventId: await reportErrorToSentry(error) });
|
|
18818
|
+
}
|
|
18819
|
+
};
|
|
18820
|
+
//#endregion
|
|
18821
|
+
//#region src/cli/utils/rule-catalog.ts
|
|
18822
|
+
const buildRuleCatalog = () => REACT_DOCTOR_RULES.map((entry) => ({
|
|
18823
|
+
key: entry.key,
|
|
18824
|
+
id: entry.id,
|
|
18825
|
+
category: entry.rule.category ?? "Other",
|
|
18826
|
+
defaultSeverity: entry.rule.severity,
|
|
18827
|
+
framework: entry.rule.framework ?? "global",
|
|
18828
|
+
tags: entry.rule.tags ?? [],
|
|
18829
|
+
recommendation: entry.rule.recommendation,
|
|
18830
|
+
defaultEnabled: entry.rule.defaultEnabled !== false
|
|
18831
|
+
}));
|
|
18832
|
+
/**
|
|
18833
|
+
* Resolves a user-supplied rule reference to a catalog entry. Accepts the
|
|
18834
|
+
* fully-qualified key (`react-doctor/no-danger`), the bare id (`no-danger`),
|
|
18835
|
+
* and legacy plugin keys (`react/no-danger`) via the shared alias map.
|
|
18836
|
+
*/
|
|
18837
|
+
const findRuleInCatalog = (catalog, ruleQuery) => {
|
|
18838
|
+
const normalizedQuery = ruleQuery.trim();
|
|
18839
|
+
if (normalizedQuery.length === 0) return void 0;
|
|
18840
|
+
const directMatch = catalog.find((entry) => entry.key === normalizedQuery || entry.id === normalizedQuery);
|
|
18841
|
+
if (directMatch) return directMatch;
|
|
18842
|
+
return catalog.find((entry) => isSameRuleKey(entry.key, normalizedQuery));
|
|
18843
|
+
};
|
|
18844
|
+
const listRuleCategories = (catalog) => [...new Set(catalog.map((entry) => entry.category))].sort();
|
|
18845
|
+
const listRuleTags = (catalog) => [...new Set(catalog.flatMap((entry) => [...entry.tags]))].sort();
|
|
18846
|
+
//#endregion
|
|
18847
|
+
//#region src/cli/utils/render-rule-catalog.ts
|
|
18848
|
+
const SEVERITY_COLUMN_WIDTH_CHARS = 6;
|
|
18849
|
+
const colorizeSeverity = (severity, text) => {
|
|
18850
|
+
if (severity === "error") return highlighter.error(text);
|
|
18851
|
+
if (severity === "warn") return highlighter.warn(text);
|
|
18852
|
+
return highlighter.gray(text);
|
|
18853
|
+
};
|
|
18854
|
+
const formatSourceNote = (effective) => effective.source === "default" ? highlighter.dim("(default)") : highlighter.dim(`(${effective.source})`);
|
|
18855
|
+
const renderRuleCatalog = (rows) => {
|
|
18856
|
+
if (rows.length === 0) return highlighter.dim("No rules match the given filters.");
|
|
18857
|
+
const rowsByCategory = /* @__PURE__ */ new Map();
|
|
18858
|
+
for (const row of rows) {
|
|
18859
|
+
const bucket = rowsByCategory.get(row.entry.category) ?? [];
|
|
18860
|
+
bucket.push(row);
|
|
18861
|
+
rowsByCategory.set(row.entry.category, bucket);
|
|
18862
|
+
}
|
|
18863
|
+
const lines = [];
|
|
18864
|
+
for (const category of [...rowsByCategory.keys()].sort()) {
|
|
18865
|
+
const categoryRows = (rowsByCategory.get(category) ?? []).sort((leftRow, rightRow) => leftRow.entry.key.localeCompare(rightRow.entry.key));
|
|
18866
|
+
lines.push(highlighter.bold(`${category} ${highlighter.dim(`(${categoryRows.length})`)}`));
|
|
18867
|
+
for (const row of categoryRows) {
|
|
18868
|
+
const severityBadge = colorizeSeverity(row.effective.value, row.effective.value.padEnd(SEVERITY_COLUMN_WIDTH_CHARS));
|
|
18869
|
+
const tagSuffix = row.entry.tags.length > 0 ? highlighter.dim(` [${row.entry.tags.join(", ")}]`) : "";
|
|
18870
|
+
lines.push(` ${severityBadge} ${row.entry.key} ${formatSourceNote(row.effective)}${tagSuffix}`);
|
|
18871
|
+
}
|
|
18872
|
+
lines.push("");
|
|
18873
|
+
}
|
|
18874
|
+
lines.push(highlighter.dim(`${rows.length} rule${rows.length === 1 ? "" : "s"} shown.`));
|
|
18875
|
+
return lines.join("\n");
|
|
18876
|
+
};
|
|
18877
|
+
const DETAIL_LABEL_COLUMN_WIDTH_CHARS = 18;
|
|
18878
|
+
const formatDetailRow = (label, value) => ` ${highlighter.dim(label.padEnd(DETAIL_LABEL_COLUMN_WIDTH_CHARS))}${value}`;
|
|
18879
|
+
const renderRuleExplanation = (row) => {
|
|
18880
|
+
const { entry, effective } = row;
|
|
18881
|
+
const lines = [highlighter.bold(entry.key), ""];
|
|
18882
|
+
lines.push(formatDetailRow("Category", entry.category));
|
|
18883
|
+
lines.push(formatDetailRow("Default severity", entry.defaultSeverity));
|
|
18884
|
+
lines.push(formatDetailRow("Current severity", `${colorizeSeverity(effective.value, effective.value)} ${formatSourceNote(effective)}`));
|
|
18885
|
+
lines.push(formatDetailRow("Framework", entry.framework));
|
|
18886
|
+
lines.push(formatDetailRow("Tags", entry.tags.length > 0 ? entry.tags.join(", ") : "none"));
|
|
18887
|
+
lines.push(formatDetailRow("Default enabled", entry.defaultEnabled ? "yes" : "no (opt-in)"));
|
|
18888
|
+
lines.push("");
|
|
18889
|
+
lines.push(highlighter.bold("Why it matters"));
|
|
18890
|
+
lines.push(` ${entry.recommendation ?? "No additional guidance recorded for this rule yet."}`);
|
|
18891
|
+
lines.push("");
|
|
18892
|
+
lines.push(highlighter.bold("Configure"));
|
|
18893
|
+
lines.push(highlighter.dim(` react-doctor rules disable ${entry.key}`));
|
|
18894
|
+
lines.push(highlighter.dim(` react-doctor rules enable ${entry.key} --severity error`));
|
|
18895
|
+
lines.push(highlighter.dim(` react-doctor rules set ${entry.key} warn`));
|
|
18896
|
+
lines.push("");
|
|
18897
|
+
lines.push(highlighter.bold("Learn more"));
|
|
18898
|
+
lines.push(highlighter.dim(` ${buildRuleDocsUrl("react-doctor", entry.id)}`));
|
|
18899
|
+
return lines.join("\n");
|
|
18900
|
+
};
|
|
18901
|
+
//#endregion
|
|
18902
|
+
//#region src/cli/utils/rule-config-file.ts
|
|
18903
|
+
const NEW_CONFIG_FILENAME = "doctor.config.json";
|
|
18904
|
+
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
18905
|
+
const JSON_INDENT_SPACES = 2;
|
|
18906
|
+
const MANAGED_KEYS = [
|
|
18907
|
+
"rules",
|
|
18908
|
+
"categories",
|
|
18909
|
+
"ignore"
|
|
18910
|
+
];
|
|
18911
|
+
/**
|
|
18912
|
+
* Decides where a rule-config mutation should be written. Discovery
|
|
18913
|
+
* reuses `loadConfigWithSource` (the loader the scan uses) so edits land
|
|
18914
|
+
* in the file the scan reads — `doctor.config.{ts,js,…}` is preferred,
|
|
18915
|
+
* then `package.json#reactDoctor`. When nothing exists, a fresh
|
|
18916
|
+
* `doctor.config.json` is targeted at `projectRoot`. Data configs are
|
|
18917
|
+
* re-read raw so unrelated fields round-trip untouched.
|
|
18918
|
+
*/
|
|
18919
|
+
const resolveRuleConfigTarget = async (projectRoot) => {
|
|
18920
|
+
clearConfigCache();
|
|
18921
|
+
const loaded = await loadConfigWithSource(projectRoot);
|
|
18922
|
+
if (loaded) {
|
|
18923
|
+
if (loaded.format === "package-json") {
|
|
18924
|
+
const embedded = (readObjectFile(loaded.configFilePath) ?? {})[PACKAGE_JSON_CONFIG_KEY];
|
|
18925
|
+
return {
|
|
18926
|
+
format: "package-json",
|
|
18927
|
+
filePath: loaded.configFilePath,
|
|
18928
|
+
directory: loaded.sourceDirectory,
|
|
18929
|
+
exists: true,
|
|
18930
|
+
config: isPlainObject(embedded) ? embedded : {}
|
|
18931
|
+
};
|
|
18932
|
+
}
|
|
18933
|
+
if (loaded.format === "json") return {
|
|
18934
|
+
format: "json",
|
|
18935
|
+
filePath: loaded.configFilePath,
|
|
18936
|
+
directory: loaded.sourceDirectory,
|
|
18937
|
+
exists: true,
|
|
18938
|
+
config: readObjectFile(loaded.configFilePath) ?? {}
|
|
18939
|
+
};
|
|
18940
|
+
return {
|
|
18941
|
+
format: "module",
|
|
18942
|
+
filePath: loaded.configFilePath,
|
|
18943
|
+
directory: loaded.sourceDirectory,
|
|
18944
|
+
exists: true,
|
|
18945
|
+
config: loaded.config
|
|
18946
|
+
};
|
|
18947
|
+
}
|
|
18948
|
+
return {
|
|
18949
|
+
format: "json",
|
|
18950
|
+
filePath: path.join(projectRoot, NEW_CONFIG_FILENAME),
|
|
18951
|
+
directory: projectRoot,
|
|
18952
|
+
exists: false,
|
|
18953
|
+
config: {}
|
|
18954
|
+
};
|
|
18955
|
+
};
|
|
18956
|
+
const writeJsonConfig = (filePath, nextConfig) => {
|
|
18957
|
+
const { $schema, ...rest } = nextConfig;
|
|
18958
|
+
writeFileSync(filePath, `${JSON.stringify({
|
|
18959
|
+
$schema: $schema ?? "https://react.doctor/schema/config.json",
|
|
18960
|
+
...rest
|
|
18961
|
+
}, null, JSON_INDENT_SPACES)}\n`);
|
|
18962
|
+
};
|
|
18963
|
+
const writePackageJsonConfig = (filePath, nextConfig) => {
|
|
18964
|
+
const packageJson = readObjectFile(filePath) ?? {};
|
|
18965
|
+
writeFileSync(filePath, `${JSON.stringify({
|
|
18966
|
+
...packageJson,
|
|
18967
|
+
[PACKAGE_JSON_CONFIG_KEY]: nextConfig
|
|
18968
|
+
}, null, JSON_INDENT_SPACES)}\n`);
|
|
18969
|
+
};
|
|
18970
|
+
const syncManagedKeys = (target, nextConfig) => {
|
|
18971
|
+
for (const key of MANAGED_KEYS) {
|
|
18972
|
+
const value = nextConfig[key];
|
|
18973
|
+
if (value === void 0) {
|
|
18974
|
+
if (target[key] !== void 0) delete target[key];
|
|
18975
|
+
} else target[key] = value;
|
|
18976
|
+
}
|
|
18977
|
+
};
|
|
18978
|
+
const assignNodeSource = (owner, key, code) => {
|
|
18979
|
+
owner[key] = code;
|
|
18980
|
+
};
|
|
18981
|
+
const editVariableDeclarationConfig = (declaration, config, nextConfig) => {
|
|
18982
|
+
syncManagedKeys(config, nextConfig);
|
|
18983
|
+
const initializer = declaration.init;
|
|
18984
|
+
if (!initializer) return false;
|
|
18985
|
+
const generatedSource = generateCode(config).code;
|
|
18986
|
+
if (initializer.type === "ObjectExpression") {
|
|
18987
|
+
assignNodeSource(declaration, "init", generatedSource);
|
|
18988
|
+
return true;
|
|
18989
|
+
}
|
|
18990
|
+
if (initializer.type === "TSSatisfiesExpression" && initializer.expression.type === "ObjectExpression") {
|
|
18991
|
+
assignNodeSource(initializer, "expression", generatedSource);
|
|
18992
|
+
return true;
|
|
18993
|
+
}
|
|
18994
|
+
return false;
|
|
18995
|
+
};
|
|
18996
|
+
const writeModuleConfig = async (filePath, nextConfig) => {
|
|
18997
|
+
try {
|
|
18998
|
+
const module = await loadFile(filePath);
|
|
18999
|
+
if (module.exports.default?.$type === "identifier") {
|
|
19000
|
+
const { declaration, config } = getConfigFromVariableDeclaration(module);
|
|
19001
|
+
if (!config || !editVariableDeclarationConfig(declaration, config, nextConfig)) return false;
|
|
19002
|
+
} else syncManagedKeys(getDefaultExportOptions(module), nextConfig);
|
|
19003
|
+
await writeFile(module, filePath);
|
|
19004
|
+
return true;
|
|
19005
|
+
} catch {
|
|
19006
|
+
return false;
|
|
19007
|
+
}
|
|
19008
|
+
};
|
|
19009
|
+
const writeRuleConfig = async (target, nextConfig) => {
|
|
19010
|
+
if (target.format === "module") {
|
|
19011
|
+
const written = await writeModuleConfig(target.filePath, nextConfig);
|
|
19012
|
+
if (written) clearConfigCache();
|
|
19013
|
+
return { written };
|
|
19014
|
+
}
|
|
19015
|
+
if (target.format === "package-json") writePackageJsonConfig(target.filePath, nextConfig);
|
|
19016
|
+
else writeJsonConfig(target.filePath, nextConfig);
|
|
19017
|
+
clearConfigCache();
|
|
19018
|
+
return { written: true };
|
|
19019
|
+
};
|
|
19020
|
+
//#endregion
|
|
19021
|
+
//#region src/cli/utils/resolve-effective-rule-severity.ts
|
|
19022
|
+
/**
|
|
19023
|
+
* Resolves what a rule will actually do under the current config without
|
|
19024
|
+
* running a scan. `ignore.tags` is a pre-lint gate: a rule carrying an
|
|
19025
|
+
* ignored tag is dropped (via `shouldEnableRule`) before any severity is
|
|
19026
|
+
* read, so it wins over every override. Among rules that survive the gate,
|
|
19027
|
+
* the scanner's order is `rules` > `categories` > `buckets` > the registry
|
|
19028
|
+
* default.
|
|
19029
|
+
*/
|
|
19030
|
+
const resolveEffectiveRuleSeverity = (config, entry) => {
|
|
19031
|
+
const ignoredTags = config?.ignore?.tags ?? [];
|
|
19032
|
+
if (entry.tags.some((tag) => ignoredTags.includes(tag))) return {
|
|
19033
|
+
value: "off",
|
|
19034
|
+
source: "tag"
|
|
19035
|
+
};
|
|
19036
|
+
const ruleOverrides = config?.rules ?? {};
|
|
19037
|
+
for (const equivalentKey of getEquivalentRuleKeys(entry.key)) {
|
|
19038
|
+
const override = ruleOverrides[equivalentKey];
|
|
19039
|
+
if (override !== void 0) return {
|
|
19040
|
+
value: override,
|
|
19041
|
+
source: "rule"
|
|
19042
|
+
};
|
|
17756
19043
|
}
|
|
19044
|
+
const categoryOverride = config?.categories?.[entry.category];
|
|
19045
|
+
if (categoryOverride !== void 0) return {
|
|
19046
|
+
value: categoryOverride,
|
|
19047
|
+
source: "category"
|
|
19048
|
+
};
|
|
19049
|
+
if (COMPILER_CLEANUP_RULE_KEYS.has(entry.key)) {
|
|
19050
|
+
const bucketOverride = config?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
19051
|
+
if (bucketOverride !== void 0) return {
|
|
19052
|
+
value: bucketOverride,
|
|
19053
|
+
source: "bucket"
|
|
19054
|
+
};
|
|
19055
|
+
}
|
|
19056
|
+
return {
|
|
19057
|
+
value: entry.defaultEnabled ? entry.defaultSeverity : "off",
|
|
19058
|
+
source: "default"
|
|
19059
|
+
};
|
|
19060
|
+
};
|
|
19061
|
+
//#endregion
|
|
19062
|
+
//#region src/cli/utils/update-rule-config.ts
|
|
19063
|
+
/**
|
|
19064
|
+
* Sets a per-rule severity, replacing any existing entry for the same
|
|
19065
|
+
* rule (including legacy-aliased keys, so a config still targeting
|
|
19066
|
+
* `react/no-danger` is rewritten to the canonical key instead of
|
|
19067
|
+
* leaving a dead duplicate).
|
|
19068
|
+
*/
|
|
19069
|
+
const setRuleSeverity = (config, ruleKey, severity) => {
|
|
19070
|
+
const equivalentKeys = new Set(getEquivalentRuleKeys(ruleKey));
|
|
19071
|
+
const nextRules = {};
|
|
19072
|
+
for (const [existingKey, existingSeverity] of Object.entries(config.rules ?? {})) if (!equivalentKeys.has(existingKey)) nextRules[existingKey] = existingSeverity;
|
|
19073
|
+
nextRules[ruleKey] = severity;
|
|
19074
|
+
return {
|
|
19075
|
+
...config,
|
|
19076
|
+
rules: nextRules
|
|
19077
|
+
};
|
|
19078
|
+
};
|
|
19079
|
+
const setCategorySeverity = (config, category, severity) => ({
|
|
19080
|
+
...config,
|
|
19081
|
+
categories: {
|
|
19082
|
+
...config.categories,
|
|
19083
|
+
[category]: severity
|
|
19084
|
+
}
|
|
19085
|
+
});
|
|
19086
|
+
const addIgnoredTag = (config, tag) => {
|
|
19087
|
+
const currentTags = config.ignore?.tags ?? [];
|
|
19088
|
+
if (currentTags.includes(tag)) return config;
|
|
19089
|
+
return {
|
|
19090
|
+
...config,
|
|
19091
|
+
ignore: {
|
|
19092
|
+
...config.ignore,
|
|
19093
|
+
tags: [...new Set([...currentTags, tag])].sort()
|
|
19094
|
+
}
|
|
19095
|
+
};
|
|
19096
|
+
};
|
|
19097
|
+
const removeIgnoredTag = (config, tag) => {
|
|
19098
|
+
const currentTags = config.ignore?.tags ?? [];
|
|
19099
|
+
if (!currentTags.includes(tag)) return config;
|
|
19100
|
+
const remainingTags = currentTags.filter((existingTag) => existingTag !== tag);
|
|
19101
|
+
const { tags: _removed, ...remainingIgnore } = config.ignore ?? {};
|
|
19102
|
+
if (remainingTags.length === 0) {
|
|
19103
|
+
if (Object.keys(remainingIgnore).length === 0) {
|
|
19104
|
+
const { ignore: _ignore, ...configWithoutIgnore } = config;
|
|
19105
|
+
return configWithoutIgnore;
|
|
19106
|
+
}
|
|
19107
|
+
return {
|
|
19108
|
+
...config,
|
|
19109
|
+
ignore: remainingIgnore
|
|
19110
|
+
};
|
|
19111
|
+
}
|
|
19112
|
+
return {
|
|
19113
|
+
...config,
|
|
19114
|
+
ignore: {
|
|
19115
|
+
...remainingIgnore,
|
|
19116
|
+
tags: remainingTags
|
|
19117
|
+
}
|
|
19118
|
+
};
|
|
19119
|
+
};
|
|
19120
|
+
//#endregion
|
|
19121
|
+
//#region src/cli/commands/rules.ts
|
|
19122
|
+
const SEVERITY_VALUES = [
|
|
19123
|
+
"off",
|
|
19124
|
+
"warn",
|
|
19125
|
+
"error"
|
|
19126
|
+
];
|
|
19127
|
+
const resolveProjectRoot = (options) => {
|
|
19128
|
+
const requestedDirectory = path.resolve(options.cwd ?? process.cwd());
|
|
19129
|
+
return findNearestPackageDirectory(requestedDirectory) ?? requestedDirectory;
|
|
19130
|
+
};
|
|
19131
|
+
const parseSeverity = (value) => SEVERITY_VALUES.includes(value) ? value : null;
|
|
19132
|
+
const reportInvalidSeverity = (value) => {
|
|
19133
|
+
cliLogger.error(`Invalid severity "${value}". Expected one of: ${SEVERITY_VALUES.join(", ")}.`);
|
|
19134
|
+
process.exitCode = 1;
|
|
19135
|
+
};
|
|
19136
|
+
const reportRuleNotFound = (ruleQuery) => {
|
|
19137
|
+
cliLogger.error(`Unknown rule "${ruleQuery}".`);
|
|
19138
|
+
cliLogger.dim(" Run `react-doctor rules list` to see every available rule.");
|
|
19139
|
+
process.exitCode = 1;
|
|
19140
|
+
};
|
|
19141
|
+
const describeTargetPath = (target) => {
|
|
19142
|
+
const relativePath = path.relative(process.cwd(), target.filePath);
|
|
19143
|
+
const displayPath = relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : target.filePath;
|
|
19144
|
+
return target.exists ? displayPath : `${displayPath} ${highlighter.dim("(created)")}`;
|
|
19145
|
+
};
|
|
19146
|
+
const applyConfigChange = async (options, change) => {
|
|
19147
|
+
const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
|
|
19148
|
+
const nextConfig = change(target.config);
|
|
19149
|
+
const { written } = await writeRuleConfig(target, nextConfig);
|
|
19150
|
+
return {
|
|
19151
|
+
target,
|
|
19152
|
+
nextConfig,
|
|
19153
|
+
written
|
|
19154
|
+
};
|
|
19155
|
+
};
|
|
19156
|
+
const reportManualEdit = (target, nextConfig) => {
|
|
19157
|
+
const managed = {};
|
|
19158
|
+
for (const key of [
|
|
19159
|
+
"rules",
|
|
19160
|
+
"categories",
|
|
19161
|
+
"ignore"
|
|
19162
|
+
]) if (nextConfig[key] !== void 0) managed[key] = nextConfig[key];
|
|
19163
|
+
cliLogger.error(`Couldn't automatically edit ${describeTargetPath(target)} (dynamic config).`);
|
|
19164
|
+
cliLogger.dim(" Apply this to your config's default export, then re-run:");
|
|
19165
|
+
for (const line of JSON.stringify(managed, null, 2).split("\n")) cliLogger.dim(` ${line}`);
|
|
19166
|
+
process.exitCode = 1;
|
|
19167
|
+
};
|
|
19168
|
+
const rulesListAction = async (options) => {
|
|
19169
|
+
const catalog = buildRuleCatalog();
|
|
19170
|
+
const config = validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config);
|
|
19171
|
+
const categoryFilter = options.category?.toLowerCase();
|
|
19172
|
+
const frameworkFilter = options.framework?.toLowerCase();
|
|
19173
|
+
const rows = catalog.filter((entry) => {
|
|
19174
|
+
if (categoryFilter && entry.category.toLowerCase() !== categoryFilter) return false;
|
|
19175
|
+
if (frameworkFilter && entry.framework.toLowerCase() !== frameworkFilter) return false;
|
|
19176
|
+
if (options.tag && !entry.tags.includes(options.tag)) return false;
|
|
19177
|
+
return true;
|
|
19178
|
+
}).map((entry) => ({
|
|
19179
|
+
entry,
|
|
19180
|
+
effective: resolveEffectiveRuleSeverity(config, entry)
|
|
19181
|
+
})).filter((row) => options.configured ? row.effective.source !== "default" : true);
|
|
19182
|
+
if (options.json) {
|
|
19183
|
+
const payload = rows.map((row) => ({
|
|
19184
|
+
key: row.entry.key,
|
|
19185
|
+
id: row.entry.id,
|
|
19186
|
+
category: row.entry.category,
|
|
19187
|
+
framework: row.entry.framework,
|
|
19188
|
+
tags: row.entry.tags,
|
|
19189
|
+
defaultSeverity: row.entry.defaultSeverity,
|
|
19190
|
+
defaultEnabled: row.entry.defaultEnabled,
|
|
19191
|
+
severity: row.effective.value,
|
|
19192
|
+
source: row.effective.source
|
|
19193
|
+
}));
|
|
19194
|
+
cliLogger.log(JSON.stringify(payload, null, 2));
|
|
19195
|
+
return;
|
|
19196
|
+
}
|
|
19197
|
+
cliLogger.log(renderRuleCatalog(rows));
|
|
19198
|
+
};
|
|
19199
|
+
const rulesExplainAction = async (ruleQuery, options) => {
|
|
19200
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
19201
|
+
if (!entry) {
|
|
19202
|
+
reportRuleNotFound(ruleQuery);
|
|
19203
|
+
return;
|
|
19204
|
+
}
|
|
19205
|
+
const effective = resolveEffectiveRuleSeverity(validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config), entry);
|
|
19206
|
+
if (options.json) {
|
|
19207
|
+
cliLogger.log(JSON.stringify({
|
|
19208
|
+
key: entry.key,
|
|
19209
|
+
id: entry.id,
|
|
19210
|
+
category: entry.category,
|
|
19211
|
+
framework: entry.framework,
|
|
19212
|
+
tags: entry.tags,
|
|
19213
|
+
defaultSeverity: entry.defaultSeverity,
|
|
19214
|
+
defaultEnabled: entry.defaultEnabled,
|
|
19215
|
+
severity: effective.value,
|
|
19216
|
+
source: effective.source,
|
|
19217
|
+
recommendation: entry.recommendation ?? null,
|
|
19218
|
+
learnMoreUrl: buildRuleDocsUrl("react-doctor", entry.id)
|
|
19219
|
+
}, null, 2));
|
|
19220
|
+
return;
|
|
19221
|
+
}
|
|
19222
|
+
cliLogger.log(renderRuleExplanation({
|
|
19223
|
+
entry,
|
|
19224
|
+
effective
|
|
19225
|
+
}));
|
|
19226
|
+
};
|
|
19227
|
+
const setRuleSeverityAndReport = async (entry, severity, options) => {
|
|
19228
|
+
const { target, nextConfig, written } = await applyConfigChange(options, (config) => setRuleSeverity(config, entry.key, severity));
|
|
19229
|
+
if (!written) {
|
|
19230
|
+
reportManualEdit(target, nextConfig);
|
|
19231
|
+
return;
|
|
19232
|
+
}
|
|
19233
|
+
cliLogger.success(`Set ${entry.key} → ${severity}`);
|
|
19234
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
19235
|
+
};
|
|
19236
|
+
const rulesSetAction = async (ruleQuery, severityValue, options) => {
|
|
19237
|
+
const severity = parseSeverity(severityValue);
|
|
19238
|
+
if (!severity) {
|
|
19239
|
+
reportInvalidSeverity(severityValue);
|
|
19240
|
+
return;
|
|
19241
|
+
}
|
|
19242
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
19243
|
+
if (!entry) {
|
|
19244
|
+
reportRuleNotFound(ruleQuery);
|
|
19245
|
+
return;
|
|
19246
|
+
}
|
|
19247
|
+
await setRuleSeverityAndReport(entry, severity, options);
|
|
19248
|
+
};
|
|
19249
|
+
const rulesEnableAction = async (ruleQuery, options) => {
|
|
19250
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
19251
|
+
if (!entry) {
|
|
19252
|
+
reportRuleNotFound(ruleQuery);
|
|
19253
|
+
return;
|
|
19254
|
+
}
|
|
19255
|
+
if (options.severity === void 0) {
|
|
19256
|
+
await setRuleSeverityAndReport(entry, entry.defaultSeverity, options);
|
|
19257
|
+
return;
|
|
19258
|
+
}
|
|
19259
|
+
const severity = parseSeverity(options.severity);
|
|
19260
|
+
if (!severity) {
|
|
19261
|
+
reportInvalidSeverity(options.severity);
|
|
19262
|
+
return;
|
|
19263
|
+
}
|
|
19264
|
+
if (severity === "off") {
|
|
19265
|
+
cliLogger.error("`enable` cannot set a rule to off. Use `react-doctor rules disable` instead.");
|
|
19266
|
+
process.exitCode = 1;
|
|
19267
|
+
return;
|
|
19268
|
+
}
|
|
19269
|
+
await setRuleSeverityAndReport(entry, severity, options);
|
|
19270
|
+
};
|
|
19271
|
+
const rulesDisableAction = async (ruleQuery, options) => {
|
|
19272
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
19273
|
+
if (!entry) {
|
|
19274
|
+
reportRuleNotFound(ruleQuery);
|
|
19275
|
+
return;
|
|
19276
|
+
}
|
|
19277
|
+
await setRuleSeverityAndReport(entry, "off", options);
|
|
19278
|
+
};
|
|
19279
|
+
const rulesCategoryAction = async (categoryQuery, severityValue, options) => {
|
|
19280
|
+
const severity = parseSeverity(severityValue);
|
|
19281
|
+
if (!severity) {
|
|
19282
|
+
reportInvalidSeverity(severityValue);
|
|
19283
|
+
return;
|
|
19284
|
+
}
|
|
19285
|
+
const knownCategories = listRuleCategories(buildRuleCatalog());
|
|
19286
|
+
const matchedCategory = knownCategories.find((category) => category.toLowerCase() === categoryQuery.toLowerCase());
|
|
19287
|
+
if (!matchedCategory) {
|
|
19288
|
+
cliLogger.error(`Unknown category "${categoryQuery}".`);
|
|
19289
|
+
cliLogger.dim(` Known categories: ${knownCategories.join(", ")}`);
|
|
19290
|
+
process.exitCode = 1;
|
|
19291
|
+
return;
|
|
19292
|
+
}
|
|
19293
|
+
const { target, nextConfig, written } = await applyConfigChange(options, (config) => setCategorySeverity(config, matchedCategory, severity));
|
|
19294
|
+
if (!written) {
|
|
19295
|
+
reportManualEdit(target, nextConfig);
|
|
19296
|
+
return;
|
|
19297
|
+
}
|
|
19298
|
+
cliLogger.success(`Set category "${matchedCategory}" → ${severity}`);
|
|
19299
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
19300
|
+
};
|
|
19301
|
+
const rulesIgnoreTagAction = async (tag, options) => {
|
|
19302
|
+
const knownTags = listRuleTags(buildRuleCatalog());
|
|
19303
|
+
if (!knownTags.includes(tag)) {
|
|
19304
|
+
cliLogger.error(`Unknown tag "${tag}".`);
|
|
19305
|
+
cliLogger.dim(` Known tags: ${knownTags.join(", ")}`);
|
|
19306
|
+
process.exitCode = 1;
|
|
19307
|
+
return;
|
|
19308
|
+
}
|
|
19309
|
+
const { target, nextConfig, written } = await applyConfigChange(options, (config) => addIgnoredTag(config, tag));
|
|
19310
|
+
if (!written) {
|
|
19311
|
+
reportManualEdit(target, nextConfig);
|
|
19312
|
+
return;
|
|
19313
|
+
}
|
|
19314
|
+
cliLogger.success(`Ignoring tag "${tag}" (rules with this tag are skipped before linting)`);
|
|
19315
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
19316
|
+
};
|
|
19317
|
+
const rulesUnignoreTagAction = async (tag, options) => {
|
|
19318
|
+
const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
|
|
19319
|
+
if (!(target.config.ignore?.tags ?? []).includes(tag)) {
|
|
19320
|
+
cliLogger.dim(`Tag "${tag}" was not being ignored; nothing to change.`);
|
|
19321
|
+
return;
|
|
19322
|
+
}
|
|
19323
|
+
const nextConfig = removeIgnoredTag(target.config, tag);
|
|
19324
|
+
const { written } = await writeRuleConfig(target, nextConfig);
|
|
19325
|
+
if (!written) {
|
|
19326
|
+
reportManualEdit(target, nextConfig);
|
|
19327
|
+
return;
|
|
19328
|
+
}
|
|
19329
|
+
cliLogger.success(`Tag "${tag}" is no longer ignored`);
|
|
19330
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
19331
|
+
};
|
|
19332
|
+
//#endregion
|
|
19333
|
+
//#region src/cli/commands/version.ts
|
|
19334
|
+
/**
|
|
19335
|
+
* oclif-style version line. 12-factor CLI Apps (#3, "What version am I
|
|
19336
|
+
* on?"): the `version` command is the primary place users grab debugging
|
|
19337
|
+
* info, so it carries the Node runtime and platform alongside the CLI
|
|
19338
|
+
* version. The `-v` / `-V` / `--version` flags stay terse (just the
|
|
19339
|
+
* number) so scripts can parse them.
|
|
19340
|
+
*/
|
|
19341
|
+
const buildVersionString = () => `react-doctor/${VERSION} ${process.platform}-${process.arch} node-${process.version}`;
|
|
19342
|
+
const versionAction = () => {
|
|
19343
|
+
process.stdout.write(`${buildVersionString()}\n`);
|
|
19344
|
+
};
|
|
19345
|
+
//#endregion
|
|
19346
|
+
//#region src/cli/utils/apply-color-preference.ts
|
|
19347
|
+
/**
|
|
19348
|
+
* Resolve an explicit color preference from `--color` / `--no-color` or the
|
|
19349
|
+
* app-specific `REACT_DOCTOR_NO_COLOR` / `REACT_DOCTOR_FORCE_COLOR` env vars
|
|
19350
|
+
* (clig.dev Output; 12-factor #6), overriding picocolors' own
|
|
19351
|
+
* `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY detection. Flags win over env
|
|
19352
|
+
* vars; with neither set, picocolors' detection stands.
|
|
19353
|
+
*
|
|
19354
|
+
* A resolved preference is mirrored onto the standard `NO_COLOR` /
|
|
19355
|
+
* `FORCE_COLOR` env vars in addition to our picocolors highlighter, so
|
|
19356
|
+
* libraries with their own color stacks (the `ora` spinner, `prompts`)
|
|
19357
|
+
* honor it too rather than only the scan report. Scanning argv directly
|
|
19358
|
+
* (not Commander's parsed options) applies the preference before Commander
|
|
19359
|
+
* parses, so it reaches every later path. The scan stops at `--`.
|
|
19360
|
+
*/
|
|
19361
|
+
const applyColorPreference = (argv, env = process.env) => {
|
|
19362
|
+
let enabled;
|
|
19363
|
+
for (const argument of argv) {
|
|
19364
|
+
if (argument === "--") break;
|
|
19365
|
+
if (argument === "--no-color") enabled = false;
|
|
19366
|
+
else if (argument === "--color") enabled = true;
|
|
19367
|
+
}
|
|
19368
|
+
if (enabled === void 0) {
|
|
19369
|
+
if (env.REACT_DOCTOR_NO_COLOR) enabled = false;
|
|
19370
|
+
else if (env.REACT_DOCTOR_FORCE_COLOR) enabled = true;
|
|
19371
|
+
}
|
|
19372
|
+
if (enabled === void 0) return;
|
|
19373
|
+
if (enabled) {
|
|
19374
|
+
env.FORCE_COLOR = "1";
|
|
19375
|
+
delete env.NO_COLOR;
|
|
19376
|
+
} else {
|
|
19377
|
+
env.NO_COLOR = "1";
|
|
19378
|
+
delete env.FORCE_COLOR;
|
|
19379
|
+
}
|
|
19380
|
+
setColorEnabled(enabled);
|
|
17757
19381
|
};
|
|
17758
19382
|
//#endregion
|
|
17759
19383
|
//#region src/cli/utils/exit-gracefully.ts
|
|
@@ -17765,21 +19389,54 @@ const exitGracefully = () => {
|
|
|
17765
19389
|
process.exit(130);
|
|
17766
19390
|
};
|
|
17767
19391
|
//#endregion
|
|
19392
|
+
//#region src/cli/utils/normalize-help-command.ts
|
|
19393
|
+
/**
|
|
19394
|
+
* 12-factor CLI Apps (#1, "Great help is essential"): `mycli help` and
|
|
19395
|
+
* `mycli help <command>` must display help. Commander doesn't wire this
|
|
19396
|
+
* up once the root command has its own default action plus a positional
|
|
19397
|
+
* argument — it treats a leading `help` as the `[directory]` to scan,
|
|
19398
|
+
* which then errors with "No React project found in ./help".
|
|
19399
|
+
*
|
|
19400
|
+
* We rewrite the argv up front so the existing `--help` paths handle it:
|
|
19401
|
+
* `react-doctor help` -> `react-doctor --help`
|
|
19402
|
+
* `react-doctor help install` -> `react-doctor install --help`
|
|
19403
|
+
*
|
|
19404
|
+
* Only a *leading* `help` token is rewritten, so a flag value such as
|
|
19405
|
+
* `--project help` is never mistaken for the help command. The target is
|
|
19406
|
+
* the first non-flag token after `help`, so intervening flags like
|
|
19407
|
+
* `help --no-color install` still resolve to `install`. An unknown target
|
|
19408
|
+
* (`help bogus`) falls back to root help rather than erroring.
|
|
19409
|
+
*/
|
|
19410
|
+
const normalizeHelpInvocation = (argv, knownCommands) => {
|
|
19411
|
+
const nodeArguments = argv.slice(0, 2);
|
|
19412
|
+
const userArguments = argv.slice(2);
|
|
19413
|
+
if (userArguments[0] !== "help") return [...argv];
|
|
19414
|
+
const target = userArguments.slice(1).find((argument) => !argument.startsWith("-"));
|
|
19415
|
+
if (target !== void 0 && knownCommands.includes(target)) return [
|
|
19416
|
+
...nodeArguments,
|
|
19417
|
+
target,
|
|
19418
|
+
"--help"
|
|
19419
|
+
];
|
|
19420
|
+
return [...nodeArguments, "--help"];
|
|
19421
|
+
};
|
|
19422
|
+
//#endregion
|
|
17768
19423
|
//#region src/cli/utils/strip-unknown-cli-flags.ts
|
|
17769
|
-
const NODE_ARGUMENT_COUNT = 2;
|
|
17770
19424
|
const ROOT_FLAG_SPEC = {
|
|
17771
19425
|
longOptionsWithoutValues: new Set([
|
|
17772
19426
|
"--annotations",
|
|
19427
|
+
"--color",
|
|
17773
19428
|
"--dead-code",
|
|
17774
19429
|
"--full",
|
|
17775
19430
|
"--help",
|
|
17776
19431
|
"--json",
|
|
17777
19432
|
"--json-compact",
|
|
17778
19433
|
"--lint",
|
|
19434
|
+
"--no-color",
|
|
17779
19435
|
"--no-dead-code",
|
|
17780
19436
|
"--no-lint",
|
|
17781
19437
|
"--no-respect-inline-disables",
|
|
17782
19438
|
"--no-score",
|
|
19439
|
+
"--no-telemetry",
|
|
17783
19440
|
"--no-warnings",
|
|
17784
19441
|
"--pr-comment",
|
|
17785
19442
|
"--respect-inline-disables",
|
|
@@ -17808,8 +19465,10 @@ const ROOT_FLAG_SPEC = {
|
|
|
17808
19465
|
const INSTALL_FLAG_SPEC = {
|
|
17809
19466
|
longOptionsWithoutValues: new Set([
|
|
17810
19467
|
"--agent-hooks",
|
|
19468
|
+
"--color",
|
|
17811
19469
|
"--dry-run",
|
|
17812
19470
|
"--help",
|
|
19471
|
+
"--no-color",
|
|
17813
19472
|
"--yes"
|
|
17814
19473
|
]),
|
|
17815
19474
|
longOptionsWithRequiredValues: new Set(["--cwd"]),
|
|
@@ -17817,7 +19476,40 @@ const INSTALL_FLAG_SPEC = {
|
|
|
17817
19476
|
shortOptionsWithoutValues: new Set(["-h", "-y"]),
|
|
17818
19477
|
shortOptionsWithRequiredValues: new Set(["-c"])
|
|
17819
19478
|
};
|
|
17820
|
-
const COMMAND_FLAG_SPECS = new Map([
|
|
19479
|
+
const COMMAND_FLAG_SPECS = new Map([
|
|
19480
|
+
["install", INSTALL_FLAG_SPEC],
|
|
19481
|
+
["setup", INSTALL_FLAG_SPEC],
|
|
19482
|
+
["version", {
|
|
19483
|
+
longOptionsWithoutValues: new Set([
|
|
19484
|
+
"--color",
|
|
19485
|
+
"--help",
|
|
19486
|
+
"--no-color"
|
|
19487
|
+
]),
|
|
19488
|
+
longOptionsWithRequiredValues: /* @__PURE__ */ new Set(),
|
|
19489
|
+
longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
|
|
19490
|
+
shortOptionsWithoutValues: new Set(["-h"]),
|
|
19491
|
+
shortOptionsWithRequiredValues: /* @__PURE__ */ new Set()
|
|
19492
|
+
}],
|
|
19493
|
+
["rules", {
|
|
19494
|
+
longOptionsWithoutValues: new Set([
|
|
19495
|
+
"--color",
|
|
19496
|
+
"--configured",
|
|
19497
|
+
"--help",
|
|
19498
|
+
"--json",
|
|
19499
|
+
"--no-color"
|
|
19500
|
+
]),
|
|
19501
|
+
longOptionsWithRequiredValues: new Set([
|
|
19502
|
+
"--category",
|
|
19503
|
+
"--cwd",
|
|
19504
|
+
"--framework",
|
|
19505
|
+
"--severity",
|
|
19506
|
+
"--tag"
|
|
19507
|
+
]),
|
|
19508
|
+
longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
|
|
19509
|
+
shortOptionsWithoutValues: new Set(["-h"]),
|
|
19510
|
+
shortOptionsWithRequiredValues: new Set(["-c"])
|
|
19511
|
+
}]
|
|
19512
|
+
]);
|
|
17821
19513
|
const isFlagLike = (argument) => argument.startsWith("-") && argument !== "-";
|
|
17822
19514
|
const getLongOptionName = (argument) => {
|
|
17823
19515
|
const equalsIndex = argument.indexOf("=");
|
|
@@ -17871,8 +19563,8 @@ const stripUnknownFlags = (userArguments, flagSpec) => {
|
|
|
17871
19563
|
return sanitizedArguments;
|
|
17872
19564
|
};
|
|
17873
19565
|
const stripUnknownCliFlags = (argv) => {
|
|
17874
|
-
const nodeArguments = argv.slice(0,
|
|
17875
|
-
const userArguments = argv.slice(
|
|
19566
|
+
const nodeArguments = argv.slice(0, 2);
|
|
19567
|
+
const userArguments = argv.slice(2);
|
|
17876
19568
|
const commandIndex = findCommandIndex(userArguments);
|
|
17877
19569
|
if (commandIndex === null) return [...nodeArguments, ...stripUnknownFlags(userArguments, ROOT_FLAG_SPEC)];
|
|
17878
19570
|
const commandName = userArguments[commandIndex];
|
|
@@ -17886,30 +19578,83 @@ const stripUnknownCliFlags = (argv) => {
|
|
|
17886
19578
|
};
|
|
17887
19579
|
//#endregion
|
|
17888
19580
|
//#region src/cli/index.ts
|
|
19581
|
+
initializeSentry();
|
|
17889
19582
|
process.on("SIGINT", exitGracefully);
|
|
17890
19583
|
process.on("SIGTERM", exitGracefully);
|
|
17891
19584
|
unrefStdin();
|
|
17892
|
-
const
|
|
19585
|
+
const formatExampleLines = (examples) => {
|
|
19586
|
+
const width = Math.max(...examples.map(([command]) => command.length));
|
|
19587
|
+
return examples.map(([command, description]) => ` $ ${command.padEnd(width)} ${highlighter.dim(`# ${description}`)}`).join("\n");
|
|
19588
|
+
};
|
|
19589
|
+
const renderRootHelpEpilog = () => `
|
|
19590
|
+
${highlighter.dim("Examples:")}
|
|
19591
|
+
${formatExampleLines([
|
|
19592
|
+
["react-doctor", "scan the current project"],
|
|
19593
|
+
["react-doctor ./apps/web", "scan a specific directory"],
|
|
19594
|
+
["react-doctor --diff main", "scan only files changed vs. main"],
|
|
19595
|
+
["react-doctor --staged", "scan staged files (pre-commit hook)"],
|
|
19596
|
+
["react-doctor --fail-on warning", "exit non-zero on warnings (CI gate)"],
|
|
19597
|
+
["react-doctor --json > report.json", "write a machine-readable report"],
|
|
19598
|
+
["react-doctor --explain src/App.tsx:42", "explain why a rule fired there"],
|
|
19599
|
+
["react-doctor install", "set up the agent skill and git hook"]
|
|
19600
|
+
])}
|
|
19601
|
+
|
|
17893
19602
|
${highlighter.dim("Configuration:")}
|
|
17894
|
-
|
|
17895
|
-
|
|
19603
|
+
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.
|
|
19604
|
+
Use ${highlighter.info("react-doctor rules")} to list, explain, and configure rules. CLI flags always override config values.
|
|
19605
|
+
|
|
19606
|
+
${highlighter.dim("Feedback & bug reports:")}
|
|
19607
|
+
${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
|
|
17896
19608
|
|
|
17897
19609
|
${highlighter.dim("Learn more:")}
|
|
17898
19610
|
${highlighter.info(CANONICAL_GITHUB_URL)}
|
|
17899
|
-
|
|
19611
|
+
`;
|
|
19612
|
+
const renderInstallHelpEpilog = () => `
|
|
19613
|
+
${highlighter.dim("Examples:")}
|
|
19614
|
+
${formatExampleLines([
|
|
19615
|
+
["react-doctor install", "interactive setup"],
|
|
19616
|
+
["react-doctor install --yes", "non-interactive; all detected agents"],
|
|
19617
|
+
["react-doctor install --dry-run", "preview without writing files"],
|
|
19618
|
+
["react-doctor install --agent-hooks", "also install native agent hooks"]
|
|
19619
|
+
])}
|
|
19620
|
+
|
|
19621
|
+
${highlighter.dim("Learn more:")}
|
|
19622
|
+
${highlighter.info(CANONICAL_GITHUB_URL)}
|
|
19623
|
+
`;
|
|
19624
|
+
const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead-code analysis (default)").option("--no-dead-code", "skip dead-code analysis (unused files / exports / dependencies, circular imports)").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--experimental-parallel [workers]", "experimental: lint with N parallel workers (default: auto-detect CPU cores) — speeds up large repos").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--changed-files-from <file>", "internal: scan source files listed in a newline-delimited changed-files file").option("--no-score", "skip the score API, the share URL, and crash reporting").option("--no-telemetry", "alias for --no-score (skip the score API, share URL, and crash reporting)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none (default: none)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--pr-comment", "tune CLI output for sticky PR comments (drops weak-signal rule families like `design` from the printed list and the fail-on gate; configure via config.surfaces)").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "alias for --explain").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").option("--warnings", "show warning-severity diagnostics (default)").option("--no-warnings", "hide warning-severity diagnostics (errors only)").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderRootHelpEpilog);
|
|
17900
19625
|
program.action(inspectAction);
|
|
17901
|
-
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()).action(installAction);
|
|
19626
|
+
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);
|
|
19627
|
+
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);
|
|
19628
|
+
const rules = program.command("rules").description("List, explain, and configure which React Doctor rules run");
|
|
19629
|
+
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()));
|
|
19630
|
+
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()));
|
|
19631
|
+
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()));
|
|
19632
|
+
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()));
|
|
19633
|
+
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()));
|
|
19634
|
+
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()));
|
|
19635
|
+
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()));
|
|
19636
|
+
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()));
|
|
17902
19637
|
process.stdout.on("error", (error) => {
|
|
17903
19638
|
if (error.code === "EPIPE") process.exit(0);
|
|
17904
19639
|
});
|
|
17905
|
-
program.
|
|
19640
|
+
const knownCommands = program.commands.flatMap((command) => [command.name(), ...command.aliases()]);
|
|
19641
|
+
const strippedArgv = stripUnknownCliFlags(process.argv);
|
|
19642
|
+
if (process.argv.includes("-V") && !strippedArgv.includes("-V")) {
|
|
19643
|
+
process.stdout.write(`${VERSION}\n`);
|
|
19644
|
+
process.exit(0);
|
|
19645
|
+
}
|
|
19646
|
+
applyColorPreference(strippedArgv);
|
|
19647
|
+
const argv = normalizeHelpInvocation(strippedArgv, knownCommands);
|
|
19648
|
+
program.parseAsync(argv).then(() => flushSentry()).catch(async (error) => {
|
|
19649
|
+
const sentryEventId = await reportErrorToSentry(error);
|
|
17906
19650
|
if (isJsonModeActive()) {
|
|
17907
19651
|
writeJsonErrorReport(error);
|
|
17908
19652
|
process.exit(1);
|
|
17909
19653
|
}
|
|
17910
|
-
handleError(error);
|
|
19654
|
+
handleError(error, { sentryEventId });
|
|
17911
19655
|
});
|
|
17912
19656
|
//#endregion
|
|
17913
19657
|
export {};
|
|
17914
19658
|
|
|
17915
|
-
//# sourceMappingURL=cli.js.map
|
|
19659
|
+
//# sourceMappingURL=cli.js.map
|
|
19660
|
+
//# debugId=d012dbb2-27e9-5092-9eab-c7fa4573f0b2
|