react-doctor 0.2.14-dev.6e59f10 → 0.2.14-dev.75c1f99
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 +48 -2
- package/dist/cli.js +2627 -388
- package/dist/index.d.ts +69 -9
- package/dist/index.js +754 -106
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +6 -2
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]="74dccde1-47a7-5718-88d7-ea8e5d7b463b")}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";
|
|
@@ -15,7 +17,10 @@ import * as Redacted from "effect/Redacted";
|
|
|
15
17
|
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
16
18
|
import * as Otlp from "effect/unstable/observability/Otlp";
|
|
17
19
|
import * as Context from "effect/Context";
|
|
20
|
+
import os, { tmpdir } from "node:os";
|
|
18
21
|
import * as Console from "effect/Console";
|
|
22
|
+
import { parseJSON5 } from "confbox";
|
|
23
|
+
import { createJiti } from "jiti";
|
|
19
24
|
import * as Fiber from "effect/Fiber";
|
|
20
25
|
import * as Filter from "effect/Filter";
|
|
21
26
|
import * as Option from "effect/Option";
|
|
@@ -27,10 +32,12 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
|
|
|
27
32
|
import * as NodePath from "@effect/platform-node-shared/NodePath";
|
|
28
33
|
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
29
34
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
30
|
-
import os, { tmpdir } from "node:os";
|
|
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;
|
|
@@ -5892,29 +5901,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
5892
5901
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
5893
5902
|
};
|
|
5894
5903
|
};
|
|
5895
|
-
const
|
|
5896
|
-
|
|
5904
|
+
const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
|
|
5905
|
+
const rootValue = select(rootPackageJson);
|
|
5906
|
+
if (rootValue !== null) return rootValue;
|
|
5897
5907
|
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
5898
|
-
if (patterns.length === 0) return
|
|
5908
|
+
if (patterns.length === 0) return null;
|
|
5899
5909
|
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
5900
5910
|
for (const pattern of patterns) {
|
|
5901
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
5911
|
+
const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
|
|
5902
5912
|
for (const workspaceDirectory of directories) {
|
|
5903
5913
|
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
5904
5914
|
visitedDirectories.add(workspaceDirectory);
|
|
5905
|
-
|
|
5915
|
+
const value = select(readPackageJson$1(path.join(workspaceDirectory, "package.json")));
|
|
5916
|
+
if (value !== null) return value;
|
|
5906
5917
|
}
|
|
5907
5918
|
}
|
|
5908
|
-
return
|
|
5919
|
+
return null;
|
|
5909
5920
|
};
|
|
5921
|
+
const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
|
|
5910
5922
|
const NAMES = new Set([
|
|
5911
5923
|
"react-native",
|
|
5912
5924
|
"react-native-tvos",
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5925
|
+
...new Set([
|
|
5926
|
+
"expo",
|
|
5927
|
+
"expo-router",
|
|
5928
|
+
"@expo/cli",
|
|
5929
|
+
"@expo/metro-config",
|
|
5930
|
+
"@expo/metro-runtime"
|
|
5931
|
+
]),
|
|
5918
5932
|
"react-native-windows",
|
|
5919
5933
|
"react-native-macos"
|
|
5920
5934
|
]);
|
|
@@ -5938,6 +5952,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
|
|
|
5938
5952
|
return false;
|
|
5939
5953
|
};
|
|
5940
5954
|
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
|
|
5955
|
+
const getExpoDependencySpec = (packageJson) => {
|
|
5956
|
+
const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
|
|
5957
|
+
return typeof spec === "string" ? spec : null;
|
|
5958
|
+
};
|
|
5959
|
+
const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
|
|
5941
5960
|
const getPreactVersion = (packageJson) => {
|
|
5942
5961
|
return {
|
|
5943
5962
|
...packageJson.peerDependencies,
|
|
@@ -6174,6 +6193,19 @@ const discoverProject = (directory) => {
|
|
|
6174
6193
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
6175
6194
|
const sourceFileCount = countSourceFiles(directory);
|
|
6176
6195
|
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
6196
|
+
let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
|
|
6197
|
+
if (expoVersion !== null && isCatalogReference(expoVersion)) {
|
|
6198
|
+
const catalogName = extractCatalogName(expoVersion);
|
|
6199
|
+
let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
|
|
6200
|
+
if (!resolvedExpoVersion) {
|
|
6201
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
6202
|
+
if (monorepoRoot) {
|
|
6203
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
6204
|
+
if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson$1(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
|
|
6205
|
+
}
|
|
6206
|
+
}
|
|
6207
|
+
expoVersion = resolvedExpoVersion ?? expoVersion;
|
|
6208
|
+
}
|
|
6177
6209
|
const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
|
|
6178
6210
|
const preactVersion = getPreactVersion(packageJson);
|
|
6179
6211
|
const projectInfo = {
|
|
@@ -6191,6 +6223,7 @@ const discoverProject = (directory) => {
|
|
|
6191
6223
|
preactVersion,
|
|
6192
6224
|
preactMajorVersion: parseReactMajor(preactVersion),
|
|
6193
6225
|
hasReactNativeWorkspace,
|
|
6226
|
+
expoVersion,
|
|
6194
6227
|
hasReanimated,
|
|
6195
6228
|
sourceFileCount
|
|
6196
6229
|
};
|
|
@@ -6266,7 +6299,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
|
|
|
6266
6299
|
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
6267
6300
|
const ENTERPRISE_CONTACT_URL = "https://react.doctor/enterprise";
|
|
6268
6301
|
const SHARE_BASE_URL = "https://react.doctor/share";
|
|
6269
|
-
const
|
|
6302
|
+
const DOCS_URL = "https://www.react.doctor/docs";
|
|
6303
|
+
const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
|
|
6270
6304
|
const FETCH_TIMEOUT_MS = 1e4;
|
|
6271
6305
|
const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
|
|
6272
6306
|
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
@@ -6284,11 +6318,19 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
6284
6318
|
"tsconfig.json",
|
|
6285
6319
|
"tsconfig.base.json",
|
|
6286
6320
|
"package.json",
|
|
6287
|
-
"
|
|
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",
|
|
6288
6329
|
"oxlint.json",
|
|
6289
6330
|
".oxlintrc.json"
|
|
6290
6331
|
];
|
|
6291
6332
|
const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
|
|
6333
|
+
const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
|
|
6292
6334
|
const SKILL_NAME = "react-doctor";
|
|
6293
6335
|
const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
6294
6336
|
const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
|
|
@@ -6301,6 +6343,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
|
6301
6343
|
"Accessibility",
|
|
6302
6344
|
"Maintainability"
|
|
6303
6345
|
];
|
|
6346
|
+
const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
|
|
6347
|
+
const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
|
|
6348
|
+
const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
|
|
6304
6349
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
6305
6350
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
6306
6351
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -6420,10 +6465,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
6420
6465
|
*/
|
|
6421
6466
|
const buildRuleSeverityControls = (config) => {
|
|
6422
6467
|
if (!config) return void 0;
|
|
6423
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
6468
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
6424
6469
|
return {
|
|
6425
6470
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
6426
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
6471
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
6472
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
6427
6473
|
};
|
|
6428
6474
|
};
|
|
6429
6475
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -6787,6 +6833,65 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
6787
6833
|
}
|
|
6788
6834
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
6789
6835
|
};
|
|
6836
|
+
const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
|
|
6837
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
6838
|
+
const findNearestPackageDirectory$1 = (filename) => {
|
|
6839
|
+
if (!filename) return null;
|
|
6840
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
6841
|
+
if (fromCache !== void 0) return fromCache;
|
|
6842
|
+
let currentDirectory = path.dirname(filename);
|
|
6843
|
+
while (true) {
|
|
6844
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
6845
|
+
let hasPackageJson = false;
|
|
6846
|
+
try {
|
|
6847
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
6848
|
+
} catch {
|
|
6849
|
+
hasPackageJson = false;
|
|
6850
|
+
}
|
|
6851
|
+
if (hasPackageJson) {
|
|
6852
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
6853
|
+
return currentDirectory;
|
|
6854
|
+
}
|
|
6855
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
6856
|
+
if (parentDirectory === currentDirectory) {
|
|
6857
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
6858
|
+
return null;
|
|
6859
|
+
}
|
|
6860
|
+
currentDirectory = parentDirectory;
|
|
6861
|
+
}
|
|
6862
|
+
};
|
|
6863
|
+
const readManifest = (packageJsonPath) => {
|
|
6864
|
+
try {
|
|
6865
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
6866
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
6867
|
+
return null;
|
|
6868
|
+
} catch {
|
|
6869
|
+
return null;
|
|
6870
|
+
}
|
|
6871
|
+
};
|
|
6872
|
+
const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
|
|
6873
|
+
const classifyByDirectoryCohort = (packageDirectory) => {
|
|
6874
|
+
let current = packageDirectory;
|
|
6875
|
+
while (true) {
|
|
6876
|
+
if (path.basename(current) === "apps") return "app";
|
|
6877
|
+
const parent = path.dirname(current);
|
|
6878
|
+
if (parent === current) return null;
|
|
6879
|
+
current = parent;
|
|
6880
|
+
}
|
|
6881
|
+
};
|
|
6882
|
+
const classifyPackageRole = (filename) => {
|
|
6883
|
+
if (!filename) return "unknown";
|
|
6884
|
+
const packageDirectory = findNearestPackageDirectory$1(filename);
|
|
6885
|
+
if (!packageDirectory) return "unknown";
|
|
6886
|
+
const cached = cachedRoleByPackageDirectory.get(packageDirectory);
|
|
6887
|
+
if (cached !== void 0) return cached;
|
|
6888
|
+
const manifest = readManifest(path.join(packageDirectory, "package.json"));
|
|
6889
|
+
let result;
|
|
6890
|
+
if (manifest && hasPublishContract(manifest)) result = "library";
|
|
6891
|
+
else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
|
|
6892
|
+
cachedRoleByPackageDirectory.set(packageDirectory, result);
|
|
6893
|
+
return result;
|
|
6894
|
+
};
|
|
6790
6895
|
/**
|
|
6791
6896
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
6792
6897
|
* accounting for the various shapes oxlint emits:
|
|
@@ -6949,6 +7054,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6949
7054
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
6950
7055
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
6951
7056
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
7057
|
+
const libraryFileCache = /* @__PURE__ */ new Map();
|
|
7058
|
+
const isLibraryFile = (filePath) => {
|
|
7059
|
+
let cached = libraryFileCache.get(filePath);
|
|
7060
|
+
if (cached === void 0) {
|
|
7061
|
+
cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
|
|
7062
|
+
libraryFileCache.set(filePath, cached);
|
|
7063
|
+
}
|
|
7064
|
+
return cached;
|
|
7065
|
+
};
|
|
6952
7066
|
const getFileLines = (filePath) => {
|
|
6953
7067
|
const cached = fileLinesCache.get(filePath);
|
|
6954
7068
|
if (cached !== void 0) return cached;
|
|
@@ -6975,6 +7089,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6975
7089
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
6976
7090
|
return false;
|
|
6977
7091
|
};
|
|
7092
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
7093
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
7094
|
+
return false;
|
|
7095
|
+
};
|
|
6978
7096
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
6979
7097
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
6980
7098
|
if (diagnostic.line <= 0) return false;
|
|
@@ -6989,8 +7107,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6989
7107
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
6990
7108
|
let current = diagnostic;
|
|
6991
7109
|
let explicitSeverityOverride;
|
|
7110
|
+
let explicitRuleOverride;
|
|
6992
7111
|
if (severityControls) {
|
|
6993
7112
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
7113
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
6994
7114
|
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
6995
7115
|
ruleKey,
|
|
6996
7116
|
category
|
|
@@ -6998,6 +7118,9 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6998
7118
|
if (explicitSeverityOverride === "off") return null;
|
|
6999
7119
|
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
7000
7120
|
}
|
|
7121
|
+
if (explicitRuleOverride === void 0) {
|
|
7122
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
7123
|
+
}
|
|
7001
7124
|
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
7002
7125
|
if (userConfig) {
|
|
7003
7126
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
@@ -7183,6 +7306,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
7183
7306
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
7184
7307
|
}).pipe(Effect.orDie));
|
|
7185
7308
|
/**
|
|
7309
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
7310
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
7311
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
7312
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
7313
|
+
*/
|
|
7314
|
+
const resolveScanConcurrency = (requested) => {
|
|
7315
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
7316
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
7317
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
7318
|
+
};
|
|
7319
|
+
/**
|
|
7186
7320
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
7187
7321
|
* startup so the eval harness can raise the budget under sandbox
|
|
7188
7322
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -7202,6 +7336,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
7202
7336
|
* tests that exercise the cap behavior.
|
|
7203
7337
|
*/
|
|
7204
7338
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
7339
|
+
/**
|
|
7340
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
7341
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
7342
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
7343
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
7344
|
+
* CI callers that never touch the flag:
|
|
7345
|
+
*
|
|
7346
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
7347
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
7348
|
+
* - a positive integer → that many workers (clamped)
|
|
7349
|
+
*
|
|
7350
|
+
* The resolved value is always within
|
|
7351
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
7352
|
+
*/
|
|
7353
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
7354
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
7355
|
+
if (raw === void 0) return 1;
|
|
7356
|
+
const normalized = raw.trim().toLowerCase();
|
|
7357
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
7358
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
7359
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
7360
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
7361
|
+
return resolveScanConcurrency(parsed);
|
|
7362
|
+
} }) {};
|
|
7205
7363
|
const DIAGNOSTIC_SURFACES = [
|
|
7206
7364
|
"cli",
|
|
7207
7365
|
"prComment",
|
|
@@ -7358,66 +7516,135 @@ const validateConfigTypes = (config) => {
|
|
|
7358
7516
|
const warn = (message) => {
|
|
7359
7517
|
Effect.runSync(Console.warn(message));
|
|
7360
7518
|
};
|
|
7361
|
-
const
|
|
7362
|
-
const
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
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"));
|
|
7380
7546
|
if (isPlainObject(packageJson)) {
|
|
7381
|
-
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
7382
|
-
if (isPlainObject(embeddedConfig)) return
|
|
7383
|
-
|
|
7384
|
-
|
|
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
|
+
}
|
|
7385
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;
|
|
7386
7585
|
}
|
|
7387
|
-
} catch {
|
|
7388
|
-
return null;
|
|
7389
7586
|
}
|
|
7390
|
-
|
|
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
|
+
};
|
|
7391
7597
|
};
|
|
7392
7598
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
7393
|
-
const
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
}
|
|
7401
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
7402
|
-
cachedConfigs.set(rootDirectory, null);
|
|
7403
|
-
return null;
|
|
7404
|
-
}
|
|
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;
|
|
7405
7606
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
7406
7607
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
7407
|
-
const
|
|
7408
|
-
if (
|
|
7409
|
-
|
|
7410
|
-
return ancestorConfig;
|
|
7411
|
-
}
|
|
7412
|
-
if (isProjectBoundary(ancestorDirectory)) {
|
|
7413
|
-
cachedConfigs.set(rootDirectory, null);
|
|
7414
|
-
return null;
|
|
7415
|
-
}
|
|
7608
|
+
const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
|
|
7609
|
+
if (ancestorResult.status === "found") return ancestorResult.loaded;
|
|
7610
|
+
if (isProjectBoundary(ancestorDirectory)) return null;
|
|
7416
7611
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
7417
7612
|
}
|
|
7418
|
-
cachedConfigs.set(rootDirectory, null);
|
|
7419
7613
|
return null;
|
|
7420
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
|
+
};
|
|
7421
7648
|
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
7422
7649
|
if (!config || !configSourceDirectory) return null;
|
|
7423
7650
|
const rawRootDir = config.rootDir;
|
|
@@ -7432,11 +7659,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
|
7432
7659
|
}
|
|
7433
7660
|
return resolvedRootDir;
|
|
7434
7661
|
};
|
|
7435
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
7662
|
+
const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
7436
7663
|
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
7437
7664
|
const reactSubprojects = discoverReactSubprojects(directory);
|
|
7438
7665
|
if (reactSubprojects.length === 0) return null;
|
|
7439
7666
|
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
7667
|
+
if (options.allowAmbiguous === true) return null;
|
|
7440
7668
|
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
7441
7669
|
};
|
|
7442
7670
|
/**
|
|
@@ -7444,13 +7672,13 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
7444
7672
|
* (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
|
|
7445
7673
|
*
|
|
7446
7674
|
* 1. Resolve the requested directory to absolute.
|
|
7447
|
-
* 2. Load `
|
|
7448
|
-
* if present.
|
|
7675
|
+
* 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
|
|
7449
7676
|
* 3. Honor `config.rootDir` to redirect the scan to a nested
|
|
7450
7677
|
* project root, if configured.
|
|
7451
7678
|
* 4. Walk into a nested React subproject when the requested
|
|
7452
7679
|
* directory has no `package.json` of its own (raises
|
|
7453
|
-
* `AmbiguousProjectError` when multiple candidates exist
|
|
7680
|
+
* `AmbiguousProjectError` when multiple candidates exist unless
|
|
7681
|
+
* the caller opts into keeping the wrapper directory).
|
|
7454
7682
|
*
|
|
7455
7683
|
* Throws `ProjectNotFoundError` when neither the requested directory
|
|
7456
7684
|
* nor any discoverable nested project has a `package.json`.
|
|
@@ -7462,14 +7690,14 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
7462
7690
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
7463
7691
|
* shell in agreement on what "the scan directory" means.
|
|
7464
7692
|
*/
|
|
7465
|
-
const resolveScanTarget = (requestedDirectory) => {
|
|
7693
|
+
const resolveScanTarget = async (requestedDirectory, options = {}) => {
|
|
7466
7694
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
7467
|
-
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
7695
|
+
const loadedConfig = await loadConfigWithSource(absoluteRequested);
|
|
7468
7696
|
const userConfig = loadedConfig?.config ?? null;
|
|
7469
7697
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
7470
7698
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
7471
7699
|
const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
|
|
7472
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
|
|
7700
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
|
|
7473
7701
|
if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
|
|
7474
7702
|
return {
|
|
7475
7703
|
resolvedDirectory,
|
|
@@ -7479,6 +7707,359 @@ const resolveScanTarget = (requestedDirectory) => {
|
|
|
7479
7707
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
7480
7708
|
};
|
|
7481
7709
|
};
|
|
7710
|
+
const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
|
|
7711
|
+
const buildExpoCheckContext = (rootDirectory, expoVersion) => {
|
|
7712
|
+
const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
|
|
7713
|
+
return {
|
|
7714
|
+
rootDirectory,
|
|
7715
|
+
packageJson,
|
|
7716
|
+
directDependencyNames: getDirectDependencyNames(packageJson),
|
|
7717
|
+
expoSdkMajor: getLowestDependencyMajor(expoVersion)
|
|
7718
|
+
};
|
|
7719
|
+
};
|
|
7720
|
+
const buildExpoDiagnostic = (input) => ({
|
|
7721
|
+
filePath: input.filePath ?? "package.json",
|
|
7722
|
+
plugin: "react-doctor",
|
|
7723
|
+
rule: input.rule,
|
|
7724
|
+
severity: input.severity ?? "warning",
|
|
7725
|
+
message: input.message,
|
|
7726
|
+
help: input.help,
|
|
7727
|
+
line: input.line ?? 0,
|
|
7728
|
+
column: input.column ?? 0,
|
|
7729
|
+
category: input.category ?? "Correctness"
|
|
7730
|
+
});
|
|
7731
|
+
const CRITICAL_OVERRIDE_NAMES = new Set([
|
|
7732
|
+
"@expo/cli",
|
|
7733
|
+
"@expo/config",
|
|
7734
|
+
"@expo/metro-config",
|
|
7735
|
+
"@expo/metro-runtime",
|
|
7736
|
+
"@expo/metro",
|
|
7737
|
+
"metro"
|
|
7738
|
+
]);
|
|
7739
|
+
const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
|
|
7740
|
+
const collectOverrideNames = (packageJson) => new Set([
|
|
7741
|
+
...Object.keys(packageJson.overrides ?? {}),
|
|
7742
|
+
...Object.keys(packageJson.resolutions ?? {}),
|
|
7743
|
+
...Object.keys(packageJson.pnpm?.overrides ?? {})
|
|
7744
|
+
]);
|
|
7745
|
+
const checkExpoDependencyOverrides = (context) => {
|
|
7746
|
+
const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
|
|
7747
|
+
if (overriddenCriticalNames.length === 0) return [];
|
|
7748
|
+
const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
|
|
7749
|
+
return [buildExpoDiagnostic({
|
|
7750
|
+
rule: "expo-no-conflicting-dependency-override",
|
|
7751
|
+
message: `package.json pins SDK-critical ${overriddenCriticalNames.length === 1 ? "package" : "packages"} via overrides/resolutions (${quotedNames}) — these versions are tied to the Expo SDK release and overriding them is unsupported and may break Metro or native builds`,
|
|
7752
|
+
help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
|
|
7753
|
+
})];
|
|
7754
|
+
};
|
|
7755
|
+
const isPathGitIgnored = (rootDirectory, absolutePath) => {
|
|
7756
|
+
const result = spawnSync("git", [
|
|
7757
|
+
"check-ignore",
|
|
7758
|
+
"-q",
|
|
7759
|
+
absolutePath
|
|
7760
|
+
], {
|
|
7761
|
+
cwd: rootDirectory,
|
|
7762
|
+
stdio: [
|
|
7763
|
+
"ignore",
|
|
7764
|
+
"ignore",
|
|
7765
|
+
"ignore"
|
|
7766
|
+
]
|
|
7767
|
+
});
|
|
7768
|
+
if (result.error) return null;
|
|
7769
|
+
if (result.status === 0) return true;
|
|
7770
|
+
if (result.status === 1) return false;
|
|
7771
|
+
return null;
|
|
7772
|
+
};
|
|
7773
|
+
const LOCAL_ENV_FILE_NAMES = [
|
|
7774
|
+
".env.local",
|
|
7775
|
+
".env.development.local",
|
|
7776
|
+
".env.production.local",
|
|
7777
|
+
".env.test.local"
|
|
7778
|
+
];
|
|
7779
|
+
const checkExpoEnvLocalFiles = (context) => {
|
|
7780
|
+
const { rootDirectory } = context;
|
|
7781
|
+
const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
|
|
7782
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
7783
|
+
if (!isFile(filePath)) return false;
|
|
7784
|
+
return isPathGitIgnored(rootDirectory, filePath) === false;
|
|
7785
|
+
});
|
|
7786
|
+
if (committedEnvFiles.length === 0) return [];
|
|
7787
|
+
return [buildExpoDiagnostic({
|
|
7788
|
+
rule: "expo-env-local-not-gitignored",
|
|
7789
|
+
category: "Security",
|
|
7790
|
+
message: `Local environment ${committedEnvFiles.length === 1 ? "file" : "files"} (${committedEnvFiles.join(", ")}) ${committedEnvFiles.length === 1 ? "is" : "are"} not ignored by Git — committing \`.env*.local\` risks leaking secrets and overriding committed defaults for everyone who clones the project`,
|
|
7791
|
+
help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
|
|
7792
|
+
})];
|
|
7793
|
+
};
|
|
7794
|
+
const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
|
|
7795
|
+
const UNIMODULES_HELP = "Remove every `@unimodules/*` and `react-native-unimodules` package — their functionality now lives in `expo-modules-core`. See https://expo.fyi/r/sdk-44-remove-unimodules";
|
|
7796
|
+
const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
|
|
7797
|
+
const unimodulesEntry = (packageName) => ({
|
|
7798
|
+
packageName,
|
|
7799
|
+
rule: "expo-no-unimodules-packages",
|
|
7800
|
+
message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
|
|
7801
|
+
help: UNIMODULES_HELP
|
|
7802
|
+
});
|
|
7803
|
+
const FLAGGED_DEPENDENCIES = [
|
|
7804
|
+
unimodulesEntry("@unimodules/core"),
|
|
7805
|
+
unimodulesEntry("@unimodules/react-native-adapter"),
|
|
7806
|
+
unimodulesEntry("react-native-unimodules"),
|
|
7807
|
+
{
|
|
7808
|
+
packageName: "expo-cli",
|
|
7809
|
+
rule: "expo-no-cli-dependencies",
|
|
7810
|
+
message: "`expo-cli` (the legacy global CLI) is a project dependency — the CLI now ships inside the `expo` package, and keeping `expo-cli` causes failures such as `unknown option --fix` when running `npx expo install --fix`",
|
|
7811
|
+
help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
|
|
7812
|
+
},
|
|
7813
|
+
{
|
|
7814
|
+
packageName: "eas-cli",
|
|
7815
|
+
rule: "expo-no-cli-dependencies",
|
|
7816
|
+
message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
|
|
7817
|
+
help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
|
|
7818
|
+
},
|
|
7819
|
+
{
|
|
7820
|
+
packageName: "expo-modules-autolinking",
|
|
7821
|
+
rule: "expo-no-redundant-dependency",
|
|
7822
|
+
message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
|
|
7823
|
+
help: "Remove `expo-modules-autolinking` from your package.json"
|
|
7824
|
+
},
|
|
7825
|
+
{
|
|
7826
|
+
packageName: "expo-dev-launcher",
|
|
7827
|
+
rule: "expo-no-redundant-dependency",
|
|
7828
|
+
message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7829
|
+
help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
|
|
7830
|
+
},
|
|
7831
|
+
{
|
|
7832
|
+
packageName: "expo-dev-menu",
|
|
7833
|
+
rule: "expo-no-redundant-dependency",
|
|
7834
|
+
message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7835
|
+
help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
|
|
7836
|
+
},
|
|
7837
|
+
{
|
|
7838
|
+
packageName: "expo-modules-core",
|
|
7839
|
+
rule: "expo-no-redundant-dependency",
|
|
7840
|
+
message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
|
|
7841
|
+
help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
|
|
7842
|
+
},
|
|
7843
|
+
{
|
|
7844
|
+
packageName: "@expo/metro-config",
|
|
7845
|
+
rule: "expo-no-redundant-dependency",
|
|
7846
|
+
message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
|
|
7847
|
+
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
|
|
7848
|
+
},
|
|
7849
|
+
{
|
|
7850
|
+
packageName: "@types/react-native",
|
|
7851
|
+
rule: "expo-no-redundant-dependency",
|
|
7852
|
+
message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
|
|
7853
|
+
help: "Remove `@types/react-native` from your package.json",
|
|
7854
|
+
minSdkMajor: 48
|
|
7855
|
+
},
|
|
7856
|
+
{
|
|
7857
|
+
packageName: "@expo/config-plugins",
|
|
7858
|
+
rule: "expo-no-redundant-dependency",
|
|
7859
|
+
message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
|
|
7860
|
+
help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
|
|
7861
|
+
minSdkMajor: 48
|
|
7862
|
+
},
|
|
7863
|
+
{
|
|
7864
|
+
packageName: "@expo/prebuild-config",
|
|
7865
|
+
rule: "expo-no-redundant-dependency",
|
|
7866
|
+
message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
|
|
7867
|
+
help: "Remove `@expo/prebuild-config` from your package.json",
|
|
7868
|
+
minSdkMajor: 53
|
|
7869
|
+
},
|
|
7870
|
+
{
|
|
7871
|
+
packageName: "expo-permissions",
|
|
7872
|
+
rule: "expo-no-redundant-dependency",
|
|
7873
|
+
message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
|
|
7874
|
+
help: "Remove `expo-permissions` and request permissions from the relevant module instead",
|
|
7875
|
+
minSdkMajor: 50
|
|
7876
|
+
},
|
|
7877
|
+
{
|
|
7878
|
+
packageName: "expo-app-loading",
|
|
7879
|
+
rule: "expo-no-redundant-dependency",
|
|
7880
|
+
message: "\"expo-app-loading\" was removed in SDK 49",
|
|
7881
|
+
help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
|
|
7882
|
+
minSdkMajor: 49
|
|
7883
|
+
},
|
|
7884
|
+
{
|
|
7885
|
+
packageName: "expo-firebase-analytics",
|
|
7886
|
+
rule: "expo-no-redundant-dependency",
|
|
7887
|
+
message: "\"expo-firebase-analytics\" was removed in SDK 48",
|
|
7888
|
+
help: FIREBASE_HELP,
|
|
7889
|
+
minSdkMajor: 48
|
|
7890
|
+
},
|
|
7891
|
+
{
|
|
7892
|
+
packageName: "expo-firebase-recaptcha",
|
|
7893
|
+
rule: "expo-no-redundant-dependency",
|
|
7894
|
+
message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
|
|
7895
|
+
help: FIREBASE_HELP,
|
|
7896
|
+
minSdkMajor: 48
|
|
7897
|
+
},
|
|
7898
|
+
{
|
|
7899
|
+
packageName: "expo-firebase-core",
|
|
7900
|
+
rule: "expo-no-redundant-dependency",
|
|
7901
|
+
message: "\"expo-firebase-core\" was removed in SDK 48",
|
|
7902
|
+
help: FIREBASE_HELP,
|
|
7903
|
+
minSdkMajor: 48
|
|
7904
|
+
}
|
|
7905
|
+
];
|
|
7906
|
+
const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
|
|
7907
|
+
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
|
|
7908
|
+
if (flaggedDependency.minSdkMajor === void 0) return true;
|
|
7909
|
+
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
|
|
7910
|
+
}).map((flaggedDependency) => buildExpoDiagnostic({
|
|
7911
|
+
rule: flaggedDependency.rule,
|
|
7912
|
+
message: flaggedDependency.message,
|
|
7913
|
+
help: flaggedDependency.help
|
|
7914
|
+
}));
|
|
7915
|
+
const findLocalModuleNativeFiles = (rootDirectory) => {
|
|
7916
|
+
const modulesDirectory = path.join(rootDirectory, "modules");
|
|
7917
|
+
if (!isDirectory(modulesDirectory)) return [];
|
|
7918
|
+
const nativeFilePaths = [];
|
|
7919
|
+
for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
|
|
7920
|
+
if (!moduleEntry.isDirectory()) continue;
|
|
7921
|
+
const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
|
|
7922
|
+
const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
|
|
7923
|
+
if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
|
|
7924
|
+
const iosDirectory = path.join(moduleDirectory, "ios");
|
|
7925
|
+
if (isDirectory(iosDirectory)) {
|
|
7926
|
+
for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
|
|
7927
|
+
}
|
|
7928
|
+
}
|
|
7929
|
+
return nativeFilePaths;
|
|
7930
|
+
};
|
|
7931
|
+
const checkExpoGitignore = (context) => {
|
|
7932
|
+
const { rootDirectory } = context;
|
|
7933
|
+
const diagnostics = [];
|
|
7934
|
+
const expoStateDirectory = path.join(rootDirectory, ".expo");
|
|
7935
|
+
if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
|
|
7936
|
+
rule: "expo-gitignore",
|
|
7937
|
+
message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
|
|
7938
|
+
help: "Add `.expo/` to your .gitignore"
|
|
7939
|
+
}));
|
|
7940
|
+
if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
|
|
7941
|
+
rule: "expo-gitignore",
|
|
7942
|
+
message: "The native `ios`/`android` directories of a local Expo module under `modules/` are gitignored — usually caused by an overly broad `ios`/`android` ignore rule",
|
|
7943
|
+
help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
|
|
7944
|
+
}));
|
|
7945
|
+
return diagnostics;
|
|
7946
|
+
};
|
|
7947
|
+
const LOCKFILE_NAMES = [
|
|
7948
|
+
"pnpm-lock.yaml",
|
|
7949
|
+
"yarn.lock",
|
|
7950
|
+
"package-lock.json",
|
|
7951
|
+
"bun.lockb",
|
|
7952
|
+
"bun.lock"
|
|
7953
|
+
];
|
|
7954
|
+
const checkExpoLockfile = (context) => {
|
|
7955
|
+
const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
|
|
7956
|
+
const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
|
|
7957
|
+
if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
|
|
7958
|
+
rule: "expo-lockfile",
|
|
7959
|
+
message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
|
|
7960
|
+
help: "Install dependencies with your package manager to generate a lock file, then commit it"
|
|
7961
|
+
})];
|
|
7962
|
+
if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
|
|
7963
|
+
rule: "expo-lockfile",
|
|
7964
|
+
message: `Multiple lock files detected (${presentLockfiles.join(", ")}) — CI environments such as EAS Build infer the package manager from the lock file, so this is ambiguous`,
|
|
7965
|
+
help: "Delete the lock files for the package managers you are not using and keep only one"
|
|
7966
|
+
})];
|
|
7967
|
+
return [];
|
|
7968
|
+
};
|
|
7969
|
+
const METRO_CONFIG_FILE_NAMES = [
|
|
7970
|
+
"metro.config.js",
|
|
7971
|
+
"metro.config.cjs",
|
|
7972
|
+
"metro.config.mjs",
|
|
7973
|
+
"metro.config.ts"
|
|
7974
|
+
];
|
|
7975
|
+
const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
|
|
7976
|
+
"expo/metro-config",
|
|
7977
|
+
"@sentry/react-native/metro",
|
|
7978
|
+
"getSentryExpoConfig"
|
|
7979
|
+
];
|
|
7980
|
+
const checkExpoMetroConfig = (context) => {
|
|
7981
|
+
const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
|
|
7982
|
+
if (metroConfigPath === void 0) return [];
|
|
7983
|
+
let contents;
|
|
7984
|
+
try {
|
|
7985
|
+
contents = fs.readFileSync(metroConfigPath, "utf-8");
|
|
7986
|
+
} catch {
|
|
7987
|
+
return [];
|
|
7988
|
+
}
|
|
7989
|
+
if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
|
|
7990
|
+
return [buildExpoDiagnostic({
|
|
7991
|
+
rule: "expo-metro-config",
|
|
7992
|
+
filePath: path.basename(metroConfigPath),
|
|
7993
|
+
message: "Your metro.config does not extend `expo/metro-config` — a custom Metro config that doesn't extend Expo's leads to unexpected, hard-to-debug bundling issues",
|
|
7994
|
+
help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
|
|
7995
|
+
})];
|
|
7996
|
+
};
|
|
7997
|
+
const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
|
|
7998
|
+
const checkExpoPackageJsonConflicts = (context) => {
|
|
7999
|
+
const { packageJson } = context;
|
|
8000
|
+
const diagnostics = [];
|
|
8001
|
+
const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
|
|
8002
|
+
if (conflictingScriptNames.length > 0) {
|
|
8003
|
+
const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
|
|
8004
|
+
const shadowsExpoCli = conflictingScriptNames.includes("expo");
|
|
8005
|
+
diagnostics.push(buildExpoDiagnostic({
|
|
8006
|
+
rule: "expo-package-json-conflict",
|
|
8007
|
+
message: `package.json defines ${quotedNames} ${conflictingScriptNames.length === 1 ? "as a script that conflicts" : "as scripts that conflict"} with binaries in node_modules/.bin${shadowsExpoCli ? " — a `expo` script shadows the Expo CLI and will likely cause build failures" : ""}`,
|
|
8008
|
+
help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
|
|
8009
|
+
}));
|
|
8010
|
+
}
|
|
8011
|
+
const packageName = packageJson.name;
|
|
8012
|
+
if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
|
|
8013
|
+
rule: "expo-package-json-conflict",
|
|
8014
|
+
message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
|
|
8015
|
+
help: "Rename your package so it no longer matches one of its dependencies"
|
|
8016
|
+
}));
|
|
8017
|
+
return diagnostics;
|
|
8018
|
+
};
|
|
8019
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
8020
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
8021
|
+
const checkExpoRouterReactNavigation = (context) => {
|
|
8022
|
+
const { expoSdkMajor } = context;
|
|
8023
|
+
if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
|
|
8024
|
+
if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
|
|
8025
|
+
if (!context.directDependencyNames.has("expo-router")) return [];
|
|
8026
|
+
const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
|
|
8027
|
+
if (reactNavigationNames.length === 0) return [];
|
|
8028
|
+
return [buildExpoDiagnostic({
|
|
8029
|
+
rule: "expo-router-no-react-navigation",
|
|
8030
|
+
message: `As of SDK 56, expo-router is no longer compatible with react-navigation, but ${reactNavigationNames.map((name) => `"${name}"`).join(", ")} ${reactNavigationNames.length === 1 ? "is" : "are"} installed as direct ${reactNavigationNames.length === 1 ? "dependency" : "dependencies"}`,
|
|
8031
|
+
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/"
|
|
8032
|
+
})];
|
|
8033
|
+
};
|
|
8034
|
+
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
8035
|
+
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
8036
|
+
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
8037
|
+
const checkExpoVectorIcons = (context) => {
|
|
8038
|
+
if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
|
|
8039
|
+
const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
|
|
8040
|
+
const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
|
|
8041
|
+
if (!hasScopedPackage || !hasConflictingPackage) return [];
|
|
8042
|
+
return [buildExpoDiagnostic({
|
|
8043
|
+
rule: "expo-vector-icons-conflict",
|
|
8044
|
+
message: "This project installs both the scoped `@react-native-vector-icons/*` packages and `@expo/vector-icons` (or the deprecated `react-native-vector-icons`) — mixing them causes icon-rendering conflicts",
|
|
8045
|
+
help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
|
|
8046
|
+
})];
|
|
8047
|
+
};
|
|
8048
|
+
const checkExpoProject = (rootDirectory, project) => {
|
|
8049
|
+
if (project.expoVersion === null) return [];
|
|
8050
|
+
const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
|
|
8051
|
+
return [
|
|
8052
|
+
...checkExpoFlaggedDependencies(context),
|
|
8053
|
+
...checkExpoDependencyOverrides(context),
|
|
8054
|
+
...checkExpoRouterReactNavigation(context),
|
|
8055
|
+
...checkExpoVectorIcons(context),
|
|
8056
|
+
...checkExpoPackageJsonConflicts(context),
|
|
8057
|
+
...checkExpoLockfile(context),
|
|
8058
|
+
...checkExpoGitignore(context),
|
|
8059
|
+
...checkExpoEnvLocalFiles(context),
|
|
8060
|
+
...checkExpoMetroConfig(context)
|
|
8061
|
+
];
|
|
8062
|
+
};
|
|
7482
8063
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
7483
8064
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
7484
8065
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -7726,6 +8307,28 @@ const collectIgnorePatterns = (rootDirectory) => {
|
|
|
7726
8307
|
cachedPatternsByRoot.set(rootDirectory, patterns);
|
|
7727
8308
|
return patterns;
|
|
7728
8309
|
};
|
|
8310
|
+
/**
|
|
8311
|
+
* Resolves a path to its canonical, symlink-free form, falling back to
|
|
8312
|
+
* the input when it cannot be realpath'd (broken symlink, permission
|
|
8313
|
+
* error) so a best-effort normalization never throws.
|
|
8314
|
+
*
|
|
8315
|
+
* deslop's dead-code module graph is collected with `fast-glob` (which
|
|
8316
|
+
* keeps the scan root's symlinks intact) while imports are resolved
|
|
8317
|
+
* through `oxc-resolver` (which returns realpath'd targets). When the
|
|
8318
|
+
* project root sits behind a symlink — e.g. macOS iCloud-synced
|
|
8319
|
+
* `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
|
|
8320
|
+
* spaces diverge: every resolved import misses the graph and the files
|
|
8321
|
+
* they point at (commonly every `@/…` alias target) are mis-reported as
|
|
8322
|
+
* unreachable. Canonicalizing the root before the scan keeps both path
|
|
8323
|
+
* spaces in agreement.
|
|
8324
|
+
*/
|
|
8325
|
+
const toCanonicalPath = (filePath) => {
|
|
8326
|
+
try {
|
|
8327
|
+
return fs.realpathSync(filePath);
|
|
8328
|
+
} catch {
|
|
8329
|
+
return filePath;
|
|
8330
|
+
}
|
|
8331
|
+
};
|
|
7729
8332
|
const DEAD_CODE_PLUGIN = "deslop";
|
|
7730
8333
|
const DEAD_CODE_CATEGORY = "Maintainability";
|
|
7731
8334
|
const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
|
|
@@ -7982,7 +8585,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
|
|
|
7982
8585
|
});
|
|
7983
8586
|
});
|
|
7984
8587
|
const checkDeadCode = async (options) => {
|
|
7985
|
-
const {
|
|
8588
|
+
const { userConfig } = options;
|
|
8589
|
+
const rootDirectory = toCanonicalPath(options.rootDirectory);
|
|
7986
8590
|
if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
|
|
7987
8591
|
const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
|
|
7988
8592
|
const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
|
|
@@ -8135,8 +8739,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
|
8135
8739
|
const cache = yield* Cache.make({
|
|
8136
8740
|
capacity: 16,
|
|
8137
8741
|
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
8138
|
-
lookup: (directory) => Effect.
|
|
8139
|
-
const loaded = loadConfigWithSource(directory);
|
|
8742
|
+
lookup: (directory) => Effect.promise(async () => {
|
|
8743
|
+
const loaded = await loadConfigWithSource(directory);
|
|
8140
8744
|
const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
|
|
8141
8745
|
return {
|
|
8142
8746
|
config: loaded?.config ?? null,
|
|
@@ -8733,6 +9337,7 @@ const buildCapabilities = (project) => {
|
|
|
8733
9337
|
const capabilities = /* @__PURE__ */ new Set();
|
|
8734
9338
|
capabilities.add(project.framework);
|
|
8735
9339
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
9340
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
8736
9341
|
const reactMajor = project.reactMajorVersion;
|
|
8737
9342
|
if (reactMajor !== null) {
|
|
8738
9343
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -8904,10 +9509,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
8904
9509
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
8905
9510
|
return fs.realpathSync(rootDirectory);
|
|
8906
9511
|
};
|
|
9512
|
+
const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
|
|
9513
|
+
if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
|
|
9514
|
+
return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
9515
|
+
};
|
|
8907
9516
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
8908
9517
|
const enabledRules = {};
|
|
8909
9518
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
8910
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
9519
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
8911
9520
|
if (severity === "off") continue;
|
|
8912
9521
|
enabledRules[ruleKey] = severity;
|
|
8913
9522
|
}
|
|
@@ -8949,7 +9558,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
8949
9558
|
category: rule.category
|
|
8950
9559
|
}, severityControls);
|
|
8951
9560
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
8952
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
9561
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
8953
9562
|
if (severity === "off") continue;
|
|
8954
9563
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
8955
9564
|
}
|
|
@@ -9006,6 +9615,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
9006
9615
|
}
|
|
9007
9616
|
return uniqueDiagnostics;
|
|
9008
9617
|
};
|
|
9618
|
+
/**
|
|
9619
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
9620
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
9621
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
9622
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
9623
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
9624
|
+
* batches do).
|
|
9625
|
+
*
|
|
9626
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
9627
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
9628
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
9629
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
9630
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
9631
|
+
* top of a still-running first one.
|
|
9632
|
+
*/
|
|
9633
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
9634
|
+
const results = new Array(items.length);
|
|
9635
|
+
if (items.length === 0) return results;
|
|
9636
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
9637
|
+
let nextIndex = 0;
|
|
9638
|
+
const errors = [];
|
|
9639
|
+
const runWorker = async () => {
|
|
9640
|
+
while (errors.length === 0) {
|
|
9641
|
+
const index = nextIndex;
|
|
9642
|
+
nextIndex += 1;
|
|
9643
|
+
if (index >= items.length) return;
|
|
9644
|
+
try {
|
|
9645
|
+
results[index] = await task(items[index], index);
|
|
9646
|
+
} catch (error) {
|
|
9647
|
+
errors.push(error);
|
|
9648
|
+
return;
|
|
9649
|
+
}
|
|
9650
|
+
}
|
|
9651
|
+
};
|
|
9652
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
9653
|
+
if (errors.length > 0) throw errors[0];
|
|
9654
|
+
return results;
|
|
9655
|
+
};
|
|
9009
9656
|
const getPublicEnvPrefix = (framework) => {
|
|
9010
9657
|
switch (framework) {
|
|
9011
9658
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -9688,6 +10335,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
9688
10335
|
*/
|
|
9689
10336
|
const spawnLintBatches = async (input) => {
|
|
9690
10337
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
10338
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
9691
10339
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
9692
10340
|
const allDiagnostics = [];
|
|
9693
10341
|
const droppedFiles = [];
|
|
@@ -9707,23 +10355,31 @@ const spawnLintBatches = async (input) => {
|
|
|
9707
10355
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
9708
10356
|
}
|
|
9709
10357
|
};
|
|
10358
|
+
let startedFileCount = 0;
|
|
9710
10359
|
let scannedFileCount = 0;
|
|
9711
|
-
|
|
9712
|
-
|
|
9713
|
-
const
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
|
|
9718
|
-
|
|
9719
|
-
|
|
10360
|
+
let displayedFileCount = 0;
|
|
10361
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
10362
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
10363
|
+
if (displayedFileCount < ceiling) {
|
|
10364
|
+
displayedFileCount += 1;
|
|
10365
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10366
|
+
}
|
|
10367
|
+
}, 50) : null;
|
|
10368
|
+
progressTimer?.unref?.();
|
|
10369
|
+
try {
|
|
10370
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
10371
|
+
startedFileCount += batch.length;
|
|
9720
10372
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
9721
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
9722
10373
|
scannedFileCount += batch.length;
|
|
9723
|
-
onFileProgress
|
|
9724
|
-
|
|
9725
|
-
|
|
9726
|
-
|
|
10374
|
+
if (onFileProgress) {
|
|
10375
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
10376
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10377
|
+
}
|
|
10378
|
+
return batchDiagnostics;
|
|
10379
|
+
});
|
|
10380
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
10381
|
+
} finally {
|
|
10382
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
9727
10383
|
}
|
|
9728
10384
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
9729
10385
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -9850,7 +10506,8 @@ const runOxlint = async (options) => {
|
|
|
9850
10506
|
onPartialFailure,
|
|
9851
10507
|
onFileProgress: options.onFileProgress,
|
|
9852
10508
|
spawnTimeoutMs,
|
|
9853
|
-
outputMaxBytes
|
|
10509
|
+
outputMaxBytes,
|
|
10510
|
+
concurrency: options.concurrency
|
|
9854
10511
|
});
|
|
9855
10512
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
9856
10513
|
try {
|
|
@@ -9918,6 +10575,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9918
10575
|
const partialFailures = yield* LintPartialFailures;
|
|
9919
10576
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
9920
10577
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
10578
|
+
const concurrency = yield* OxlintConcurrency;
|
|
9921
10579
|
const collectedFailures = [];
|
|
9922
10580
|
const diagnostics = yield* Effect.tryPromise({
|
|
9923
10581
|
try: () => runOxlint({
|
|
@@ -9936,7 +10594,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9936
10594
|
},
|
|
9937
10595
|
onFileProgress: input.onFileProgress,
|
|
9938
10596
|
spawnTimeoutMs,
|
|
9939
|
-
outputMaxBytes
|
|
10597
|
+
outputMaxBytes,
|
|
10598
|
+
concurrency
|
|
9940
10599
|
}),
|
|
9941
10600
|
catch: ensureReactDoctorError
|
|
9942
10601
|
});
|
|
@@ -10260,7 +10919,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10260
10919
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
10261
10920
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
10262
10921
|
const isDiffMode = input.includePaths.length > 0;
|
|
10263
|
-
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ??
|
|
10922
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
10264
10923
|
const transform = buildDiagnosticPipeline({
|
|
10265
10924
|
rootDirectory: scanDirectory,
|
|
10266
10925
|
userConfig: resolvedConfig.config,
|
|
@@ -10269,7 +10928,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10269
10928
|
showWarnings
|
|
10270
10929
|
});
|
|
10271
10930
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
10272
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10931
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10932
|
+
...checkReducedMotion(scanDirectory),
|
|
10933
|
+
...checkPnpmHardening(scanDirectory),
|
|
10934
|
+
...checkExpoProject(scanDirectory, project)
|
|
10935
|
+
];
|
|
10273
10936
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
10274
10937
|
const lintFailure = yield* Ref.make({
|
|
10275
10938
|
didFail: false,
|
|
@@ -10281,6 +10944,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10281
10944
|
didFail: false,
|
|
10282
10945
|
reason: null
|
|
10283
10946
|
});
|
|
10947
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
10948
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
10284
10949
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
10285
10950
|
const scanStartTime = Date.now();
|
|
10286
10951
|
let lastReportedTotalFileCount = 0;
|
|
@@ -10297,7 +10962,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10297
10962
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
10298
10963
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
10299
10964
|
lastReportedTotalFileCount = totalFileCount;
|
|
10300
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
10965
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
10301
10966
|
}
|
|
10302
10967
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
10303
10968
|
yield* Ref.set(lintFailure, {
|
|
@@ -10329,7 +10994,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10329
10994
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
10330
10995
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
10331
10996
|
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
10332
|
-
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
|
|
10997
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
10333
10998
|
yield* reporterService.finalize;
|
|
10334
10999
|
const finalDiagnostics = [
|
|
10335
11000
|
...envCollected,
|
|
@@ -10381,7 +11046,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10381
11046
|
"inspect.isCi": input.isCi,
|
|
10382
11047
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
10383
11048
|
} }));
|
|
10384
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
10385
11049
|
const parseNodeVersion = (versionString) => {
|
|
10386
11050
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
10387
11051
|
return {
|
|
@@ -10704,6 +11368,26 @@ const buildJsonReport = (input) => {
|
|
|
10704
11368
|
};
|
|
10705
11369
|
};
|
|
10706
11370
|
/**
|
|
11371
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
11372
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
11373
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
11374
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
11375
|
+
* branch logic lives here once.
|
|
11376
|
+
*/
|
|
11377
|
+
const buildSkippedChecks = (input) => {
|
|
11378
|
+
const skippedChecks = [];
|
|
11379
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
11380
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
11381
|
+
const skippedCheckReasons = {};
|
|
11382
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
11383
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
11384
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
11385
|
+
return {
|
|
11386
|
+
skippedChecks,
|
|
11387
|
+
skippedCheckReasons
|
|
11388
|
+
};
|
|
11389
|
+
};
|
|
11390
|
+
/**
|
|
10707
11391
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
10708
11392
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
10709
11393
|
* spawn, not `spawnSync`).
|
|
@@ -10808,12 +11492,32 @@ const highlighter = {
|
|
|
10808
11492
|
bold: import_picocolors.default.bold
|
|
10809
11493
|
};
|
|
10810
11494
|
/**
|
|
10811
|
-
*
|
|
10812
|
-
* `
|
|
10813
|
-
*
|
|
10814
|
-
*
|
|
11495
|
+
* Override picocolors' automatic color detection. picocolors decides
|
|
11496
|
+
* once, at import time, from `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY.
|
|
11497
|
+
* This lets the CLI honor an explicit `--color` / `--no-color` flag
|
|
11498
|
+
* (clig.dev, Output: "Disable color … if the user requested it") by
|
|
11499
|
+
* swapping in a fresh set of formatters. Call it before any colored
|
|
11500
|
+
* output is produced. Every call site reads `highlighter.<method>` at
|
|
11501
|
+
* call time, so reassigning the properties propagates everywhere.
|
|
11502
|
+
*/
|
|
11503
|
+
const setColorEnabled = (enabled) => {
|
|
11504
|
+
const colors = import_picocolors.default.createColors(enabled);
|
|
11505
|
+
highlighter.error = colors.red;
|
|
11506
|
+
highlighter.warn = colors.yellow;
|
|
11507
|
+
highlighter.info = colors.cyan;
|
|
11508
|
+
highlighter.success = colors.green;
|
|
11509
|
+
highlighter.dim = colors.dim;
|
|
11510
|
+
highlighter.gray = colors.gray;
|
|
11511
|
+
highlighter.bold = colors.bold;
|
|
11512
|
+
};
|
|
11513
|
+
/**
|
|
11514
|
+
* Canonical URL for a rule's documentation page — its reviewer-tested fix
|
|
11515
|
+
* recipe rendered for humans — served at
|
|
11516
|
+
* `https://www.react.doctor/docs/rules/<plugin>/<rule>`. The CLI links here
|
|
11517
|
+
* from its fix-recipe directive so each fix follows the canonical recipe
|
|
11518
|
+
* instead of being improvised per diagnostic.
|
|
10815
11519
|
*/
|
|
10816
|
-
const
|
|
11520
|
+
const buildRuleDocsUrl = (plugin, rule) => `${DOCS_RULES_BASE_URL}/${plugin}/${rule}`;
|
|
10817
11521
|
const groupBy = (items, keyFn) => {
|
|
10818
11522
|
const groups = /* @__PURE__ */ new Map();
|
|
10819
11523
|
for (const item of items) {
|
|
@@ -10824,40 +11528,699 @@ const groupBy = (items, keyFn) => {
|
|
|
10824
11528
|
}
|
|
10825
11529
|
return groups;
|
|
10826
11530
|
};
|
|
11531
|
+
/**
|
|
11532
|
+
* Whether a diagnostic's rule has a published per-rule fix recipe at
|
|
11533
|
+
* `${DOCS_RULES_BASE_URL}/react-doctor/<rule>`
|
|
11534
|
+
* (see `buildRuleDocsUrl`).
|
|
11535
|
+
*
|
|
11536
|
+
* Recipes are generated from react-doctor's own engine rules, so only
|
|
11537
|
+
* those resolve. Dead-code (`deslop`), the synthetic environment and
|
|
11538
|
+
* supply-chain checks (`require-reduced-motion`, `require-pnpm-hardening`
|
|
11539
|
+
* — `react-doctor`-namespaced but not engine rules), and adopted
|
|
11540
|
+
* third-party plugins (`eslint`, `unicorn`, `react-hooks-js`, …) have no
|
|
11541
|
+
* recipe, so advertising "fetch the fix recipe" for them sends agents to
|
|
11542
|
+
* a 404. Gate the directive on this predicate.
|
|
11543
|
+
*/
|
|
11544
|
+
const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
|
|
10827
11545
|
//#endregion
|
|
10828
|
-
//#region
|
|
10829
|
-
const
|
|
10830
|
-
|
|
10831
|
-
|
|
10832
|
-
|
|
10833
|
-
|
|
10834
|
-
|
|
10835
|
-
|
|
10836
|
-
|
|
10837
|
-
|
|
10838
|
-
|
|
10839
|
-
|
|
10840
|
-
|
|
10841
|
-
|
|
10842
|
-
|
|
10843
|
-
|
|
10844
|
-
|
|
10845
|
-
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
|
|
10852
|
-
|
|
10853
|
-
|
|
10854
|
-
|
|
10855
|
-
|
|
10856
|
-
|
|
10857
|
-
|
|
10858
|
-
|
|
10859
|
-
|
|
10860
|
-
|
|
11546
|
+
//#region src/cli/utils/is-ci-environment.ts
|
|
11547
|
+
const CI_ENVIRONMENT_VARIABLES = [
|
|
11548
|
+
"GITHUB_ACTIONS",
|
|
11549
|
+
"GITLAB_CI",
|
|
11550
|
+
"CIRCLECI"
|
|
11551
|
+
];
|
|
11552
|
+
const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
|
|
11553
|
+
["GITHUB_ACTIONS", "github-actions"],
|
|
11554
|
+
["GITLAB_CI", "gitlab-ci"],
|
|
11555
|
+
["CIRCLECI", "circleci"],
|
|
11556
|
+
["BUILDKITE", "buildkite"],
|
|
11557
|
+
["JENKINS_URL", "jenkins"],
|
|
11558
|
+
["TF_BUILD", "azure-pipelines"],
|
|
11559
|
+
["CODEBUILD_BUILD_ID", "aws-codebuild"],
|
|
11560
|
+
["TEAMCITY_VERSION", "teamcity"],
|
|
11561
|
+
["BITBUCKET_BUILD_NUMBER", "bitbucket"],
|
|
11562
|
+
["TRAVIS", "travis"],
|
|
11563
|
+
["DRONE", "drone"]
|
|
11564
|
+
];
|
|
11565
|
+
const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
|
|
11566
|
+
["CLAUDECODE", "claude-code"],
|
|
11567
|
+
["CLAUDE_CODE", "claude-code"],
|
|
11568
|
+
["CURSOR_AGENT", "cursor"],
|
|
11569
|
+
["CODEX_CI", "codex"],
|
|
11570
|
+
["CODEX_SANDBOX", "codex"],
|
|
11571
|
+
["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
|
|
11572
|
+
["OPENCODE", "opencode"],
|
|
11573
|
+
["GOOSE_TERMINAL", "goose"],
|
|
11574
|
+
["AMP_THREAD_ID", "amp"]
|
|
11575
|
+
];
|
|
11576
|
+
const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
|
|
11577
|
+
const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
|
|
11578
|
+
const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
|
|
11579
|
+
[...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
|
|
11580
|
+
const FALSY_CI_FLAG_VALUES = new Set([
|
|
11581
|
+
"",
|
|
11582
|
+
"0",
|
|
11583
|
+
"false"
|
|
11584
|
+
]);
|
|
11585
|
+
const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
|
|
11586
|
+
const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
|
|
11587
|
+
const detectCiProvider = () => {
|
|
11588
|
+
for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
|
|
11589
|
+
return isCiFlagSet(process.env.CI) ? "unknown" : null;
|
|
11590
|
+
};
|
|
11591
|
+
const detectCodingAgentFromValue = () => {
|
|
11592
|
+
for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
|
|
11593
|
+
const value = process.env[environmentVariable]?.toLowerCase();
|
|
11594
|
+
if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
|
|
11595
|
+
}
|
|
11596
|
+
return null;
|
|
11597
|
+
};
|
|
11598
|
+
const detectCodingAgent = () => {
|
|
11599
|
+
for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
|
|
11600
|
+
const agentFromValue = detectCodingAgentFromValue();
|
|
11601
|
+
if (agentFromValue) return agentFromValue;
|
|
11602
|
+
if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
|
|
11603
|
+
return null;
|
|
11604
|
+
};
|
|
11605
|
+
const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
|
|
11606
|
+
const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
|
|
11607
|
+
//#endregion
|
|
11608
|
+
//#region src/cli/utils/is-non-interactive-environment.ts
|
|
11609
|
+
const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
|
|
11610
|
+
"CI",
|
|
11611
|
+
"GITHUB_ACTIONS",
|
|
11612
|
+
"GITLAB_CI",
|
|
11613
|
+
"BUILDKITE",
|
|
11614
|
+
"JENKINS_URL",
|
|
11615
|
+
"TF_BUILD",
|
|
11616
|
+
"CODEBUILD_BUILD_ID",
|
|
11617
|
+
"TEAMCITY_VERSION",
|
|
11618
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
11619
|
+
"CIRCLECI",
|
|
11620
|
+
"TRAVIS",
|
|
11621
|
+
"DRONE",
|
|
11622
|
+
"GIT_DIR"
|
|
11623
|
+
];
|
|
11624
|
+
const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
|
|
11625
|
+
//#endregion
|
|
11626
|
+
//#region src/cli/utils/constants.ts
|
|
11627
|
+
const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
|
|
11628
|
+
const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
|
|
11629
|
+
const SENTRY_FLUSH_TIMEOUT_MS = 2e3;
|
|
11630
|
+
const NANOSECONDS_PER_SECOND = 1000000000n;
|
|
11631
|
+
//#endregion
|
|
11632
|
+
//#region src/cli/utils/noop-console.ts
|
|
11633
|
+
/**
|
|
11634
|
+
* A concrete `Console.Console` whose methods are all no-ops.
|
|
11635
|
+
*
|
|
11636
|
+
* Used by `--silent` (provided via
|
|
11637
|
+
* `Effect.provideService(Console.Console, makeNoopConsole())`) and by
|
|
11638
|
+
* `enableJsonMode` (assigned over the relevant slots on
|
|
11639
|
+
* `globalThis.console` so imperative legacy callsites that aren't
|
|
11640
|
+
* Effect-typed also fall silent). Sourcing both from a single concrete
|
|
11641
|
+
* object keeps "what is a no-op console" answered in one place; the
|
|
11642
|
+
* earlier `new Proxy({} as Console.Console, { get: () => () => undefined })`
|
|
11643
|
+
* combined a cast with a Proxy to do the same thing implicitly.
|
|
11644
|
+
*
|
|
11645
|
+
* The interface mirrors Effect v4's `Console.Console` shape exactly so
|
|
11646
|
+
* `Effect.provideService(Console.Console, makeNoopConsole())` requires
|
|
11647
|
+
* no cast.
|
|
11648
|
+
*/
|
|
11649
|
+
const makeNoopConsole = () => ({
|
|
11650
|
+
assert: () => {},
|
|
11651
|
+
clear: () => {},
|
|
11652
|
+
count: () => {},
|
|
11653
|
+
countReset: () => {},
|
|
11654
|
+
debug: () => {},
|
|
11655
|
+
dir: () => {},
|
|
11656
|
+
dirxml: () => {},
|
|
11657
|
+
error: () => {},
|
|
11658
|
+
group: () => {},
|
|
11659
|
+
groupCollapsed: () => {},
|
|
11660
|
+
groupEnd: () => {},
|
|
11661
|
+
info: () => {},
|
|
11662
|
+
log: () => {},
|
|
11663
|
+
table: () => {},
|
|
11664
|
+
time: () => {},
|
|
11665
|
+
timeEnd: () => {},
|
|
11666
|
+
timeLog: () => {},
|
|
11667
|
+
trace: () => {},
|
|
11668
|
+
warn: () => {}
|
|
11669
|
+
});
|
|
11670
|
+
//#endregion
|
|
11671
|
+
//#region src/cli/utils/version.ts
|
|
11672
|
+
const VERSION = "0.2.14-dev.75c1f99";
|
|
11673
|
+
//#endregion
|
|
11674
|
+
//#region src/cli/utils/json-mode.ts
|
|
11675
|
+
let context = null;
|
|
11676
|
+
/**
|
|
11677
|
+
* JSON mode writes the report payload to stdout; any incidental log
|
|
11678
|
+
* line printed by an Effect program would corrupt the JSON. Effect's
|
|
11679
|
+
* `Console` module resolves to `globalThis.console` by default (see
|
|
11680
|
+
* `effect/internal/effect.ts` → `ConsoleRef`), so copying the methods
|
|
11681
|
+
* from `makeNoopConsole()` onto the global is enough to silence every
|
|
11682
|
+
* `yield* Console.log(...)` and `cliLogger.*` call sourced from
|
|
11683
|
+
* react-doctor or its services.
|
|
11684
|
+
*
|
|
11685
|
+
* We use the same `makeNoopConsole()` source as the `--silent` path
|
|
11686
|
+
* (which provides the Effect Console via
|
|
11687
|
+
* `Effect.provideService(Console.Console, makeNoopConsole())`) — one
|
|
11688
|
+
* canonical "no-op console" definition shared by the two silent
|
|
11689
|
+
* mechanisms. The two routes still differ in how they install the
|
|
11690
|
+
* noop: silent mode swaps the Effect Console reference inside the
|
|
11691
|
+
* program; JSON mode patches the global because the surrounding CLI
|
|
11692
|
+
* command body is still imperative. Both will collapse into the
|
|
11693
|
+
* Effect-typed route once the command body finishes its migration.
|
|
11694
|
+
*
|
|
11695
|
+
* JSON mode is one-shot per CLI invocation, so we never restore.
|
|
11696
|
+
*/
|
|
11697
|
+
const installSilentConsole = () => {
|
|
11698
|
+
const noopConsole = makeNoopConsole();
|
|
11699
|
+
const target = globalThis.console;
|
|
11700
|
+
const source = noopConsole;
|
|
11701
|
+
for (const key of [
|
|
11702
|
+
"log",
|
|
11703
|
+
"error",
|
|
11704
|
+
"warn",
|
|
11705
|
+
"info",
|
|
11706
|
+
"debug",
|
|
11707
|
+
"trace"
|
|
11708
|
+
]) target[key] = source[key];
|
|
11709
|
+
};
|
|
11710
|
+
const enableJsonMode = ({ compact, directory }) => {
|
|
11711
|
+
context = {
|
|
11712
|
+
compact,
|
|
11713
|
+
directory,
|
|
11714
|
+
startTime: performance.now(),
|
|
11715
|
+
mode: "full"
|
|
11716
|
+
};
|
|
11717
|
+
installSilentConsole();
|
|
11718
|
+
};
|
|
11719
|
+
const isJsonModeActive = () => context !== null;
|
|
11720
|
+
const setJsonReportDirectory = (directory) => {
|
|
11721
|
+
if (context) context.directory = directory;
|
|
11722
|
+
};
|
|
11723
|
+
const setJsonReportMode = (mode) => {
|
|
11724
|
+
if (context) context.mode = mode;
|
|
11725
|
+
};
|
|
11726
|
+
const writeJsonReport = (report) => {
|
|
11727
|
+
const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
|
|
11728
|
+
process.stdout.write(`${serialized}\n`);
|
|
11729
|
+
};
|
|
11730
|
+
const writeJsonErrorReport = (error) => {
|
|
11731
|
+
if (!context) return;
|
|
11732
|
+
try {
|
|
11733
|
+
writeJsonReport(buildJsonReportError({
|
|
11734
|
+
version: VERSION,
|
|
11735
|
+
directory: context.directory,
|
|
11736
|
+
error,
|
|
11737
|
+
elapsedMilliseconds: performance.now() - context.startTime,
|
|
11738
|
+
mode: context.mode
|
|
11739
|
+
}));
|
|
11740
|
+
} catch {
|
|
11741
|
+
process.stdout.write(INTERNAL_ERROR_JSON_FALLBACK);
|
|
11742
|
+
}
|
|
11743
|
+
};
|
|
11744
|
+
//#endregion
|
|
11745
|
+
//#region src/cli/utils/scrub-sensitive-text.ts
|
|
11746
|
+
const HOME_DIRECTORY = os.homedir();
|
|
11747
|
+
const USER_HOME_PATTERNS = [/[A-Za-z]:[\\/]Users[\\/][^\\/]+/gi, /(?:\/Users\/|\/home\/)[^/\\]+/gi];
|
|
11748
|
+
/**
|
|
11749
|
+
* Replaces the user's home directory (and generic `/Users|/home|C:\Users\<name>`
|
|
11750
|
+
* roots) with `~` so absolute paths can't be tied back to an individual. Keeps
|
|
11751
|
+
* the path's relative structure intact, which stays useful for debugging while
|
|
11752
|
+
* dropping the personally-identifying prefix. Idempotent — re-running on an
|
|
11753
|
+
* already-scrubbed `~/...` path is a no-op.
|
|
11754
|
+
*/
|
|
11755
|
+
const scrubSensitivePaths = (text) => {
|
|
11756
|
+
let scrubbed = text;
|
|
11757
|
+
if (HOME_DIRECTORY.length > 1) scrubbed = scrubbed.split(HOME_DIRECTORY).join("~");
|
|
11758
|
+
for (const pattern of USER_HOME_PATTERNS) scrubbed = scrubbed.replace(pattern, "~");
|
|
11759
|
+
return scrubbed;
|
|
11760
|
+
};
|
|
11761
|
+
//#endregion
|
|
11762
|
+
//#region src/cli/utils/build-run-context.ts
|
|
11763
|
+
const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
|
|
11764
|
+
const detectInvokedVia = () => {
|
|
11765
|
+
const userAgent = process.env.npm_config_user_agent;
|
|
11766
|
+
if (!userAgent) return "unknown";
|
|
11767
|
+
return userAgent.split("/", 1)[0]?.trim() || "unknown";
|
|
11768
|
+
};
|
|
11769
|
+
const detectNodeMajor = () => {
|
|
11770
|
+
const major = Number.parseInt(process.versions.node.split(".", 1)[0] ?? "", 10);
|
|
11771
|
+
return Number.isNaN(major) ? 0 : major;
|
|
11772
|
+
};
|
|
11773
|
+
const detectOrigin = () => {
|
|
11774
|
+
if (process.env.GIT_DIR) return "git-hook";
|
|
11775
|
+
if (isCodingAgentEnvironment()) return "agent";
|
|
11776
|
+
if (isCiEnvironment()) return "ci";
|
|
11777
|
+
return "cli";
|
|
11778
|
+
};
|
|
11779
|
+
const detectCommand = (userArguments) => {
|
|
11780
|
+
for (const argument of userArguments) {
|
|
11781
|
+
if (argument === "--") break;
|
|
11782
|
+
if (argument.startsWith("-")) continue;
|
|
11783
|
+
return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
|
|
11784
|
+
}
|
|
11785
|
+
return "inspect";
|
|
11786
|
+
};
|
|
11787
|
+
/**
|
|
11788
|
+
* Snapshot of the current invocation, attached to Sentry events as the
|
|
11789
|
+
* `run` context to make crashes triage-able (which version, platform,
|
|
11790
|
+
* CI/agent, how it was invoked). Every field is cheap, synchronous, and
|
|
11791
|
+
* safe to read at any point — cwd reads fall back, env reads are
|
|
11792
|
+
* booleans — so it's rebuilt lazily at capture time when runtime-only
|
|
11793
|
+
* signals like `jsonMode` are finally known.
|
|
11794
|
+
*/
|
|
11795
|
+
const buildRunContext = () => {
|
|
11796
|
+
const userArguments = process.argv.slice(2);
|
|
11797
|
+
return {
|
|
11798
|
+
version: VERSION,
|
|
11799
|
+
origin: detectOrigin(),
|
|
11800
|
+
command: detectCommand(userArguments),
|
|
11801
|
+
argv: scrubSensitivePaths(userArguments.join(" ")),
|
|
11802
|
+
cwd: scrubSensitivePaths(process.cwd()),
|
|
11803
|
+
node: process.version,
|
|
11804
|
+
nodeMajor: detectNodeMajor(),
|
|
11805
|
+
platform: process.platform,
|
|
11806
|
+
arch: process.arch,
|
|
11807
|
+
ci: isCiEnvironment(),
|
|
11808
|
+
ciProvider: detectCiProvider(),
|
|
11809
|
+
codingAgent: detectCodingAgent(),
|
|
11810
|
+
interactive: !isNonInteractiveEnvironment(),
|
|
11811
|
+
jsonMode: isJsonModeActive(),
|
|
11812
|
+
invokedVia: detectInvokedVia()
|
|
11813
|
+
};
|
|
11814
|
+
};
|
|
11815
|
+
//#endregion
|
|
11816
|
+
//#region src/cli/utils/build-sentry-project-context.ts
|
|
11817
|
+
/**
|
|
11818
|
+
* Projects the {@link ProjectInfo} we already detect during a scan into the
|
|
11819
|
+
* Sentry scope shape: a handful of searchable `project.*` tags plus the
|
|
11820
|
+
* anonymous project *shape* as a `project` context block. Lets crash/transaction
|
|
11821
|
+
* triage answer "which kind of project hit this?" (framework, React/Expo
|
|
11822
|
+
* version, TypeScript, size) without sending source code — and deliberately
|
|
11823
|
+
* omits `projectName` and `rootDirectory`, the two identifying fields, so the
|
|
11824
|
+
* project can't be tied back to a specific company/repo.
|
|
11825
|
+
*/
|
|
11826
|
+
const buildSentryProjectContext = (projectInfo) => ({
|
|
11827
|
+
tags: {
|
|
11828
|
+
"project.framework": projectInfo.framework,
|
|
11829
|
+
"project.reactMajor": projectInfo.reactMajorVersion,
|
|
11830
|
+
"project.typescript": projectInfo.hasTypeScript,
|
|
11831
|
+
"project.reactCompiler": projectInfo.hasReactCompiler,
|
|
11832
|
+
"project.expo": projectInfo.expoVersion !== null,
|
|
11833
|
+
"project.reactNative": projectInfo.hasReactNativeWorkspace
|
|
11834
|
+
},
|
|
11835
|
+
context: {
|
|
11836
|
+
framework: projectInfo.framework,
|
|
11837
|
+
reactVersion: projectInfo.reactVersion,
|
|
11838
|
+
reactMajorVersion: projectInfo.reactMajorVersion,
|
|
11839
|
+
hasTypeScript: projectInfo.hasTypeScript,
|
|
11840
|
+
hasReactCompiler: projectInfo.hasReactCompiler,
|
|
11841
|
+
hasTanStackQuery: projectInfo.hasTanStackQuery,
|
|
11842
|
+
tailwindVersion: projectInfo.tailwindVersion,
|
|
11843
|
+
zodVersion: projectInfo.zodVersion,
|
|
11844
|
+
preactVersion: projectInfo.preactVersion,
|
|
11845
|
+
hasReactNativeWorkspace: projectInfo.hasReactNativeWorkspace,
|
|
11846
|
+
expoVersion: projectInfo.expoVersion,
|
|
11847
|
+
hasReanimated: projectInfo.hasReanimated,
|
|
11848
|
+
sourceFileCount: projectInfo.sourceFileCount
|
|
11849
|
+
}
|
|
11850
|
+
});
|
|
11851
|
+
let currentProjectInfo = null;
|
|
11852
|
+
const setSentryProjectInfo = (projectInfo) => {
|
|
11853
|
+
currentProjectInfo = projectInfo;
|
|
11854
|
+
};
|
|
11855
|
+
const getSentryProjectInfo = () => currentProjectInfo;
|
|
11856
|
+
//#endregion
|
|
11857
|
+
//#region src/cli/utils/build-sentry-scope.ts
|
|
11858
|
+
/**
|
|
11859
|
+
* Projects a {@link RunContext} snapshot (plus the current run's
|
|
11860
|
+
* {@link getSentryProjectInfo project info}, when a scan has discovered it) into
|
|
11861
|
+
* the Sentry scope shape — the searchable `tags` that make crashes/transactions
|
|
11862
|
+
* filterable (which command, origin, CI provider, coding agent, Node major,
|
|
11863
|
+
* package manager, project framework/React major) plus the full `run` and
|
|
11864
|
+
* `project` context blocks for deep triage.
|
|
11865
|
+
*
|
|
11866
|
+
* Shared by `instrument.ts` (seeded as `initialScope` so *every* event,
|
|
11867
|
+
* including performance transactions, carries it) and `report-error.ts` (a
|
|
11868
|
+
* capture-time refresh, since runtime-only signals like `jsonMode` and the
|
|
11869
|
+
* scanned project are only known once a command has begun).
|
|
11870
|
+
*/
|
|
11871
|
+
const buildSentryScope = (runContext = buildRunContext()) => {
|
|
11872
|
+
const tags = {
|
|
11873
|
+
origin: runContext.origin,
|
|
11874
|
+
command: runContext.command,
|
|
11875
|
+
ci: runContext.ci,
|
|
11876
|
+
ciProvider: runContext.ciProvider,
|
|
11877
|
+
codingAgent: runContext.codingAgent,
|
|
11878
|
+
interactive: runContext.interactive,
|
|
11879
|
+
jsonMode: runContext.jsonMode,
|
|
11880
|
+
invokedVia: runContext.invokedVia,
|
|
11881
|
+
nodeMajor: runContext.nodeMajor
|
|
11882
|
+
};
|
|
11883
|
+
const contexts = { run: { ...runContext } };
|
|
11884
|
+
const projectInfo = getSentryProjectInfo();
|
|
11885
|
+
if (projectInfo) {
|
|
11886
|
+
const project = buildSentryProjectContext(projectInfo);
|
|
11887
|
+
Object.assign(tags, project.tags);
|
|
11888
|
+
contexts.project = project.context;
|
|
11889
|
+
}
|
|
11890
|
+
return {
|
|
11891
|
+
tags,
|
|
11892
|
+
contexts
|
|
11893
|
+
};
|
|
11894
|
+
};
|
|
11895
|
+
//#endregion
|
|
11896
|
+
//#region src/cli/utils/scrub-sentry-event.ts
|
|
11897
|
+
const anonymizeText = (text) => redactSensitiveText(scrubSensitivePaths(text));
|
|
11898
|
+
/**
|
|
11899
|
+
* Recursively rewrites every string within an arbitrary value (object / array /
|
|
11900
|
+
* primitive) through {@link anonymizeText}, mutating in place. Used to sweep the
|
|
11901
|
+
* unstructured corners of an event (contexts, extra, tags, breadcrumb data,
|
|
11902
|
+
* span attributes) where a path or secret could hide.
|
|
11903
|
+
*/
|
|
11904
|
+
const anonymizeInPlace = (value) => {
|
|
11905
|
+
if (Array.isArray(value)) {
|
|
11906
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
11907
|
+
const item = value[index];
|
|
11908
|
+
if (typeof item === "string") value[index] = anonymizeText(item);
|
|
11909
|
+
else anonymizeInPlace(item);
|
|
11910
|
+
}
|
|
11911
|
+
return;
|
|
11912
|
+
}
|
|
11913
|
+
if (!isPlainObject(value)) return;
|
|
11914
|
+
for (const key of Object.keys(value)) {
|
|
11915
|
+
const inner = value[key];
|
|
11916
|
+
if (typeof inner === "string") value[key] = anonymizeText(inner);
|
|
11917
|
+
else anonymizeInPlace(inner);
|
|
11918
|
+
}
|
|
11919
|
+
};
|
|
11920
|
+
/**
|
|
11921
|
+
* Anonymizes a Sentry event (error or transaction) before it leaves the
|
|
11922
|
+
* machine. Strips identity the SDK attaches automatically — the IP-bearing
|
|
11923
|
+
* `user`, the `server_name`, and the device `name` (all hostnames) — drops
|
|
11924
|
+
* captured local variables (unbounded, un-anonymizable user data), and scrubs
|
|
11925
|
+
* home-directory paths + known secrets/emails from every remaining string:
|
|
11926
|
+
* messages, stack frames, breadcrumbs, contexts/extra/tags, and span
|
|
11927
|
+
* attributes (e.g. the `inspect.directory` path on the bridged `runInspect`
|
|
11928
|
+
* span).
|
|
11929
|
+
*
|
|
11930
|
+
* Wired into both `beforeSend` and `beforeSendTransaction`. If scrubbing ever
|
|
11931
|
+
* throws, the event is dropped (`null`) rather than risk sending un-anonymized
|
|
11932
|
+
* data — telemetry is best-effort, privacy is not.
|
|
11933
|
+
*/
|
|
11934
|
+
const scrubSentryEvent = (event) => {
|
|
11935
|
+
try {
|
|
11936
|
+
delete event.server_name;
|
|
11937
|
+
delete event.user;
|
|
11938
|
+
const device = event.contexts?.device;
|
|
11939
|
+
if (device) delete device.name;
|
|
11940
|
+
if (event.contexts) anonymizeInPlace(event.contexts);
|
|
11941
|
+
if (event.extra) anonymizeInPlace(event.extra);
|
|
11942
|
+
if (event.tags) anonymizeInPlace(event.tags);
|
|
11943
|
+
if (typeof event.message === "string") event.message = anonymizeText(event.message);
|
|
11944
|
+
for (const breadcrumb of event.breadcrumbs ?? []) {
|
|
11945
|
+
if (typeof breadcrumb.message === "string") breadcrumb.message = anonymizeText(breadcrumb.message);
|
|
11946
|
+
if (breadcrumb.data) anonymizeInPlace(breadcrumb.data);
|
|
11947
|
+
}
|
|
11948
|
+
for (const exception of event.exception?.values ?? []) {
|
|
11949
|
+
if (typeof exception.value === "string") exception.value = anonymizeText(exception.value);
|
|
11950
|
+
for (const frame of exception.stacktrace?.frames ?? []) {
|
|
11951
|
+
delete frame.vars;
|
|
11952
|
+
if (typeof frame.filename === "string") frame.filename = scrubSensitivePaths(frame.filename);
|
|
11953
|
+
if (typeof frame.abs_path === "string") frame.abs_path = scrubSensitivePaths(frame.abs_path);
|
|
11954
|
+
if (typeof frame.module === "string") frame.module = scrubSensitivePaths(frame.module);
|
|
11955
|
+
}
|
|
11956
|
+
}
|
|
11957
|
+
for (const span of event.spans ?? []) {
|
|
11958
|
+
if (typeof span.description === "string") span.description = anonymizeText(span.description);
|
|
11959
|
+
if (span.data) anonymizeInPlace(span.data);
|
|
11960
|
+
}
|
|
11961
|
+
return event;
|
|
11962
|
+
} catch {
|
|
11963
|
+
return null;
|
|
11964
|
+
}
|
|
11965
|
+
};
|
|
11966
|
+
//#endregion
|
|
11967
|
+
//#region src/instrument.ts
|
|
11968
|
+
let isInitialized = false;
|
|
11969
|
+
let resolvedTracesSampleRate = 0;
|
|
11970
|
+
const shouldEnableSentry = () => {
|
|
11971
|
+
if (process.argv.includes("--no-score") || process.argv.includes("--no-telemetry")) return false;
|
|
11972
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
|
|
11973
|
+
return true;
|
|
11974
|
+
};
|
|
11975
|
+
const isEnvFlagEnabled = (value) => value === "1" || value?.toLowerCase() === "true";
|
|
11976
|
+
/**
|
|
11977
|
+
* A version is a "dev" build when it's the unbuilt placeholder (`0.0.0`) or
|
|
11978
|
+
* carries a prerelease suffix (e.g. the `-dev.<sha>` snapshots published from
|
|
11979
|
+
* CI). Everything else is a real, tagged release.
|
|
11980
|
+
*/
|
|
11981
|
+
const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
|
|
11982
|
+
/**
|
|
11983
|
+
* Sentry release identifier. `react-doctor@<version>` keeps it unique within
|
|
11984
|
+
* the org and — crucially — matches the value `scripts/sentry-sourcemaps.mjs`
|
|
11985
|
+
* uploads source-map artifacts under, so stack frames symbolicate. Honors the
|
|
11986
|
+
* standard `SENTRY_RELEASE` override.
|
|
11987
|
+
*/
|
|
11988
|
+
const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.2.14-dev.75c1f99`;
|
|
11989
|
+
/**
|
|
11990
|
+
* Deployment environment shown in Sentry's environment filter. Defaults to
|
|
11991
|
+
* `production` for tagged releases and `development` for dev/unbuilt versions,
|
|
11992
|
+
* overridable via the standard `SENTRY_ENVIRONMENT` env var.
|
|
11993
|
+
*/
|
|
11994
|
+
const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.2.14-dev.75c1f99") ? "development" : "production");
|
|
11995
|
+
/**
|
|
11996
|
+
* Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
|
|
11997
|
+
* (set to `0` to disable tracing) and falls back to
|
|
11998
|
+
* {@link SENTRY_DEFAULT_TRACES_SAMPLE_RATE}. Invalid / out-of-range values fall
|
|
11999
|
+
* back to the default rather than silently disabling tracing.
|
|
12000
|
+
*/
|
|
12001
|
+
const resolveTracesSampleRate = () => {
|
|
12002
|
+
const raw = process.env.SENTRY_TRACES_SAMPLE_RATE;
|
|
12003
|
+
if (raw === void 0 || raw.trim() === "") return 1;
|
|
12004
|
+
const parsed = Number(raw);
|
|
12005
|
+
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) return 1;
|
|
12006
|
+
return parsed;
|
|
12007
|
+
};
|
|
12008
|
+
/**
|
|
12009
|
+
* Whether performance traces will actually be recorded — Sentry is live and the
|
|
12010
|
+
* resolved sample rate is above zero. Used to gate the per-run root span and
|
|
12011
|
+
* the Effect→Sentry tracer bridge so they're true no-ops when tracing is off.
|
|
12012
|
+
*/
|
|
12013
|
+
const isSentryTracingEnabled = () => Sentry.isInitialized() && resolvedTracesSampleRate > 0;
|
|
12014
|
+
/**
|
|
12015
|
+
* Flushes queued Sentry events (errors + transactions) before the CLI exits, so
|
|
12016
|
+
* the success-path transaction is delivered. A no-op when Sentry was never
|
|
12017
|
+
* initialized, and it swallows transport failures so telemetry can never mask
|
|
12018
|
+
* the user's result.
|
|
12019
|
+
*/
|
|
12020
|
+
const flushSentry = async () => {
|
|
12021
|
+
if (!Sentry.isInitialized()) return;
|
|
12022
|
+
try {
|
|
12023
|
+
await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
|
|
12024
|
+
} catch {}
|
|
12025
|
+
};
|
|
12026
|
+
/**
|
|
12027
|
+
* Initializes the Sentry Node SDK for CLI crash reporting and performance
|
|
12028
|
+
* tracing. Invoked as the first statement of the CLI entry (`cli/index.ts`) so
|
|
12029
|
+
* the SDK's global `uncaughtException` / `unhandledRejection` handlers and OTel
|
|
12030
|
+
* auto-instrumentation are armed before any command runs.
|
|
12031
|
+
*
|
|
12032
|
+
* Exported as a function rather than a bare side-effecting import because the
|
|
12033
|
+
* package declares `"sideEffects": false`, which lets the bundler tree-shake
|
|
12034
|
+
* side-effect-only modules. An explicit call keeps the initialization in the
|
|
12035
|
+
* published `dist/cli.js`.
|
|
12036
|
+
*
|
|
12037
|
+
* Scoped to the CLI application only — the programmatic `@react-doctor/api`
|
|
12038
|
+
* library never initializes Sentry, so importing `diagnose()` into a consumer
|
|
12039
|
+
* app can't hijack their telemetry.
|
|
12040
|
+
*
|
|
12041
|
+
* Configuration is environment-overridable for self-hosting and tuning:
|
|
12042
|
+
* `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`,
|
|
12043
|
+
* `SENTRY_TRACES_SAMPLE_RATE` (`0` disables tracing), and `SENTRY_DEBUG`.
|
|
12044
|
+
*/
|
|
12045
|
+
const initializeSentry = () => {
|
|
12046
|
+
if (isInitialized || !shouldEnableSentry()) return;
|
|
12047
|
+
isInitialized = true;
|
|
12048
|
+
resolvedTracesSampleRate = resolveTracesSampleRate();
|
|
12049
|
+
const { tags, contexts } = buildSentryScope();
|
|
12050
|
+
Sentry.init({
|
|
12051
|
+
dsn: process.env.SENTRY_DSN || "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920",
|
|
12052
|
+
release: resolveSentryRelease(),
|
|
12053
|
+
environment: resolveSentryEnvironment(),
|
|
12054
|
+
sendDefaultPii: false,
|
|
12055
|
+
tracesSampleRate: resolvedTracesSampleRate,
|
|
12056
|
+
debug: isEnvFlagEnabled(process.env.SENTRY_DEBUG),
|
|
12057
|
+
initialScope: {
|
|
12058
|
+
tags,
|
|
12059
|
+
contexts
|
|
12060
|
+
},
|
|
12061
|
+
beforeSend: (event) => scrubSentryEvent(event),
|
|
12062
|
+
beforeSendTransaction: (event) => scrubSentryEvent(event)
|
|
12063
|
+
});
|
|
12064
|
+
};
|
|
12065
|
+
//#endregion
|
|
12066
|
+
//#region src/cli/utils/sentry-tracer.ts
|
|
12067
|
+
const toHrTime = (epochNanoseconds) => [Number(epochNanoseconds / NANOSECONDS_PER_SECOND), Number(epochNanoseconds % NANOSECONDS_PER_SECOND)];
|
|
12068
|
+
const SPAN_KIND_TO_OTEL = {
|
|
12069
|
+
internal: 0,
|
|
12070
|
+
server: 1,
|
|
12071
|
+
client: 2,
|
|
12072
|
+
producer: 3,
|
|
12073
|
+
consumer: 4
|
|
12074
|
+
};
|
|
12075
|
+
const toSentryAttributeValue = (value) => {
|
|
12076
|
+
if (value === null || value === void 0) return void 0;
|
|
12077
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
12078
|
+
return String(value);
|
|
12079
|
+
};
|
|
12080
|
+
const normalizeAttributes = (attributes) => {
|
|
12081
|
+
const normalized = {};
|
|
12082
|
+
if (!attributes) return normalized;
|
|
12083
|
+
for (const [key, value] of Object.entries(attributes)) normalized[key] = toSentryAttributeValue(value);
|
|
12084
|
+
return normalized;
|
|
12085
|
+
};
|
|
12086
|
+
const isSentryBackedSpan = (span) => span._tag === "Span" && "sentrySpan" in span;
|
|
12087
|
+
const spanContextFor = (span) => isSentryBackedSpan(span) ? span.sentrySpan.spanContext() : {
|
|
12088
|
+
traceId: span.traceId,
|
|
12089
|
+
spanId: span.spanId,
|
|
12090
|
+
traceFlags: span.sampled ? 1 : 0
|
|
12091
|
+
};
|
|
12092
|
+
/**
|
|
12093
|
+
* Builds an Effect {@link Tracer.Tracer} that materializes every Effect span
|
|
12094
|
+
* (`Effect.withSpan(...)` / `Effect.fn("Service.method")`) as a child Sentry
|
|
12095
|
+
* span, producing one unified per-run trace in Sentry. The CLI already
|
|
12096
|
+
* instruments `runInspect` and each core service method, so this bridge lights
|
|
12097
|
+
* all of that up in Sentry for free.
|
|
12098
|
+
*
|
|
12099
|
+
* `rootSpan` is the active per-run transaction; Effect spans without an Effect
|
|
12100
|
+
* parent attach to it, so nesting is correct even if async-context propagation
|
|
12101
|
+
* is interrupted by Effect's fiber scheduler. Provided to a program via
|
|
12102
|
+
* `Effect.withTracer(...)`.
|
|
12103
|
+
*/
|
|
12104
|
+
const makeSentryTracer = (rootSpan, startInactiveSpan = Sentry.startInactiveSpan) => Tracer.make({ span: (options) => {
|
|
12105
|
+
const parentSpan = Option.isSome(options.parent) && isSentryBackedSpan(options.parent.value) ? options.parent.value.sentrySpan : rootSpan;
|
|
12106
|
+
const sentrySpan = startInactiveSpan({
|
|
12107
|
+
name: options.name,
|
|
12108
|
+
startTime: toHrTime(options.startTime),
|
|
12109
|
+
parentSpan,
|
|
12110
|
+
kind: SPAN_KIND_TO_OTEL[options.kind]
|
|
12111
|
+
});
|
|
12112
|
+
const { traceId, spanId } = sentrySpan.spanContext();
|
|
12113
|
+
const attributes = /* @__PURE__ */ new Map();
|
|
12114
|
+
let status = {
|
|
12115
|
+
_tag: "Started",
|
|
12116
|
+
startTime: options.startTime
|
|
12117
|
+
};
|
|
12118
|
+
return {
|
|
12119
|
+
_tag: "Span",
|
|
12120
|
+
sentrySpan,
|
|
12121
|
+
name: options.name,
|
|
12122
|
+
spanId,
|
|
12123
|
+
traceId,
|
|
12124
|
+
parent: options.parent,
|
|
12125
|
+
annotations: options.annotations,
|
|
12126
|
+
links: options.links,
|
|
12127
|
+
sampled: options.sampled,
|
|
12128
|
+
kind: options.kind,
|
|
12129
|
+
get status() {
|
|
12130
|
+
return status;
|
|
12131
|
+
},
|
|
12132
|
+
get attributes() {
|
|
12133
|
+
return attributes;
|
|
12134
|
+
},
|
|
12135
|
+
end: (endTime, exit) => {
|
|
12136
|
+
status = {
|
|
12137
|
+
_tag: "Ended",
|
|
12138
|
+
startTime: options.startTime,
|
|
12139
|
+
endTime,
|
|
12140
|
+
exit
|
|
12141
|
+
};
|
|
12142
|
+
sentrySpan.setStatus({ code: Exit.isSuccess(exit) ? 1 : 2 });
|
|
12143
|
+
sentrySpan.end(toHrTime(endTime));
|
|
12144
|
+
},
|
|
12145
|
+
attribute: (key, value) => {
|
|
12146
|
+
attributes.set(key, value);
|
|
12147
|
+
sentrySpan.setAttribute(key, toSentryAttributeValue(value));
|
|
12148
|
+
},
|
|
12149
|
+
event: (name, startTime, eventAttributes) => {
|
|
12150
|
+
sentrySpan.addEvent(name, normalizeAttributes(eventAttributes), toHrTime(startTime));
|
|
12151
|
+
},
|
|
12152
|
+
addLinks: (links) => {
|
|
12153
|
+
for (const link of links) sentrySpan.addLink({
|
|
12154
|
+
context: spanContextFor(link.span),
|
|
12155
|
+
attributes: normalizeAttributes(link.attributes)
|
|
12156
|
+
});
|
|
12157
|
+
}
|
|
12158
|
+
};
|
|
12159
|
+
} });
|
|
12160
|
+
//#endregion
|
|
12161
|
+
//#region src/cli/utils/apply-observability.ts
|
|
12162
|
+
const isOtlpExportConfigured = () => Boolean(process.env.REACT_DOCTOR_OTLP_ENDPOINT) && Boolean(process.env.REACT_DOCTOR_OTLP_AUTH_HEADER);
|
|
12163
|
+
const externalSpanFrom = (sentrySpan) => {
|
|
12164
|
+
const { traceId, spanId, traceFlags } = sentrySpan.spanContext();
|
|
12165
|
+
return Tracer.externalSpan({
|
|
12166
|
+
traceId,
|
|
12167
|
+
spanId,
|
|
12168
|
+
sampled: (traceFlags & 1) === 1
|
|
12169
|
+
});
|
|
12170
|
+
};
|
|
12171
|
+
/**
|
|
12172
|
+
* Installs the tracing backend for the inspect program. Effect's tracer is a
|
|
12173
|
+
* single reference, so the backends are mutually exclusive — we pick by
|
|
12174
|
+
* precedence:
|
|
12175
|
+
*
|
|
12176
|
+
* 1. **User OTLP backend** (`REACT_DOCTOR_OTLP_*` set) wins; we additionally
|
|
12177
|
+
* parent the Effect trace under the active Sentry trace via an
|
|
12178
|
+
* `ExternalSpan` so a trace exported to the user's backend shares its
|
|
12179
|
+
* `trace_id` with the corresponding Sentry trace.
|
|
12180
|
+
* 2. **Sentry tracing active** (and no user OTLP): route Effect's existing
|
|
12181
|
+
* span instrumentation straight into Sentry as one unified per-run trace.
|
|
12182
|
+
* 3. **Neither**: provide the (no-op) OTLP layer, leaving Effect's native
|
|
12183
|
+
* in-memory tracer — identical to the prior default behavior.
|
|
12184
|
+
*/
|
|
12185
|
+
const applyObservability = (program, rootSentrySpan) => {
|
|
12186
|
+
if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(Effect.provideService(Tracer.ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(Effect.provide(layerOtlp));
|
|
12187
|
+
if (rootSentrySpan) return program.pipe(Effect.withTracer(makeSentryTracer(rootSentrySpan)));
|
|
12188
|
+
return program.pipe(Effect.provide(layerOtlp));
|
|
12189
|
+
};
|
|
12190
|
+
//#endregion
|
|
12191
|
+
//#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
12192
|
+
const ANSI_BACKGROUND_OFFSET = 10;
|
|
12193
|
+
const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
|
|
12194
|
+
const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
|
|
12195
|
+
const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
|
|
12196
|
+
const styles$1 = {
|
|
12197
|
+
modifier: {
|
|
12198
|
+
reset: [0, 0],
|
|
12199
|
+
bold: [1, 22],
|
|
12200
|
+
dim: [2, 22],
|
|
12201
|
+
italic: [3, 23],
|
|
12202
|
+
underline: [4, 24],
|
|
12203
|
+
overline: [53, 55],
|
|
12204
|
+
inverse: [7, 27],
|
|
12205
|
+
hidden: [8, 28],
|
|
12206
|
+
strikethrough: [9, 29]
|
|
12207
|
+
},
|
|
12208
|
+
color: {
|
|
12209
|
+
black: [30, 39],
|
|
12210
|
+
red: [31, 39],
|
|
12211
|
+
green: [32, 39],
|
|
12212
|
+
yellow: [33, 39],
|
|
12213
|
+
blue: [34, 39],
|
|
12214
|
+
magenta: [35, 39],
|
|
12215
|
+
cyan: [36, 39],
|
|
12216
|
+
white: [37, 39],
|
|
12217
|
+
blackBright: [90, 39],
|
|
12218
|
+
gray: [90, 39],
|
|
12219
|
+
grey: [90, 39],
|
|
12220
|
+
redBright: [91, 39],
|
|
12221
|
+
greenBright: [92, 39],
|
|
12222
|
+
yellowBright: [93, 39],
|
|
12223
|
+
blueBright: [94, 39],
|
|
10861
12224
|
magentaBright: [95, 39],
|
|
10862
12225
|
cyanBright: [96, 39],
|
|
10863
12226
|
whiteBright: [97, 39]
|
|
@@ -13772,49 +15135,6 @@ function ora(options) {
|
|
|
13772
15135
|
return new Ora(options);
|
|
13773
15136
|
}
|
|
13774
15137
|
//#endregion
|
|
13775
|
-
//#region src/cli/utils/is-ci-environment.ts
|
|
13776
|
-
const CI_ENVIRONMENT_VARIABLES = [
|
|
13777
|
-
"GITHUB_ACTIONS",
|
|
13778
|
-
"GITLAB_CI",
|
|
13779
|
-
"CIRCLECI"
|
|
13780
|
-
];
|
|
13781
|
-
const CODING_AGENT_ENVIRONMENT_VARIABLES = [
|
|
13782
|
-
"CLAUDECODE",
|
|
13783
|
-
"CLAUDE_CODE",
|
|
13784
|
-
"CURSOR_AGENT",
|
|
13785
|
-
"CODEX_CI",
|
|
13786
|
-
"CODEX_SANDBOX",
|
|
13787
|
-
"CODEX_SANDBOX_NETWORK_DISABLED",
|
|
13788
|
-
"OPENCODE",
|
|
13789
|
-
"GOOSE_TERMINAL",
|
|
13790
|
-
"AGENT_SESSION_ID",
|
|
13791
|
-
"AMP_THREAD_ID",
|
|
13792
|
-
"AGENT_THREAD_ID"
|
|
13793
|
-
];
|
|
13794
|
-
const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
|
|
13795
|
-
const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
|
|
13796
|
-
const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
|
|
13797
|
-
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));
|
|
13798
|
-
const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
|
|
13799
|
-
//#endregion
|
|
13800
|
-
//#region src/cli/utils/is-non-interactive-environment.ts
|
|
13801
|
-
const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
|
|
13802
|
-
"CI",
|
|
13803
|
-
"GITHUB_ACTIONS",
|
|
13804
|
-
"GITLAB_CI",
|
|
13805
|
-
"BUILDKITE",
|
|
13806
|
-
"JENKINS_URL",
|
|
13807
|
-
"TF_BUILD",
|
|
13808
|
-
"CODEBUILD_BUILD_ID",
|
|
13809
|
-
"TEAMCITY_VERSION",
|
|
13810
|
-
"BITBUCKET_BUILD_NUMBER",
|
|
13811
|
-
"CIRCLECI",
|
|
13812
|
-
"TRAVIS",
|
|
13813
|
-
"DRONE",
|
|
13814
|
-
"GIT_DIR"
|
|
13815
|
-
];
|
|
13816
|
-
const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
|
|
13817
|
-
//#endregion
|
|
13818
15138
|
//#region src/cli/utils/is-spinner-interactive.ts
|
|
13819
15139
|
const isSpinnerInteractive = (stream = process.stderr) => {
|
|
13820
15140
|
if (stream.isTTY !== true) return false;
|
|
@@ -13896,9 +15216,8 @@ const buildSpinnerProgressHandle = (text) => {
|
|
|
13896
15216
|
* construction and post-scan rendering — layer wiring is its own
|
|
13897
15217
|
* concern with its own contract.
|
|
13898
15218
|
*
|
|
13899
|
-
* Same shape as
|
|
13900
|
-
*
|
|
13901
|
-
* differences specific to the CLI path:
|
|
15219
|
+
* Same service shape as `@react-doctor/api → diagnose()`'s
|
|
15220
|
+
* `buildDiagnoseLayer`, with the differences specific to the CLI path:
|
|
13902
15221
|
*
|
|
13903
15222
|
* - **Config**: when the caller passes `configOverride`, the
|
|
13904
15223
|
* already-loaded config is provided via `Config.layerOf` instead
|
|
@@ -13924,47 +15243,93 @@ const buildRuntimeLayers = (input) => {
|
|
|
13924
15243
|
resolvedDirectory: input.directory,
|
|
13925
15244
|
configSourceDirectory: input.configSourceDirectory
|
|
13926
15245
|
}) : Config.layerNode;
|
|
13927
|
-
|
|
15246
|
+
const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
|
|
15247
|
+
return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
|
|
13928
15248
|
};
|
|
13929
15249
|
//#endregion
|
|
13930
|
-
//#region src/cli/utils/
|
|
15250
|
+
//#region src/cli/utils/active-run-trace.ts
|
|
15251
|
+
let activeRunTrace = null;
|
|
15252
|
+
const setActiveRunTrace = (trace) => {
|
|
15253
|
+
activeRunTrace = trace;
|
|
15254
|
+
};
|
|
15255
|
+
const getActiveRunTrace = () => activeRunTrace;
|
|
15256
|
+
//#endregion
|
|
15257
|
+
//#region src/cli/utils/to-span-attributes.ts
|
|
13931
15258
|
/**
|
|
13932
|
-
*
|
|
15259
|
+
* Converts a Sentry tag map (which permits `null` to denote an absent signal)
|
|
15260
|
+
* into Sentry/OTel span attributes, which only accept primitives. `null` values
|
|
15261
|
+
* are dropped rather than coerced, so an absent signal doesn't become a
|
|
15262
|
+
* misleading `"null"` attribute.
|
|
15263
|
+
*/
|
|
15264
|
+
const toSpanAttributes = (tags) => {
|
|
15265
|
+
const attributes = {};
|
|
15266
|
+
for (const [key, value] of Object.entries(tags)) if (value !== null) attributes[key] = value;
|
|
15267
|
+
return attributes;
|
|
15268
|
+
};
|
|
15269
|
+
//#endregion
|
|
15270
|
+
//#region src/cli/utils/with-sentry-run-span.ts
|
|
15271
|
+
/**
|
|
15272
|
+
* Clears the module-level run-scoped Sentry state — the current scanned project
|
|
15273
|
+
* and the active run trace. `inspect()` calls this at the start of every run and
|
|
15274
|
+
* again after a clean one (it's invoked once per project in a workspace scan),
|
|
15275
|
+
* so a prior or just-finished scan can't attach its project tags / trace to a
|
|
15276
|
+
* later run or to a non-scan error (e.g. inspectAction's post-loop
|
|
15277
|
+
* finalize/handoff steps). A thrown scan error skips the post-run reset, leaving
|
|
15278
|
+
* the state for the command catch to attribute and link the crash. Safe to call
|
|
15279
|
+
* when Sentry is off (the refs are read only when an event is built).
|
|
15280
|
+
*/
|
|
15281
|
+
const resetSentryRunState = () => {
|
|
15282
|
+
setSentryProjectInfo(null);
|
|
15283
|
+
setActiveRunTrace(null);
|
|
15284
|
+
};
|
|
15285
|
+
/**
|
|
15286
|
+
* Runs an inspect invocation inside a Sentry root span (transaction) so each
|
|
15287
|
+
* `react-doctor` run is a first-class trace with timing and the run snapshot as
|
|
15288
|
+
* attributes. The span is handed to `run` so the Effect→Sentry tracer bridge
|
|
15289
|
+
* can parent its spans under it.
|
|
13933
15290
|
*
|
|
13934
|
-
*
|
|
13935
|
-
*
|
|
13936
|
-
* `
|
|
13937
|
-
* `globalThis.console` so imperative legacy callsites that aren't
|
|
13938
|
-
* Effect-typed also fall silent). Sourcing both from a single concrete
|
|
13939
|
-
* object keeps "what is a no-op console" answered in one place; the
|
|
13940
|
-
* earlier `new Proxy({} as Console.Console, { get: () => () => undefined })`
|
|
13941
|
-
* combined a cast with a Proxy to do the same thing implicitly.
|
|
15291
|
+
* A no-op pass-through when Sentry performance tracing is off (Sentry disabled,
|
|
15292
|
+
* `--no-score`, tests, or `SENTRY_TRACES_SAMPLE_RATE=0`) — `run` receives
|
|
15293
|
+
* `undefined` and no transaction is created, so there's no added exit latency.
|
|
13942
15294
|
*
|
|
13943
|
-
*
|
|
13944
|
-
* `
|
|
13945
|
-
*
|
|
15295
|
+
* While the span runs, its trace context is recorded as the active run trace so
|
|
15296
|
+
* `reportErrorToSentry` can attach a crash thrown during the scan back to this
|
|
15297
|
+
* transaction's trace (errors surface in the command catch, after the span has
|
|
15298
|
+
* ended). `inspect()` owns clearing it (and the scanned project): it resets the
|
|
15299
|
+
* state right after a clean run and at the start of the next one, so the trace
|
|
15300
|
+
* is never attached to a non-scan error; on a thrown error the state is left in
|
|
15301
|
+
* place for the command catch, then the process exits.
|
|
13946
15302
|
*/
|
|
13947
|
-
const
|
|
13948
|
-
|
|
13949
|
-
|
|
13950
|
-
|
|
13951
|
-
|
|
13952
|
-
|
|
13953
|
-
|
|
13954
|
-
|
|
13955
|
-
|
|
13956
|
-
|
|
13957
|
-
|
|
13958
|
-
|
|
13959
|
-
|
|
13960
|
-
|
|
13961
|
-
|
|
13962
|
-
|
|
13963
|
-
|
|
13964
|
-
|
|
13965
|
-
|
|
13966
|
-
|
|
13967
|
-
|
|
15303
|
+
const withSentryRunSpan = (run) => {
|
|
15304
|
+
if (!isSentryTracingEnabled()) return run(void 0);
|
|
15305
|
+
const { tags } = buildSentryScope();
|
|
15306
|
+
const command = typeof tags.command === "string" ? tags.command : "inspect";
|
|
15307
|
+
return Sentry.startSpan({
|
|
15308
|
+
name: `react-doctor ${command}`,
|
|
15309
|
+
op: "cli.inspect",
|
|
15310
|
+
attributes: toSpanAttributes(tags)
|
|
15311
|
+
}, (rootSpan) => {
|
|
15312
|
+
const spanContext = rootSpan.spanContext();
|
|
15313
|
+
setActiveRunTrace({
|
|
15314
|
+
traceId: spanContext.traceId,
|
|
15315
|
+
spanId: spanContext.spanId,
|
|
15316
|
+
sampled: (spanContext.traceFlags & 1) === 1
|
|
15317
|
+
});
|
|
15318
|
+
return run(rootSpan);
|
|
15319
|
+
});
|
|
15320
|
+
};
|
|
15321
|
+
/**
|
|
15322
|
+
* Records the scanned project (discovered in the `beforeLint` hook) for Sentry:
|
|
15323
|
+
* remembers it for the lazy error-capture path (`buildSentryScope` folds it into
|
|
15324
|
+
* exception events) and, when tracing is live, sets it as attributes on the
|
|
15325
|
+
* run's root span so the transaction/trace carries the project shape too.
|
|
15326
|
+
* Always cheap — the span attribute set is skipped when `rootSpan` is absent
|
|
15327
|
+
* (tracing off), and storing the info is a plain assignment.
|
|
15328
|
+
*/
|
|
15329
|
+
const recordSentryProjectContext = (projectInfo, rootSpan) => {
|
|
15330
|
+
setSentryProjectInfo(projectInfo);
|
|
15331
|
+
rootSpan?.setAttributes(toSpanAttributes(buildSentryProjectContext(projectInfo).tags));
|
|
15332
|
+
};
|
|
13968
15333
|
//#endregion
|
|
13969
15334
|
//#region src/cli/utils/build-no-score-message.ts
|
|
13970
15335
|
const ENTERPRISE_CONTACT_HINT = `Want something custom to your company? Contact us at ${ENTERPRISE_CONTACT_URL}.`;
|
|
@@ -14011,8 +15376,10 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
|
|
|
14011
15376
|
return priorityB - priorityA;
|
|
14012
15377
|
};
|
|
14013
15378
|
const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
|
|
14014
|
-
const
|
|
14015
|
-
const
|
|
15379
|
+
const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
|
|
15380
|
+
const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
|
|
15381
|
+
const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
|
|
15382
|
+
const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
|
|
14016
15383
|
//#endregion
|
|
14017
15384
|
//#region src/cli/utils/box-text.ts
|
|
14018
15385
|
const ESCAPE = String.fromCharCode(27);
|
|
@@ -14143,15 +15510,17 @@ const buildVerboseSiteMap = (diagnostics) => {
|
|
|
14143
15510
|
return fileSites;
|
|
14144
15511
|
};
|
|
14145
15512
|
const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
|
|
15513
|
+
const formatTrailingSiteBadge = (count) => {
|
|
15514
|
+
const badge = formatSiteCountBadge(count);
|
|
15515
|
+
return badge.length > 0 ? ` ${highlighter.gray(badge)}` : "";
|
|
15516
|
+
};
|
|
14146
15517
|
const categoryTopRuleKey = (categoryGroup) => categoryGroup.ruleGroups[0][0];
|
|
14147
15518
|
const buildCategoryDiagnosticGroups = (diagnostics, rulePriority) => {
|
|
14148
|
-
return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
|
|
14149
|
-
|
|
14150
|
-
|
|
14151
|
-
|
|
14152
|
-
|
|
14153
|
-
};
|
|
14154
|
-
}).toSorted((categoryGroupA, categoryGroupB) => {
|
|
15519
|
+
return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => ({
|
|
15520
|
+
category,
|
|
15521
|
+
diagnostics: categoryDiagnostics,
|
|
15522
|
+
ruleGroups: buildSortedRuleGroups(categoryDiagnostics, rulePriority)
|
|
15523
|
+
})).toSorted((categoryGroupA, categoryGroupB) => {
|
|
14155
15524
|
const priorityDelta = compareByRulePriority(categoryTopRuleKey(categoryGroupA), categoryTopRuleKey(categoryGroupB), rulePriority);
|
|
14156
15525
|
if (priorityDelta !== 0) return priorityDelta;
|
|
14157
15526
|
return categoryGroupA.category.localeCompare(categoryGroupB.category);
|
|
@@ -14167,6 +15536,7 @@ const buildCompactCategoryLine = (categoryGroup) => {
|
|
|
14167
15536
|
};
|
|
14168
15537
|
const TOP_ERROR_DETAIL_INDENT = " ";
|
|
14169
15538
|
const pickRepresentativeDiagnostic = (ruleDiagnostics) => ruleDiagnostics.find((diagnostic) => diagnostic.line > 0) ?? ruleDiagnostics[0];
|
|
15539
|
+
const isErrorRuleGroup = (ruleDiagnostics) => pickRepresentativeDiagnostic(ruleDiagnostics).severity === "error";
|
|
14170
15540
|
const FRAME_CONTEXT_REACH_LINES = 3;
|
|
14171
15541
|
const clusterNearbyDiagnostics = (diagnostics) => {
|
|
14172
15542
|
const byFile = groupBy(diagnostics, (diagnostic) => diagnostic.filePath);
|
|
@@ -14198,17 +15568,17 @@ const formatClusterLocation = (cluster) => {
|
|
|
14198
15568
|
if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
|
|
14199
15569
|
return `${filePath}:${cluster.startLine}`;
|
|
14200
15570
|
};
|
|
14201
|
-
const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
|
|
15571
|
+
const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
|
|
14202
15572
|
const lead = cluster.diagnostics[0];
|
|
14203
15573
|
const isMultiSite = cluster.diagnostics.length > 1;
|
|
14204
15574
|
const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
|
|
14205
|
-
const codeFrame = buildCodeFrame({
|
|
15575
|
+
const codeFrame = renderCodeFrame ? buildCodeFrame({
|
|
14206
15576
|
filePath: lead.filePath,
|
|
14207
15577
|
line: cluster.startLine,
|
|
14208
15578
|
column: isMultiSite ? 0 : lead.column,
|
|
14209
15579
|
endLine: isMultiSite ? cluster.endLine : void 0,
|
|
14210
15580
|
rootDirectory: resolveSourceRoot(lead)
|
|
14211
|
-
});
|
|
15581
|
+
}) : null;
|
|
14212
15582
|
if (codeFrame) lines.push(indentMultilineText(boxText(codeFrame, 60), TOP_ERROR_DETAIL_INDENT));
|
|
14213
15583
|
const seenHints = /* @__PURE__ */ new Set();
|
|
14214
15584
|
for (const diagnostic of cluster.diagnostics) if (diagnostic.suppressionHint && !seenHints.has(diagnostic.suppressionHint)) {
|
|
@@ -14220,23 +15590,60 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
|
|
|
14220
15590
|
const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite) => {
|
|
14221
15591
|
const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
|
|
14222
15592
|
const { severity } = representative;
|
|
14223
|
-
const
|
|
14224
|
-
const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
|
|
15593
|
+
const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
|
|
14225
15594
|
const headline = colorizeBySeverity(`${representative.category}: ${representative.title ?? ruleKey}`, severity);
|
|
14226
15595
|
const lines = [` ${colorizeBySeverity(severity === "error" ? "✗" : "⚠", severity)} ${headline}${trailingBadge}`];
|
|
14227
15596
|
if (!renderEverySite) for (const explanationLine of wrapTextToWidth(representative.message, 60, { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
|
|
14228
15597
|
if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, 60, { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
|
|
15598
|
+
const renderCodeFrame = severity === "error";
|
|
14229
15599
|
const sites = renderEverySite ? ruleDiagnostics : [representative];
|
|
14230
|
-
for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot));
|
|
15600
|
+
for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
|
|
15601
|
+
return lines;
|
|
15602
|
+
};
|
|
15603
|
+
const WARNING_DETAIL_INDENT = " ";
|
|
15604
|
+
const computeRuleNameColumnWidth = (ruleKeys) => ruleKeys.reduce((widest, ruleKey) => Math.max(widest, ruleKey.length), 36);
|
|
15605
|
+
const padRuleNameToColumn = (ruleName, columnWidth) => ruleName.length >= columnWidth ? ruleName : ruleName + " ".repeat(columnWidth - ruleName.length);
|
|
15606
|
+
const buildWarningHeaderLine = (ruleKey, siteCount, ruleNameColumnWidth) => {
|
|
15607
|
+
const ruleName = formatSiteCountBadge(siteCount).length > 0 ? padRuleNameToColumn(ruleKey, ruleNameColumnWidth) : ruleKey;
|
|
15608
|
+
return ` ${highlighter.warn("⚠")} ${ruleName}${formatTrailingSiteBadge(siteCount)}`;
|
|
15609
|
+
};
|
|
15610
|
+
const buildWarningRuleBlock = (ruleKey, ruleDiagnostics, ruleNameColumnWidth, isAgentEnvironment) => {
|
|
15611
|
+
const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
|
|
15612
|
+
const lines = [buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth)];
|
|
15613
|
+
if (!isAgentEnvironment) {
|
|
15614
|
+
const learnMoreLine = formatLearnMoreLine(representative);
|
|
15615
|
+
if (learnMoreLine) lines.push(`${WARNING_DETAIL_INDENT}${highlighter.info(learnMoreLine)}`);
|
|
15616
|
+
}
|
|
15617
|
+
lines.push(highlighter.gray(indentMultilineText(representative.message, WARNING_DETAIL_INDENT)));
|
|
15618
|
+
if (representative.help) lines.push(highlighter.gray(indentMultilineText(`→ ${representative.help}`, WARNING_DETAIL_INDENT)));
|
|
15619
|
+
if (isAgentEnvironment) {
|
|
15620
|
+
const fixRecipeLine = formatFixRecipeLine(representative);
|
|
15621
|
+
if (fixRecipeLine) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${fixRecipeLine}`));
|
|
15622
|
+
}
|
|
15623
|
+
for (const [filePath, sites] of buildVerboseSiteMap(ruleDiagnostics)) {
|
|
15624
|
+
if (sites.length === 0) {
|
|
15625
|
+
lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}`));
|
|
15626
|
+
continue;
|
|
15627
|
+
}
|
|
15628
|
+
for (const site of sites) {
|
|
15629
|
+
lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}:${site.line}`));
|
|
15630
|
+
if (site.suppressionHint) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT} ↳ ${site.suppressionHint}`));
|
|
15631
|
+
}
|
|
15632
|
+
}
|
|
14231
15633
|
return lines;
|
|
14232
15634
|
};
|
|
14233
|
-
const
|
|
14234
|
-
|
|
15635
|
+
const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
|
|
15636
|
+
const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => selectErrorRuleGroups(diagnostics, rulePriority).slice(0, limit);
|
|
15637
|
+
const buildMoreRulesLine = (hiddenRuleCount, severityNoun, accent) => {
|
|
15638
|
+
const ruleNoun = hiddenRuleCount === 1 ? "rule" : "rules";
|
|
15639
|
+
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`)}`;
|
|
14235
15640
|
};
|
|
14236
15641
|
const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
|
|
14237
15642
|
const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
|
|
14238
|
-
const
|
|
15643
|
+
const errorRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority);
|
|
15644
|
+
const topRuleGroups = errorRuleGroups.slice(0, 3);
|
|
14239
15645
|
if (topRuleGroups.length === 0) return [];
|
|
15646
|
+
const hiddenRuleCount = errorRuleGroups.length - topRuleGroups.length;
|
|
14240
15647
|
const lines = [
|
|
14241
15648
|
highlighter.dim(` ${"─".repeat(60)}`),
|
|
14242
15649
|
` ${highlighter.bold(`Top ${topRuleGroups.length} ${topRuleGroups.length === 1 ? "error" : "errors"} you should fix`)}`,
|
|
@@ -14246,6 +15653,23 @@ const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
|
|
|
14246
15653
|
lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false));
|
|
14247
15654
|
lines.push("");
|
|
14248
15655
|
}
|
|
15656
|
+
if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "errors", highlighter.error));
|
|
15657
|
+
return lines;
|
|
15658
|
+
};
|
|
15659
|
+
const buildWarningsListLines = (diagnostics, rulePriority) => {
|
|
15660
|
+
const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
|
|
15661
|
+
if (warningDiagnostics.length === 0) return [];
|
|
15662
|
+
const sortedRuleGroups = buildSortedRuleGroups(warningDiagnostics, rulePriority);
|
|
15663
|
+
const shownRuleGroups = sortedRuleGroups.slice(0, 10);
|
|
15664
|
+
const hiddenRuleCount = sortedRuleGroups.length - shownRuleGroups.length;
|
|
15665
|
+
const ruleNameColumnWidth = computeRuleNameColumnWidth(shownRuleGroups.map(([ruleKey]) => ruleKey));
|
|
15666
|
+
const lines = [
|
|
15667
|
+
highlighter.dim(` ${"─".repeat(60)}`),
|
|
15668
|
+
` ${highlighter.bold(`${warningDiagnostics.length} ${warningDiagnostics.length === 1 ? "warning" : "warnings"}`)}`,
|
|
15669
|
+
""
|
|
15670
|
+
];
|
|
15671
|
+
for (const [ruleKey, ruleDiagnostics] of shownRuleGroups) lines.push(buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth));
|
|
15672
|
+
if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "warnings", highlighter.warn));
|
|
14249
15673
|
return lines;
|
|
14250
15674
|
};
|
|
14251
15675
|
const buildCategoryBreakdownLines = (diagnostics, rulePriority) => buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCompactCategoryLine);
|
|
@@ -14272,12 +15696,18 @@ const buildCountsSummaryLines = (diagnostics) => {
|
|
|
14272
15696
|
* single Effect.forEach over Console.log so failures or fiber
|
|
14273
15697
|
* interruption produce predictable partial output.
|
|
14274
15698
|
*/
|
|
14275
|
-
const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority) => Effect.gen(function* () {
|
|
15699
|
+
const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false) => Effect.gen(function* () {
|
|
14276
15700
|
const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
|
|
14277
15701
|
let detailLines;
|
|
14278
15702
|
if (!isVerbose) detailLines = buildTopErrorsLines(diagnostics, resolveSourceRoot, rulePriority);
|
|
14279
|
-
else
|
|
14280
|
-
|
|
15703
|
+
else {
|
|
15704
|
+
const sortedRuleGroups = buildSortedRuleGroups(diagnostics, rulePriority);
|
|
15705
|
+
const warningRuleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.filter(([, ruleDiagnostics]) => !isErrorRuleGroup(ruleDiagnostics)).map(([ruleKey]) => ruleKey));
|
|
15706
|
+
detailLines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => {
|
|
15707
|
+
return [...isErrorRuleGroup(ruleDiagnostics) ? buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true) : buildWarningRuleBlock(ruleKey, ruleDiagnostics, warningRuleNameColumnWidth, isAgentEnvironment), ""];
|
|
15708
|
+
});
|
|
15709
|
+
}
|
|
15710
|
+
const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines, isVerbose ? [] : buildWarningsListLines(diagnostics, rulePriority));
|
|
14281
15711
|
for (const line of lines) yield* Console.log(line);
|
|
14282
15712
|
});
|
|
14283
15713
|
const formatElapsedTime = (elapsedMilliseconds) => {
|
|
@@ -14296,7 +15726,8 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
|
|
|
14296
15726
|
];
|
|
14297
15727
|
if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
|
|
14298
15728
|
if (firstDiagnostic.url) sections.push("", `Docs: ${firstDiagnostic.url}`);
|
|
14299
|
-
|
|
15729
|
+
const fixRecipeLine = formatFixRecipeLine(firstDiagnostic);
|
|
15730
|
+
if (fixRecipeLine) sections.push("", fixRecipeLine);
|
|
14300
15731
|
sections.push("", "Files:");
|
|
14301
15732
|
const fileSites = buildVerboseSiteMap(ruleDiagnostics);
|
|
14302
15733
|
for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
|
|
@@ -14326,10 +15757,6 @@ const colorizeByScore = (text, score) => {
|
|
|
14326
15757
|
return highlighter.error(text);
|
|
14327
15758
|
};
|
|
14328
15759
|
//#endregion
|
|
14329
|
-
//#region src/cli/utils/constants.ts
|
|
14330
|
-
const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
|
|
14331
|
-
const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
|
|
14332
|
-
//#endregion
|
|
14333
15760
|
//#region src/cli/utils/render-score-header.ts
|
|
14334
15761
|
const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
|
|
14335
15762
|
const RAINBOW_GRADIENT_WIDTH = 80;
|
|
@@ -14522,8 +15949,7 @@ const printNoScoreHeader = (noScoreMessage) => Effect.gen(function* () {
|
|
|
14522
15949
|
const writeDiagnosticsDirectory = (diagnostics) => {
|
|
14523
15950
|
const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
|
|
14524
15951
|
mkdirSync(outputDirectory, { recursive: true });
|
|
14525
|
-
const
|
|
14526
|
-
for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
|
|
15952
|
+
for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(diagnostics)) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
|
|
14527
15953
|
writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
|
|
14528
15954
|
return outputDirectory;
|
|
14529
15955
|
};
|
|
@@ -14543,7 +15969,14 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
|
|
|
14543
15969
|
};
|
|
14544
15970
|
const printVerboseTip = (diagnostics, isVerbose) => Effect.gen(function* () {
|
|
14545
15971
|
if (isVerbose || diagnostics.length === 0) return;
|
|
14546
|
-
|
|
15972
|
+
const command = highlighter.info("npx react-doctor@latest --verbose");
|
|
15973
|
+
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`;
|
|
15974
|
+
yield* Console.log(highlighter.dim(` Tip: ${message}`));
|
|
15975
|
+
});
|
|
15976
|
+
const printDocsNote = () => Effect.gen(function* () {
|
|
15977
|
+
yield* Console.log("");
|
|
15978
|
+
yield* Console.log(` ${highlighter.bold("Docs:")} ${highlighter.info(DOCS_URL)}`);
|
|
15979
|
+
yield* Console.log(highlighter.dim(" Set up CI/CD, suppress rules with a config file, and scan diffs or PRs."));
|
|
14547
15980
|
});
|
|
14548
15981
|
const printSummary = (input) => Effect.gen(function* () {
|
|
14549
15982
|
if (input.scoreResult) {
|
|
@@ -14714,9 +16147,6 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
|
|
|
14714
16147
|
});
|
|
14715
16148
|
const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
|
|
14716
16149
|
//#endregion
|
|
14717
|
-
//#region src/cli/utils/version.ts
|
|
14718
|
-
const VERSION = "0.2.14-dev.6e59f10";
|
|
14719
|
-
//#endregion
|
|
14720
16150
|
//#region src/inspect.ts
|
|
14721
16151
|
const silentConsole = makeNoopConsole();
|
|
14722
16152
|
const runConsole = (effect) => {
|
|
@@ -14741,14 +16171,16 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
14741
16171
|
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
14742
16172
|
share: userConfig?.share ?? true,
|
|
14743
16173
|
respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
|
|
14744
|
-
warnings: inputOptions.warnings ?? userConfig?.warnings ??
|
|
16174
|
+
warnings: inputOptions.warnings ?? userConfig?.warnings ?? true,
|
|
14745
16175
|
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
14746
16176
|
ignoredTags: buildIgnoredTags(userConfig),
|
|
14747
16177
|
outputSurface: inputOptions.outputSurface ?? "cli",
|
|
14748
|
-
suppressRendering: inputOptions.suppressRendering ?? false
|
|
16178
|
+
suppressRendering: inputOptions.suppressRendering ?? false,
|
|
16179
|
+
concurrency: inputOptions.concurrency
|
|
14749
16180
|
});
|
|
14750
16181
|
const inspect = async (directory, inputOptions = {}) => {
|
|
14751
16182
|
const startTime = performance.now();
|
|
16183
|
+
resetSentryRunState();
|
|
14752
16184
|
const hasConfigOverride = inputOptions.configOverride !== void 0;
|
|
14753
16185
|
let scanDirectory;
|
|
14754
16186
|
let userConfig;
|
|
@@ -14758,7 +16190,7 @@ const inspect = async (directory, inputOptions = {}) => {
|
|
|
14758
16190
|
userConfig = inputOptions.configOverride ?? null;
|
|
14759
16191
|
configSourceDirectory = null;
|
|
14760
16192
|
} else {
|
|
14761
|
-
const scanTarget = resolveScanTarget(directory);
|
|
16193
|
+
const scanTarget = await resolveScanTarget(directory);
|
|
14762
16194
|
scanDirectory = scanTarget.resolvedDirectory;
|
|
14763
16195
|
userConfig = scanTarget.userConfig;
|
|
14764
16196
|
configSourceDirectory = scanTarget.configSourceDirectory;
|
|
@@ -14767,12 +16199,14 @@ const inspect = async (directory, inputOptions = {}) => {
|
|
|
14767
16199
|
const wasSpinnerSilent = isSpinnerSilent();
|
|
14768
16200
|
if (options.silent) setSpinnerSilent(true);
|
|
14769
16201
|
try {
|
|
14770
|
-
|
|
16202
|
+
const result = await withSentryRunSpan((rootSentrySpan) => runInspectWithRuntime(scanDirectory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, rootSentrySpan));
|
|
16203
|
+
resetSentryRunState();
|
|
16204
|
+
return result;
|
|
14771
16205
|
} finally {
|
|
14772
16206
|
if (options.silent) setSpinnerSilent(wasSpinnerSilent);
|
|
14773
16207
|
}
|
|
14774
16208
|
};
|
|
14775
|
-
const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime) => {
|
|
16209
|
+
const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOverride, configSourceDirectory, startTime, rootSentrySpan) => {
|
|
14776
16210
|
const isDiffMode = options.includePaths.length > 0;
|
|
14777
16211
|
const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
|
|
14778
16212
|
const lintBindingMissing = options.lint && !resolvedNodeBinaryPath;
|
|
@@ -14785,7 +16219,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14785
16219
|
shouldSkipLint: !options.lint || lintBindingMissing,
|
|
14786
16220
|
shouldRunDeadCode: options.deadCode,
|
|
14787
16221
|
shouldComputeScore: !options.noScore,
|
|
14788
|
-
shouldShowProgressSpinners
|
|
16222
|
+
shouldShowProgressSpinners,
|
|
16223
|
+
oxlintConcurrency: options.concurrency
|
|
14789
16224
|
});
|
|
14790
16225
|
const program = runInspect({
|
|
14791
16226
|
directory,
|
|
@@ -14802,6 +16237,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14802
16237
|
resolveLocalGithubViewerPermission: !options.noScore,
|
|
14803
16238
|
suppressScanSummary: options.suppressRendering
|
|
14804
16239
|
}, { beforeLint: (projectInfo, lintIncludePaths) => Effect.gen(function* () {
|
|
16240
|
+
recordSentryProjectContext(projectInfo, rootSentrySpan);
|
|
14805
16241
|
if (options.scoreOnly || options.suppressRendering) return;
|
|
14806
16242
|
const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
|
|
14807
16243
|
yield* printProjectDetection({
|
|
@@ -14812,7 +16248,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14812
16248
|
lintSourceFileCount
|
|
14813
16249
|
});
|
|
14814
16250
|
}) });
|
|
14815
|
-
const programWithLayers = options.silent ? program.pipe(Effect.provide(layers), Effect.provideService(Console.Console, silentConsole)
|
|
16251
|
+
const programWithLayers = applyObservability(options.silent ? program.pipe(Effect.provide(layers), Effect.provideService(Console.Console, silentConsole)) : program.pipe(Effect.provide(layers)), rootSentrySpan);
|
|
14816
16252
|
const output = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
|
|
14817
16253
|
const didLintFail = lintBindingMissing || output.didLintFail;
|
|
14818
16254
|
const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
|
|
@@ -14841,15 +16277,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14841
16277
|
};
|
|
14842
16278
|
const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
14843
16279
|
const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
|
|
14844
|
-
const skippedChecks =
|
|
14845
|
-
|
|
14846
|
-
|
|
16280
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
|
|
16281
|
+
didLintFail,
|
|
16282
|
+
lintFailureReason,
|
|
16283
|
+
lintPartialFailures,
|
|
16284
|
+
didDeadCodeFail,
|
|
16285
|
+
deadCodeFailureReason
|
|
16286
|
+
});
|
|
14847
16287
|
const hasSkippedChecks = skippedChecks.length > 0;
|
|
14848
16288
|
const noScoreMessage = buildNoScoreMessage(options.noScore);
|
|
14849
|
-
const skippedCheckReasons = {};
|
|
14850
|
-
if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
|
|
14851
|
-
else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
|
|
14852
|
-
if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
|
|
14853
16289
|
const buildResult = () => ({
|
|
14854
16290
|
diagnostics: [...diagnostics],
|
|
14855
16291
|
score,
|
|
@@ -14885,7 +16321,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
14885
16321
|
return buildResult();
|
|
14886
16322
|
}
|
|
14887
16323
|
yield* Console.log("");
|
|
14888
|
-
yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]));
|
|
16324
|
+
yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment());
|
|
14889
16325
|
if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
|
|
14890
16326
|
if (demotedDiagnosticCount > 0) {
|
|
14891
16327
|
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.`));
|
|
@@ -14910,6 +16346,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
14910
16346
|
yield* Console.warn(highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`));
|
|
14911
16347
|
}
|
|
14912
16348
|
yield* printVerboseTip([...surfaceDiagnostics], options.verbose);
|
|
16349
|
+
yield* printDocsNote();
|
|
14913
16350
|
return buildResult();
|
|
14914
16351
|
});
|
|
14915
16352
|
//#endregion
|
|
@@ -14954,7 +16391,7 @@ const getErrorReportContext = () => ({
|
|
|
14954
16391
|
isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE])
|
|
14955
16392
|
});
|
|
14956
16393
|
const formatConfiguredState = (isConfigured) => isConfigured ? "yes" : "no";
|
|
14957
|
-
const buildErrorIssueBody = (error, context) => {
|
|
16394
|
+
const buildErrorIssueBody = (error, context, sentryEventId) => {
|
|
14958
16395
|
const formattedError = formatErrorForReport(error) || "(empty error)";
|
|
14959
16396
|
const isOtlpExporterEnabled = context.isOtlpEndpointConfigured && context.isOtlpAuthHeaderConfigured;
|
|
14960
16397
|
return [
|
|
@@ -14971,6 +16408,7 @@ const buildErrorIssueBody = (error, context) => {
|
|
|
14971
16408
|
`- platform: ${context.platform} ${context.architecture}`,
|
|
14972
16409
|
`- cwd: ${context.cwd}`,
|
|
14973
16410
|
`- command: ${context.command}`,
|
|
16411
|
+
...sentryEventId ? [`- Sentry reference: ${sentryEventId}`] : [],
|
|
14974
16412
|
"",
|
|
14975
16413
|
"## OpenTelemetry",
|
|
14976
16414
|
"",
|
|
@@ -14984,12 +16422,12 @@ const buildErrorIssueBody = (error, context) => {
|
|
|
14984
16422
|
"Please add reproduction steps and any relevant repository details."
|
|
14985
16423
|
].join("\n");
|
|
14986
16424
|
};
|
|
14987
|
-
const buildErrorIssueUrl = (error) => {
|
|
16425
|
+
const buildErrorIssueUrl = (error, sentryEventId) => {
|
|
14988
16426
|
const formattedError = formatSingleLine(formatErrorForReport(error));
|
|
14989
16427
|
const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
|
|
14990
16428
|
issueUrl.searchParams.set("title", formattedError ? `CLI error: ${formattedError}` : "CLI error");
|
|
14991
16429
|
issueUrl.searchParams.set("labels", "bug");
|
|
14992
|
-
issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext()));
|
|
16430
|
+
issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext(), sentryEventId));
|
|
14993
16431
|
return issueUrl.toString();
|
|
14994
16432
|
};
|
|
14995
16433
|
/**
|
|
@@ -14999,10 +16437,12 @@ const buildErrorIssueUrl = (error) => {
|
|
|
14999
16437
|
* red-highlighted (matches the historical `consoleLogger.error`
|
|
15000
16438
|
* contract) so the user sees a clearly distinguished error block.
|
|
15001
16439
|
*/
|
|
15002
|
-
const handleErrorEffect = (error) => Effect.gen(function* () {
|
|
16440
|
+
const handleErrorEffect = (error, sentryEventId) => Effect.gen(function* () {
|
|
15003
16441
|
yield* Console.error("");
|
|
15004
16442
|
yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
|
|
15005
|
-
yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
|
|
16443
|
+
yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error, sentryEventId)}`));
|
|
16444
|
+
yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
|
|
16445
|
+
if (sentryEventId) yield* Console.error(highlighter.error(`Reference (mention this when reporting): ${sentryEventId}`));
|
|
15006
16446
|
yield* Console.error("");
|
|
15007
16447
|
yield* Console.error(highlighter.error(formatErrorForReport(error)));
|
|
15008
16448
|
yield* Console.error("");
|
|
@@ -15012,15 +16452,15 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
|
|
|
15012
16452
|
* aren't yet Effect-typed). Bridges via `Effect.runSync` so the
|
|
15013
16453
|
* underlying Console writes happen exactly like the Effect path.
|
|
15014
16454
|
*/
|
|
15015
|
-
const handleError = (error, options = {
|
|
15016
|
-
Effect.runSync(handleErrorEffect(error));
|
|
16455
|
+
const handleError = (error, options = {}) => {
|
|
16456
|
+
Effect.runSync(handleErrorEffect(error, options.sentryEventId));
|
|
15017
16457
|
if (options.shouldExit !== false) process.exit(1);
|
|
15018
16458
|
process.exitCode = 1;
|
|
15019
16459
|
};
|
|
15020
16460
|
//#endregion
|
|
15021
16461
|
//#region src/cli/utils/build-handoff-payload.ts
|
|
15022
16462
|
const buildHandoffPayload = (input) => {
|
|
15023
|
-
const topGroups =
|
|
16463
|
+
const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
|
|
15024
16464
|
let diagnosticsDirectory = null;
|
|
15025
16465
|
try {
|
|
15026
16466
|
diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
|
|
@@ -15029,7 +16469,9 @@ const buildHandoffPayload = (input) => {
|
|
|
15029
16469
|
topGroups.forEach(([ruleKey, ruleDiagnostics], index) => {
|
|
15030
16470
|
const representative = ruleDiagnostics[0];
|
|
15031
16471
|
const severityLabel = representative.severity === "error" ? "ERROR" : "WARN";
|
|
15032
|
-
lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (×${ruleDiagnostics.length})`, ` ${representative.message}
|
|
16472
|
+
lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (×${ruleDiagnostics.length})`, ` ${representative.message}`);
|
|
16473
|
+
const fixRecipeLine = formatFixRecipeLine(representative);
|
|
16474
|
+
if (fixRecipeLine) lines.push(` ${fixRecipeLine}`);
|
|
15033
16475
|
const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
|
|
15034
16476
|
for (const filePath of uniqueFiles.slice(0, 3)) {
|
|
15035
16477
|
const firstSite = ruleDiagnostics.find((diagnostic) => diagnostic.filePath === filePath && diagnostic.line > 0);
|
|
@@ -15040,7 +16482,7 @@ const buildHandoffPayload = (input) => {
|
|
|
15040
16482
|
});
|
|
15041
16483
|
lines.push("");
|
|
15042
16484
|
if (diagnosticsDirectory) lines.push(`Full results for all ${input.diagnostics.length} issues (diagnostics.json + a .txt per rule): ${diagnosticsDirectory}`, "");
|
|
15043
|
-
lines.push("Read each file and fix the root cause — don't suppress.
|
|
16485
|
+
lines.push("Read each file and fix the root cause — don't suppress or silence the rule.", "", "Verify against the real thing, don't assume: confirm each change matches the canonical fix recipe you fetched for that rule, then re-run `npx react-doctor@latest --verbose` and check the issue is actually gone against the real tool before moving on.", "", "Teach me as you go: for every issue you touch, explain it in plain language (no jargon) — what the problem is, why it's a problem, and how serious it is in human terms. Describe the real-world impact and severity concretely (e.g. \"this crashes the page for users on Safari\" vs. \"this is a minor cleanup with no user impact\") so I understand why it matters, not just what changed.", "", "Then work through the rest from the full results above.");
|
|
15044
16486
|
return lines.join("\n");
|
|
15045
16487
|
};
|
|
15046
16488
|
//#endregion
|
|
@@ -15261,7 +16703,7 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
|
|
|
15261
16703
|
const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
|
|
15262
16704
|
const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
|
|
15263
16705
|
const CURSOR_HOOKS_SCHEMA_VERSION = 1;
|
|
15264
|
-
const JSON_INDENT_SPACES = 2;
|
|
16706
|
+
const JSON_INDENT_SPACES$1 = 2;
|
|
15265
16707
|
const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
|
|
15266
16708
|
const readJsonFile = (filePath, fallback) => {
|
|
15267
16709
|
if (!existsSync(filePath)) return fallback;
|
|
@@ -15271,7 +16713,7 @@ const readJsonFile = (filePath, fallback) => {
|
|
|
15271
16713
|
};
|
|
15272
16714
|
const writeJsonFile = (filePath, value) => {
|
|
15273
16715
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
15274
|
-
writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES)}\n`);
|
|
16716
|
+
writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
|
|
15275
16717
|
};
|
|
15276
16718
|
const writeHookScript = (filePath) => {
|
|
15277
16719
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -16037,6 +17479,29 @@ const getSkillSourceDirectory = () => {
|
|
|
16037
17479
|
const distDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
16038
17480
|
return path.join(distDirectory, "skills", SKILL_NAME);
|
|
16039
17481
|
};
|
|
17482
|
+
const findBundledSiblingSkills = (primarySkillDir) => {
|
|
17483
|
+
const skillsParent = path.dirname(primarySkillDir);
|
|
17484
|
+
if (!existsSync(skillsParent)) return [];
|
|
17485
|
+
const resolvedPrimary = path.resolve(primarySkillDir);
|
|
17486
|
+
return readdirSync(skillsParent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => ({
|
|
17487
|
+
name: entry.name,
|
|
17488
|
+
source: path.join(skillsParent, entry.name)
|
|
17489
|
+
})).filter((sibling) => path.resolve(sibling.source) !== resolvedPrimary && existsSync(path.join(sibling.source, SKILL_MANIFEST_FILE)));
|
|
17490
|
+
};
|
|
17491
|
+
const installBundledSiblingSkills = async (primarySkillDir, agents, projectRoot) => {
|
|
17492
|
+
const installedSkillNames = [];
|
|
17493
|
+
for (const sibling of findBundledSiblingSkills(primarySkillDir)) {
|
|
17494
|
+
const result = await installSkillsFromSource({
|
|
17495
|
+
source: sibling.source,
|
|
17496
|
+
agents: [...agents],
|
|
17497
|
+
cwd: projectRoot,
|
|
17498
|
+
mode: "copy"
|
|
17499
|
+
});
|
|
17500
|
+
if (result.failed.length > 0) throw new Error(result.failed.map((failure) => `${getSkillAgentConfig(failure.agent).displayName}: ${failure.error}`).join("\n"));
|
|
17501
|
+
if (result.skills.length > 0) installedSkillNames.push(sibling.name);
|
|
17502
|
+
}
|
|
17503
|
+
return installedSkillNames;
|
|
17504
|
+
};
|
|
16040
17505
|
const canInstallNativeAgentHooks = (agents) => agents.some((agent) => agent === "claude-code" || agent === "cursor");
|
|
16041
17506
|
const buildWorkflowContent = () => [
|
|
16042
17507
|
"name: React Doctor",
|
|
@@ -16143,6 +17608,7 @@ const runInstallReactDoctor = async (options = {}) => {
|
|
|
16143
17608
|
cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
|
|
16144
17609
|
for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
|
|
16145
17610
|
cliLogger.dim(` Source: ${sourceDir}`);
|
|
17611
|
+
for (const sibling of findBundledSiblingSkills(sourceDir)) cliLogger.dim(` Also installs skill: ${sibling.name}`);
|
|
16146
17612
|
cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
|
|
16147
17613
|
cliLogger.dim(" Dev dependency: react-doctor");
|
|
16148
17614
|
if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
|
|
@@ -16165,6 +17631,12 @@ const runInstallReactDoctor = async (options = {}) => {
|
|
|
16165
17631
|
installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
|
|
16166
17632
|
throw error;
|
|
16167
17633
|
}
|
|
17634
|
+
try {
|
|
17635
|
+
const installedSiblingSkills = await installBundledSiblingSkills(sourceDir, selectedAgents, projectRoot);
|
|
17636
|
+
if (installedSiblingSkills.length > 0) cliLogger.dim(` Also installed the ${installedSiblingSkills.join(", ")} skill.`);
|
|
17637
|
+
} catch {
|
|
17638
|
+
cliLogger.dim(" Skipped bundled sibling skills (install error).");
|
|
17639
|
+
}
|
|
16168
17640
|
await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
|
|
16169
17641
|
if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
|
|
16170
17642
|
const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
|
|
@@ -16325,100 +17797,93 @@ const handoffToAgent = async (input) => {
|
|
|
16325
17797
|
});
|
|
16326
17798
|
if (handoffTarget === PRINT_CHOICE) {
|
|
16327
17799
|
printPayload(payload);
|
|
16328
|
-
return;
|
|
16329
|
-
}
|
|
16330
|
-
if (handoffTarget === CLIPBOARD_CHOICE) {
|
|
16331
|
-
if (await copyToClipboard(payload)) cliLogger.log("Copied the prompt to your clipboard.");
|
|
16332
|
-
else printPayload(payload);
|
|
16333
|
-
return;
|
|
16334
|
-
}
|
|
16335
|
-
const agentId = handoffTarget;
|
|
16336
|
-
const displayName = getSkillAgentConfig(agentId).displayName;
|
|
16337
|
-
const skillSpinner = spinner(`Installing the /react-doctor skill for ${displayName}...`).start();
|
|
16338
|
-
try {
|
|
16339
|
-
if (await installReactDoctorSkillForAgent(agentId, input.rootDirectory)) skillSpinner.succeed(`Installed the /react-doctor skill for ${displayName}.`);
|
|
16340
|
-
else skillSpinner.stop();
|
|
16341
|
-
} catch {
|
|
16342
|
-
skillSpinner.stop();
|
|
16343
|
-
}
|
|
16344
|
-
cliLogger.log(highlighter.dim(`Handing off to ${displayName}...`));
|
|
16345
|
-
try {
|
|
16346
|
-
await launchCliAgent(agentId, payload, input.rootDirectory);
|
|
16347
|
-
} catch {
|
|
16348
|
-
cliLogger.warn(`Couldn't launch ${CLI_AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
|
|
16349
|
-
printPayload(payload);
|
|
16350
|
-
}
|
|
16351
|
-
};
|
|
16352
|
-
//#endregion
|
|
16353
|
-
//#region src/cli/utils/json-mode.ts
|
|
16354
|
-
let context = null;
|
|
16355
|
-
/**
|
|
16356
|
-
* JSON mode writes the report payload to stdout; any incidental log
|
|
16357
|
-
* line printed by an Effect program would corrupt the JSON. Effect's
|
|
16358
|
-
* `Console` module resolves to `globalThis.console` by default (see
|
|
16359
|
-
* `effect/internal/effect.ts` → `ConsoleRef`), so copying the methods
|
|
16360
|
-
* from `makeNoopConsole()` onto the global is enough to silence every
|
|
16361
|
-
* `yield* Console.log(...)` and `cliLogger.*` call sourced from
|
|
16362
|
-
* react-doctor or its services.
|
|
16363
|
-
*
|
|
16364
|
-
* We use the same `makeNoopConsole()` source as the `--silent` path
|
|
16365
|
-
* (which provides the Effect Console via
|
|
16366
|
-
* `Effect.provideService(Console.Console, makeNoopConsole())`) — one
|
|
16367
|
-
* canonical "no-op console" definition shared by the two silent
|
|
16368
|
-
* mechanisms. The two routes still differ in how they install the
|
|
16369
|
-
* noop: silent mode swaps the Effect Console reference inside the
|
|
16370
|
-
* program; JSON mode patches the global because the surrounding CLI
|
|
16371
|
-
* command body is still imperative. Both will collapse into the
|
|
16372
|
-
* Effect-typed route once the command body finishes its migration.
|
|
16373
|
-
*
|
|
16374
|
-
* JSON mode is one-shot per CLI invocation, so we never restore.
|
|
16375
|
-
*/
|
|
16376
|
-
const installSilentConsole = () => {
|
|
16377
|
-
const noopConsole = makeNoopConsole();
|
|
16378
|
-
const target = globalThis.console;
|
|
16379
|
-
const source = noopConsole;
|
|
16380
|
-
for (const key of [
|
|
16381
|
-
"log",
|
|
16382
|
-
"error",
|
|
16383
|
-
"warn",
|
|
16384
|
-
"info",
|
|
16385
|
-
"debug",
|
|
16386
|
-
"trace"
|
|
16387
|
-
]) target[key] = source[key];
|
|
16388
|
-
};
|
|
16389
|
-
const enableJsonMode = ({ compact, directory }) => {
|
|
16390
|
-
context = {
|
|
16391
|
-
compact,
|
|
16392
|
-
directory,
|
|
16393
|
-
startTime: performance.now(),
|
|
16394
|
-
mode: "full"
|
|
16395
|
-
};
|
|
16396
|
-
installSilentConsole();
|
|
16397
|
-
};
|
|
16398
|
-
const isJsonModeActive = () => context !== null;
|
|
16399
|
-
const setJsonReportDirectory = (directory) => {
|
|
16400
|
-
if (context) context.directory = directory;
|
|
16401
|
-
};
|
|
16402
|
-
const setJsonReportMode = (mode) => {
|
|
16403
|
-
if (context) context.mode = mode;
|
|
16404
|
-
};
|
|
16405
|
-
const writeJsonReport = (report) => {
|
|
16406
|
-
const serialized = context?.compact ? JSON.stringify(report) : JSON.stringify(report, null, 2);
|
|
16407
|
-
process.stdout.write(`${serialized}\n`);
|
|
17800
|
+
return;
|
|
17801
|
+
}
|
|
17802
|
+
if (handoffTarget === CLIPBOARD_CHOICE) {
|
|
17803
|
+
if (await copyToClipboard(payload)) cliLogger.log("Copied the prompt to your clipboard.");
|
|
17804
|
+
else printPayload(payload);
|
|
17805
|
+
return;
|
|
17806
|
+
}
|
|
17807
|
+
const agentId = handoffTarget;
|
|
17808
|
+
const displayName = getSkillAgentConfig(agentId).displayName;
|
|
17809
|
+
const skillSpinner = spinner(`Installing the /react-doctor skill for ${displayName}...`).start();
|
|
17810
|
+
try {
|
|
17811
|
+
if (await installReactDoctorSkillForAgent(agentId, input.rootDirectory)) skillSpinner.succeed(`Installed the /react-doctor skill for ${displayName}.`);
|
|
17812
|
+
else skillSpinner.stop();
|
|
17813
|
+
} catch {
|
|
17814
|
+
skillSpinner.stop();
|
|
17815
|
+
}
|
|
17816
|
+
cliLogger.log(highlighter.dim(`Handing off to ${displayName}...`));
|
|
17817
|
+
try {
|
|
17818
|
+
await launchCliAgent(agentId, payload, input.rootDirectory);
|
|
17819
|
+
} catch {
|
|
17820
|
+
cliLogger.warn(`Couldn't launch ${CLI_AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
|
|
17821
|
+
printPayload(payload);
|
|
17822
|
+
}
|
|
16408
17823
|
};
|
|
16409
|
-
|
|
16410
|
-
|
|
17824
|
+
//#endregion
|
|
17825
|
+
//#region src/cli/utils/read-object-file.ts
|
|
17826
|
+
/**
|
|
17827
|
+
* Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
|
|
17828
|
+
* unparseable, or not an object. JSON5 parsing tolerates comments and
|
|
17829
|
+
* trailing commas so hand-edited config files round-trip.
|
|
17830
|
+
*/
|
|
17831
|
+
const readObjectFile = (filePath) => {
|
|
16411
17832
|
try {
|
|
16412
|
-
|
|
16413
|
-
|
|
16414
|
-
directory: context.directory,
|
|
16415
|
-
error,
|
|
16416
|
-
elapsedMilliseconds: performance.now() - context.startTime,
|
|
16417
|
-
mode: context.mode
|
|
16418
|
-
}));
|
|
17833
|
+
const parsed = parseJSON5(readFileSync(filePath, "utf-8"));
|
|
17834
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
16419
17835
|
} catch {
|
|
16420
|
-
|
|
17836
|
+
return null;
|
|
17837
|
+
}
|
|
17838
|
+
};
|
|
17839
|
+
//#endregion
|
|
17840
|
+
//#region src/cli/utils/serialize-ts-object-literal.ts
|
|
17841
|
+
const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
17842
|
+
const INDENT_UNIT = " ";
|
|
17843
|
+
const serializeKey = (key) => SAFE_IDENTIFIER_PATTERN.test(key) ? key : JSON.stringify(key);
|
|
17844
|
+
/**
|
|
17845
|
+
* Serializes a JSON-compatible value as an idiomatic TypeScript literal:
|
|
17846
|
+
* identifier-shaped object keys stay unquoted, two-space indented, no blank
|
|
17847
|
+
* lines. Intended for JSON-sourced config values (string / number / boolean /
|
|
17848
|
+
* null / array / plain object); any other type falls back to its JSON form.
|
|
17849
|
+
*/
|
|
17850
|
+
const serializeTsObjectLiteral = (value, depth = 0) => {
|
|
17851
|
+
const indent = INDENT_UNIT.repeat(depth);
|
|
17852
|
+
const childIndent = INDENT_UNIT.repeat(depth + 1);
|
|
17853
|
+
if (Array.isArray(value)) {
|
|
17854
|
+
if (value.length === 0) return "[]";
|
|
17855
|
+
return `[\n${value.map((item) => `${childIndent}${serializeTsObjectLiteral(item, depth + 1)}`).join(",\n")}\n${indent}]`;
|
|
16421
17856
|
}
|
|
17857
|
+
if (isPlainObject(value)) {
|
|
17858
|
+
const keys = Object.keys(value);
|
|
17859
|
+
if (keys.length === 0) return "{}";
|
|
17860
|
+
return `{\n${keys.map((key) => `${childIndent}${serializeKey(key)}: ${serializeTsObjectLiteral(value[key], depth + 1)}`).join(",\n")}\n${indent}}`;
|
|
17861
|
+
}
|
|
17862
|
+
return JSON.stringify(value);
|
|
17863
|
+
};
|
|
17864
|
+
//#endregion
|
|
17865
|
+
//#region src/cli/utils/migrate-legacy-config.ts
|
|
17866
|
+
const MIGRATED_CONFIG_FILENAME = "doctor.config.ts";
|
|
17867
|
+
/**
|
|
17868
|
+
* Renames a pre-migration `react-doctor.config.json` to a typed
|
|
17869
|
+
* `doctor.config.ts`, preserving the user's settings as the default export.
|
|
17870
|
+
* `$schema` is dropped — the `ReactDoctorConfig` type supersedes it for
|
|
17871
|
+
* editor autocomplete. Returns the new file's absolute path, or `null` when
|
|
17872
|
+
* the legacy file can't be parsed as an object (left untouched so the user
|
|
17873
|
+
* can resolve it by hand).
|
|
17874
|
+
*/
|
|
17875
|
+
const migrateLegacyConfig = (legacy) => {
|
|
17876
|
+
const parsed = readObjectFile(legacy.legacyFilePath);
|
|
17877
|
+
if (!parsed) return null;
|
|
17878
|
+
const config = { ...parsed };
|
|
17879
|
+
delete config.$schema;
|
|
17880
|
+
const targetPath = path.join(legacy.directory, MIGRATED_CONFIG_FILENAME);
|
|
17881
|
+
writeFileSync(targetPath, `import type { ReactDoctorConfig } from "react-doctor/api";
|
|
17882
|
+
|
|
17883
|
+
export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
|
|
17884
|
+
`);
|
|
17885
|
+
rmSync(legacy.legacyFilePath, { force: true });
|
|
17886
|
+
return targetPath;
|
|
16422
17887
|
};
|
|
16423
17888
|
//#endregion
|
|
16424
17889
|
//#region src/cli/utils/annotation-encoding.ts
|
|
@@ -16450,6 +17915,45 @@ const printBrandedHeader = Effect.gen(function* () {
|
|
|
16450
17915
|
yield* Console.log("");
|
|
16451
17916
|
});
|
|
16452
17917
|
//#endregion
|
|
17918
|
+
//#region src/cli/utils/report-error.ts
|
|
17919
|
+
/**
|
|
17920
|
+
* Sends an error to Sentry — enriched with a fresh snapshot of the current run
|
|
17921
|
+
* (version, platform, CI/agent, invocation, scanned project) and, when a run
|
|
17922
|
+
* transaction is in flight, linked to its trace via the scope's propagation
|
|
17923
|
+
* context so the crash and its transaction share a `trace_id` — then waits for
|
|
17924
|
+
* delivery before the caller exits. The CLI tears down synchronously after
|
|
17925
|
+
* rendering an error, so the awaited `flush` is what actually gets the event
|
|
17926
|
+
* (and any in-flight transaction) off the machine.
|
|
17927
|
+
*
|
|
17928
|
+
* Returns the Sentry event id so the caller can surface it as a reference the
|
|
17929
|
+
* user can quote when reporting the bug; returns `undefined` when Sentry was
|
|
17930
|
+
* never initialized (`--no-score`, tests, or a missing DSN) or delivery failed.
|
|
17931
|
+
* Swallows any transport failure so telemetry can never mask the user's
|
|
17932
|
+
* original error.
|
|
17933
|
+
*/
|
|
17934
|
+
const reportErrorToSentry = async (error) => {
|
|
17935
|
+
if (!Sentry.isInitialized()) return void 0;
|
|
17936
|
+
try {
|
|
17937
|
+
const { tags, contexts } = buildSentryScope();
|
|
17938
|
+
const runTrace = getActiveRunTrace();
|
|
17939
|
+
const eventId = Sentry.withScope((scope) => {
|
|
17940
|
+
for (const [name, context] of Object.entries(contexts)) scope.setContext(name, context);
|
|
17941
|
+
scope.setTags(tags);
|
|
17942
|
+
if (runTrace) scope.setPropagationContext({
|
|
17943
|
+
traceId: runTrace.traceId,
|
|
17944
|
+
parentSpanId: runTrace.spanId,
|
|
17945
|
+
sampled: runTrace.sampled,
|
|
17946
|
+
sampleRand: Math.random()
|
|
17947
|
+
});
|
|
17948
|
+
return Sentry.captureException(error);
|
|
17949
|
+
});
|
|
17950
|
+
await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
|
|
17951
|
+
return eventId;
|
|
17952
|
+
} catch {
|
|
17953
|
+
return;
|
|
17954
|
+
}
|
|
17955
|
+
};
|
|
17956
|
+
//#endregion
|
|
16453
17957
|
//#region src/cli/utils/path-format.ts
|
|
16454
17958
|
const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
|
|
16455
17959
|
//#endregion
|
|
@@ -16517,7 +18021,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
|
|
|
16517
18021
|
yield* Console.log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalScanElapsedMilliseconds)}`);
|
|
16518
18022
|
if (surfaceDiagnostics.length > 0) {
|
|
16519
18023
|
yield* Console.log("");
|
|
16520
|
-
yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)));
|
|
18024
|
+
yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment());
|
|
16521
18025
|
}
|
|
16522
18026
|
const lowestScoredScan = findLowestScoredScan(completedScans);
|
|
16523
18027
|
const aggregateScore = lowestScoredScan?.result.score ?? null;
|
|
@@ -16547,6 +18051,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
|
|
|
16547
18051
|
for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
|
|
16548
18052
|
yield* Console.log("");
|
|
16549
18053
|
yield* printVerboseTip(surfaceDiagnostics, verbose);
|
|
18054
|
+
yield* printDocsNote();
|
|
16550
18055
|
});
|
|
16551
18056
|
//#endregion
|
|
16552
18057
|
//#region src/cli/utils/prompt-install-setup.ts
|
|
@@ -16594,6 +18099,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
|
|
|
16594
18099
|
for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
|
|
16595
18100
|
};
|
|
16596
18101
|
//#endregion
|
|
18102
|
+
//#region src/cli/utils/resolve-parallel-flag.ts
|
|
18103
|
+
/**
|
|
18104
|
+
* Translates the `--experimental-parallel [workers]` flag into a concrete
|
|
18105
|
+
* worker count for `InspectOptions.concurrency`:
|
|
18106
|
+
*
|
|
18107
|
+
* - flag absent (`undefined`) → `undefined` (defer to the ambient
|
|
18108
|
+
* default: serial unless `REACT_DOCTOR_PARALLEL` is set)
|
|
18109
|
+
* - bare flag / `auto` → auto-detect CPU cores
|
|
18110
|
+
* - `--experimental-parallel <n>` → `n` workers (clamped)
|
|
18111
|
+
* - `false` / `off` / `0` → serial (an explicit opt-out, so
|
|
18112
|
+
* it overrides an env-enabled default rather than deferring to it)
|
|
18113
|
+
* - an unparseable value → auto-detect cores
|
|
18114
|
+
*
|
|
18115
|
+
* Commander yields `true` for a bare flag, the raw string for an explicit
|
|
18116
|
+
* value, and `undefined` when the flag is omitted.
|
|
18117
|
+
*/
|
|
18118
|
+
const resolveParallelFlag = (parallel) => {
|
|
18119
|
+
if (parallel === void 0) return void 0;
|
|
18120
|
+
if (parallel === true) return resolveScanConcurrency("auto");
|
|
18121
|
+
if (parallel === false) return 1;
|
|
18122
|
+
const normalized = parallel.trim().toLowerCase();
|
|
18123
|
+
if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
|
|
18124
|
+
if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
|
|
18125
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
18126
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
|
|
18127
|
+
return resolveScanConcurrency(parsed);
|
|
18128
|
+
};
|
|
18129
|
+
//#endregion
|
|
16597
18130
|
//#region src/cli/utils/resolve-cli-inspect-options.ts
|
|
16598
18131
|
/**
|
|
16599
18132
|
* Translates CLI flags into the `InspectOptions` contract `inspect()`
|
|
@@ -16616,10 +18149,11 @@ const resolveCliInspectOptions = (flags, userConfig) => {
|
|
|
16616
18149
|
respectInlineDisables: flags.respectInlineDisables,
|
|
16617
18150
|
warnings: flags.warnings ?? (wantsWarningGate ? true : void 0),
|
|
16618
18151
|
scoreOnly: flags.score === true,
|
|
16619
|
-
noScore: flags.score === false || (userConfig?.noScore ?? false),
|
|
18152
|
+
noScore: flags.score === false || flags.telemetry === false || (userConfig?.noScore ?? false),
|
|
16620
18153
|
isCi: isCiEnvironment(),
|
|
16621
18154
|
silent: Boolean(flags.json),
|
|
16622
|
-
outputSurface: flags.prComment ? "prComment" : "cli"
|
|
18155
|
+
outputSurface: flags.prComment ? "prComment" : "cli",
|
|
18156
|
+
concurrency: resolveParallelFlag(flags.experimentalParallel)
|
|
16623
18157
|
};
|
|
16624
18158
|
};
|
|
16625
18159
|
//#endregion
|
|
@@ -16778,11 +18312,9 @@ const parseFileLineArgument = (rawArgument) => {
|
|
|
16778
18312
|
//#endregion
|
|
16779
18313
|
//#region src/cli/utils/select-projects.ts
|
|
16780
18314
|
const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
18315
|
+
const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json"));
|
|
16781
18316
|
let packages = listWorkspacePackages(rootDirectory);
|
|
16782
|
-
if (packages.length === 0)
|
|
16783
|
-
if (!isMonorepoRoot(rootDirectory)) return [rootDirectory];
|
|
16784
|
-
packages = discoverReactSubprojects(rootDirectory);
|
|
16785
|
-
}
|
|
18317
|
+
if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) packages = discoverReactSubprojects(rootDirectory);
|
|
16786
18318
|
if (packages.length === 0) return [rootDirectory];
|
|
16787
18319
|
if (packages.length === 1) {
|
|
16788
18320
|
cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages[0].name}`);
|
|
@@ -16886,6 +18418,7 @@ const validateModeFlags = (flags) => {
|
|
|
16886
18418
|
if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
|
|
16887
18419
|
if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
|
|
16888
18420
|
if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
|
|
18421
|
+
if (flags.score && flags.telemetry === false) throw new Error("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
|
|
16889
18422
|
if (flags.prComment && (flags.json || flags.score)) throw new Error("--pr-comment cannot be combined with --json or --score.");
|
|
16890
18423
|
if (flags.annotations && flags.score) throw new Error("--annotations cannot be combined with --score.");
|
|
16891
18424
|
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.");
|
|
@@ -16919,6 +18452,24 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
|
|
|
16919
18452
|
changedFiles,
|
|
16920
18453
|
isCurrentChanges: false
|
|
16921
18454
|
});
|
|
18455
|
+
/**
|
|
18456
|
+
* On an interactive human run, rename a pre-migration
|
|
18457
|
+
* `react-doctor.config.json` to `doctor.config.ts` before config is loaded,
|
|
18458
|
+
* so the scan reads the renamed file and the user is told once. CI, coding
|
|
18459
|
+
* agents, JSON/score output, pre-commit (`--staged`) hooks, and non-TTY runs
|
|
18460
|
+
* are left untouched — the loader's warning still nudges them — so a scan
|
|
18461
|
+
* never mutates the repo unattended.
|
|
18462
|
+
*/
|
|
18463
|
+
const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
|
|
18464
|
+
if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
|
|
18465
|
+
const legacyConfig = findLegacyConfig(requestedDirectory);
|
|
18466
|
+
if (!legacyConfig) return;
|
|
18467
|
+
const migratedPath = migrateLegacyConfig(legacyConfig);
|
|
18468
|
+
if (!migratedPath) return;
|
|
18469
|
+
cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
|
|
18470
|
+
cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
|
|
18471
|
+
cliLogger.break();
|
|
18472
|
+
};
|
|
16922
18473
|
const inspectAction = async (directory, flags) => {
|
|
16923
18474
|
const isScoreOnly = Boolean(flags.score);
|
|
16924
18475
|
const isJsonMode = Boolean(flags.json);
|
|
@@ -16931,7 +18482,11 @@ const inspectAction = async (directory, flags) => {
|
|
|
16931
18482
|
});
|
|
16932
18483
|
try {
|
|
16933
18484
|
validateModeFlags(flags);
|
|
16934
|
-
|
|
18485
|
+
maybeMigrateLegacyConfig(requestedDirectory, {
|
|
18486
|
+
isQuiet,
|
|
18487
|
+
isStaged: Boolean(flags.staged)
|
|
18488
|
+
});
|
|
18489
|
+
const scanTarget = await resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
|
|
16935
18490
|
const userConfig = scanTarget.userConfig;
|
|
16936
18491
|
const resolvedDirectory = scanTarget.resolvedDirectory;
|
|
16937
18492
|
setJsonReportDirectory(resolvedDirectory);
|
|
@@ -17105,12 +18660,13 @@ const inspectAction = async (directory, flags) => {
|
|
|
17105
18660
|
})) printAgentInstallHint();
|
|
17106
18661
|
}
|
|
17107
18662
|
} catch (error) {
|
|
18663
|
+
const sentryEventId = await reportErrorToSentry(error);
|
|
17108
18664
|
if (isJsonMode) {
|
|
17109
18665
|
writeJsonErrorReport(error);
|
|
17110
18666
|
process.exitCode = 1;
|
|
17111
18667
|
return;
|
|
17112
18668
|
}
|
|
17113
|
-
handleError(error);
|
|
18669
|
+
handleError(error, { sentryEventId });
|
|
17114
18670
|
}
|
|
17115
18671
|
};
|
|
17116
18672
|
//#endregion
|
|
@@ -17126,8 +18682,570 @@ const installAction = async (options, command) => {
|
|
|
17126
18682
|
projectRoot: options.cwd ?? process.cwd()
|
|
17127
18683
|
});
|
|
17128
18684
|
} catch (error) {
|
|
17129
|
-
handleError(error);
|
|
18685
|
+
handleError(error, { sentryEventId: await reportErrorToSentry(error) });
|
|
18686
|
+
}
|
|
18687
|
+
};
|
|
18688
|
+
//#endregion
|
|
18689
|
+
//#region src/cli/utils/rule-catalog.ts
|
|
18690
|
+
const buildRuleCatalog = () => REACT_DOCTOR_RULES.map((entry) => ({
|
|
18691
|
+
key: entry.key,
|
|
18692
|
+
id: entry.id,
|
|
18693
|
+
category: entry.rule.category ?? "Other",
|
|
18694
|
+
defaultSeverity: entry.rule.severity,
|
|
18695
|
+
framework: entry.rule.framework ?? "global",
|
|
18696
|
+
tags: entry.rule.tags ?? [],
|
|
18697
|
+
recommendation: entry.rule.recommendation,
|
|
18698
|
+
defaultEnabled: entry.rule.defaultEnabled !== false
|
|
18699
|
+
}));
|
|
18700
|
+
/**
|
|
18701
|
+
* Resolves a user-supplied rule reference to a catalog entry. Accepts the
|
|
18702
|
+
* fully-qualified key (`react-doctor/no-danger`), the bare id (`no-danger`),
|
|
18703
|
+
* and legacy plugin keys (`react/no-danger`) via the shared alias map.
|
|
18704
|
+
*/
|
|
18705
|
+
const findRuleInCatalog = (catalog, ruleQuery) => {
|
|
18706
|
+
const normalizedQuery = ruleQuery.trim();
|
|
18707
|
+
if (normalizedQuery.length === 0) return void 0;
|
|
18708
|
+
const directMatch = catalog.find((entry) => entry.key === normalizedQuery || entry.id === normalizedQuery);
|
|
18709
|
+
if (directMatch) return directMatch;
|
|
18710
|
+
return catalog.find((entry) => isSameRuleKey(entry.key, normalizedQuery));
|
|
18711
|
+
};
|
|
18712
|
+
const listRuleCategories = (catalog) => [...new Set(catalog.map((entry) => entry.category))].sort();
|
|
18713
|
+
const listRuleTags = (catalog) => [...new Set(catalog.flatMap((entry) => [...entry.tags]))].sort();
|
|
18714
|
+
//#endregion
|
|
18715
|
+
//#region src/cli/utils/render-rule-catalog.ts
|
|
18716
|
+
const SEVERITY_COLUMN_WIDTH_CHARS = 6;
|
|
18717
|
+
const colorizeSeverity = (severity, text) => {
|
|
18718
|
+
if (severity === "error") return highlighter.error(text);
|
|
18719
|
+
if (severity === "warn") return highlighter.warn(text);
|
|
18720
|
+
return highlighter.gray(text);
|
|
18721
|
+
};
|
|
18722
|
+
const formatSourceNote = (effective) => effective.source === "default" ? highlighter.dim("(default)") : highlighter.dim(`(${effective.source})`);
|
|
18723
|
+
const renderRuleCatalog = (rows) => {
|
|
18724
|
+
if (rows.length === 0) return highlighter.dim("No rules match the given filters.");
|
|
18725
|
+
const rowsByCategory = /* @__PURE__ */ new Map();
|
|
18726
|
+
for (const row of rows) {
|
|
18727
|
+
const bucket = rowsByCategory.get(row.entry.category) ?? [];
|
|
18728
|
+
bucket.push(row);
|
|
18729
|
+
rowsByCategory.set(row.entry.category, bucket);
|
|
18730
|
+
}
|
|
18731
|
+
const lines = [];
|
|
18732
|
+
for (const category of [...rowsByCategory.keys()].sort()) {
|
|
18733
|
+
const categoryRows = (rowsByCategory.get(category) ?? []).sort((leftRow, rightRow) => leftRow.entry.key.localeCompare(rightRow.entry.key));
|
|
18734
|
+
lines.push(highlighter.bold(`${category} ${highlighter.dim(`(${categoryRows.length})`)}`));
|
|
18735
|
+
for (const row of categoryRows) {
|
|
18736
|
+
const severityBadge = colorizeSeverity(row.effective.value, row.effective.value.padEnd(SEVERITY_COLUMN_WIDTH_CHARS));
|
|
18737
|
+
const tagSuffix = row.entry.tags.length > 0 ? highlighter.dim(` [${row.entry.tags.join(", ")}]`) : "";
|
|
18738
|
+
lines.push(` ${severityBadge} ${row.entry.key} ${formatSourceNote(row.effective)}${tagSuffix}`);
|
|
18739
|
+
}
|
|
18740
|
+
lines.push("");
|
|
18741
|
+
}
|
|
18742
|
+
lines.push(highlighter.dim(`${rows.length} rule${rows.length === 1 ? "" : "s"} shown.`));
|
|
18743
|
+
return lines.join("\n");
|
|
18744
|
+
};
|
|
18745
|
+
const DETAIL_LABEL_COLUMN_WIDTH_CHARS = 18;
|
|
18746
|
+
const formatDetailRow = (label, value) => ` ${highlighter.dim(label.padEnd(DETAIL_LABEL_COLUMN_WIDTH_CHARS))}${value}`;
|
|
18747
|
+
const renderRuleExplanation = (row) => {
|
|
18748
|
+
const { entry, effective } = row;
|
|
18749
|
+
const lines = [highlighter.bold(entry.key), ""];
|
|
18750
|
+
lines.push(formatDetailRow("Category", entry.category));
|
|
18751
|
+
lines.push(formatDetailRow("Default severity", entry.defaultSeverity));
|
|
18752
|
+
lines.push(formatDetailRow("Current severity", `${colorizeSeverity(effective.value, effective.value)} ${formatSourceNote(effective)}`));
|
|
18753
|
+
lines.push(formatDetailRow("Framework", entry.framework));
|
|
18754
|
+
lines.push(formatDetailRow("Tags", entry.tags.length > 0 ? entry.tags.join(", ") : "none"));
|
|
18755
|
+
lines.push(formatDetailRow("Default enabled", entry.defaultEnabled ? "yes" : "no (opt-in)"));
|
|
18756
|
+
lines.push("");
|
|
18757
|
+
lines.push(highlighter.bold("Why it matters"));
|
|
18758
|
+
lines.push(` ${entry.recommendation ?? "No additional guidance recorded for this rule yet."}`);
|
|
18759
|
+
lines.push("");
|
|
18760
|
+
lines.push(highlighter.bold("Configure"));
|
|
18761
|
+
lines.push(highlighter.dim(` react-doctor rules disable ${entry.key}`));
|
|
18762
|
+
lines.push(highlighter.dim(` react-doctor rules enable ${entry.key} --severity error`));
|
|
18763
|
+
lines.push(highlighter.dim(` react-doctor rules set ${entry.key} warn`));
|
|
18764
|
+
lines.push("");
|
|
18765
|
+
lines.push(highlighter.bold("Learn more"));
|
|
18766
|
+
lines.push(highlighter.dim(` ${buildRuleDocsUrl("react-doctor", entry.id)}`));
|
|
18767
|
+
return lines.join("\n");
|
|
18768
|
+
};
|
|
18769
|
+
//#endregion
|
|
18770
|
+
//#region src/cli/utils/rule-config-file.ts
|
|
18771
|
+
const NEW_CONFIG_FILENAME = "doctor.config.json";
|
|
18772
|
+
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
18773
|
+
const JSON_INDENT_SPACES = 2;
|
|
18774
|
+
const MANAGED_KEYS = [
|
|
18775
|
+
"rules",
|
|
18776
|
+
"categories",
|
|
18777
|
+
"ignore"
|
|
18778
|
+
];
|
|
18779
|
+
/**
|
|
18780
|
+
* Decides where a rule-config mutation should be written. Discovery
|
|
18781
|
+
* reuses `loadConfigWithSource` (the loader the scan uses) so edits land
|
|
18782
|
+
* in the file the scan reads — `doctor.config.{ts,js,…}` is preferred,
|
|
18783
|
+
* then `package.json#reactDoctor`. When nothing exists, a fresh
|
|
18784
|
+
* `doctor.config.json` is targeted at `projectRoot`. Data configs are
|
|
18785
|
+
* re-read raw so unrelated fields round-trip untouched.
|
|
18786
|
+
*/
|
|
18787
|
+
const resolveRuleConfigTarget = async (projectRoot) => {
|
|
18788
|
+
clearConfigCache();
|
|
18789
|
+
const loaded = await loadConfigWithSource(projectRoot);
|
|
18790
|
+
if (loaded) {
|
|
18791
|
+
if (loaded.format === "package-json") {
|
|
18792
|
+
const embedded = (readObjectFile(loaded.configFilePath) ?? {})[PACKAGE_JSON_CONFIG_KEY];
|
|
18793
|
+
return {
|
|
18794
|
+
format: "package-json",
|
|
18795
|
+
filePath: loaded.configFilePath,
|
|
18796
|
+
directory: loaded.sourceDirectory,
|
|
18797
|
+
exists: true,
|
|
18798
|
+
config: isPlainObject(embedded) ? embedded : {}
|
|
18799
|
+
};
|
|
18800
|
+
}
|
|
18801
|
+
if (loaded.format === "json") return {
|
|
18802
|
+
format: "json",
|
|
18803
|
+
filePath: loaded.configFilePath,
|
|
18804
|
+
directory: loaded.sourceDirectory,
|
|
18805
|
+
exists: true,
|
|
18806
|
+
config: readObjectFile(loaded.configFilePath) ?? {}
|
|
18807
|
+
};
|
|
18808
|
+
return {
|
|
18809
|
+
format: "module",
|
|
18810
|
+
filePath: loaded.configFilePath,
|
|
18811
|
+
directory: loaded.sourceDirectory,
|
|
18812
|
+
exists: true,
|
|
18813
|
+
config: loaded.config
|
|
18814
|
+
};
|
|
18815
|
+
}
|
|
18816
|
+
return {
|
|
18817
|
+
format: "json",
|
|
18818
|
+
filePath: path.join(projectRoot, NEW_CONFIG_FILENAME),
|
|
18819
|
+
directory: projectRoot,
|
|
18820
|
+
exists: false,
|
|
18821
|
+
config: {}
|
|
18822
|
+
};
|
|
18823
|
+
};
|
|
18824
|
+
const writeJsonConfig = (filePath, nextConfig) => {
|
|
18825
|
+
const { $schema, ...rest } = nextConfig;
|
|
18826
|
+
writeFileSync(filePath, `${JSON.stringify({
|
|
18827
|
+
$schema: $schema ?? "https://react.doctor/schema/config.json",
|
|
18828
|
+
...rest
|
|
18829
|
+
}, null, JSON_INDENT_SPACES)}\n`);
|
|
18830
|
+
};
|
|
18831
|
+
const writePackageJsonConfig = (filePath, nextConfig) => {
|
|
18832
|
+
const packageJson = readObjectFile(filePath) ?? {};
|
|
18833
|
+
writeFileSync(filePath, `${JSON.stringify({
|
|
18834
|
+
...packageJson,
|
|
18835
|
+
[PACKAGE_JSON_CONFIG_KEY]: nextConfig
|
|
18836
|
+
}, null, JSON_INDENT_SPACES)}\n`);
|
|
18837
|
+
};
|
|
18838
|
+
const syncManagedKeys = (target, nextConfig) => {
|
|
18839
|
+
for (const key of MANAGED_KEYS) {
|
|
18840
|
+
const value = nextConfig[key];
|
|
18841
|
+
if (value === void 0) {
|
|
18842
|
+
if (target[key] !== void 0) delete target[key];
|
|
18843
|
+
} else target[key] = value;
|
|
18844
|
+
}
|
|
18845
|
+
};
|
|
18846
|
+
const assignNodeSource = (owner, key, code) => {
|
|
18847
|
+
owner[key] = code;
|
|
18848
|
+
};
|
|
18849
|
+
const editVariableDeclarationConfig = (declaration, config, nextConfig) => {
|
|
18850
|
+
syncManagedKeys(config, nextConfig);
|
|
18851
|
+
const initializer = declaration.init;
|
|
18852
|
+
if (!initializer) return false;
|
|
18853
|
+
const generatedSource = generateCode(config).code;
|
|
18854
|
+
if (initializer.type === "ObjectExpression") {
|
|
18855
|
+
assignNodeSource(declaration, "init", generatedSource);
|
|
18856
|
+
return true;
|
|
18857
|
+
}
|
|
18858
|
+
if (initializer.type === "TSSatisfiesExpression" && initializer.expression.type === "ObjectExpression") {
|
|
18859
|
+
assignNodeSource(initializer, "expression", generatedSource);
|
|
18860
|
+
return true;
|
|
18861
|
+
}
|
|
18862
|
+
return false;
|
|
18863
|
+
};
|
|
18864
|
+
const writeModuleConfig = async (filePath, nextConfig) => {
|
|
18865
|
+
try {
|
|
18866
|
+
const module = await loadFile(filePath);
|
|
18867
|
+
if (module.exports.default?.$type === "identifier") {
|
|
18868
|
+
const { declaration, config } = getConfigFromVariableDeclaration(module);
|
|
18869
|
+
if (!config || !editVariableDeclarationConfig(declaration, config, nextConfig)) return false;
|
|
18870
|
+
} else syncManagedKeys(getDefaultExportOptions(module), nextConfig);
|
|
18871
|
+
await writeFile(module, filePath);
|
|
18872
|
+
return true;
|
|
18873
|
+
} catch {
|
|
18874
|
+
return false;
|
|
18875
|
+
}
|
|
18876
|
+
};
|
|
18877
|
+
const writeRuleConfig = async (target, nextConfig) => {
|
|
18878
|
+
if (target.format === "module") {
|
|
18879
|
+
const written = await writeModuleConfig(target.filePath, nextConfig);
|
|
18880
|
+
if (written) clearConfigCache();
|
|
18881
|
+
return { written };
|
|
18882
|
+
}
|
|
18883
|
+
if (target.format === "package-json") writePackageJsonConfig(target.filePath, nextConfig);
|
|
18884
|
+
else writeJsonConfig(target.filePath, nextConfig);
|
|
18885
|
+
clearConfigCache();
|
|
18886
|
+
return { written: true };
|
|
18887
|
+
};
|
|
18888
|
+
//#endregion
|
|
18889
|
+
//#region src/cli/utils/resolve-effective-rule-severity.ts
|
|
18890
|
+
/**
|
|
18891
|
+
* Resolves what a rule will actually do under the current config without
|
|
18892
|
+
* running a scan. `ignore.tags` is a pre-lint gate: a rule carrying an
|
|
18893
|
+
* ignored tag is dropped (via `shouldEnableRule`) before any severity is
|
|
18894
|
+
* read, so it wins over every override. Among rules that survive the gate,
|
|
18895
|
+
* the scanner's order is `rules` > `categories` > `buckets` > the registry
|
|
18896
|
+
* default.
|
|
18897
|
+
*/
|
|
18898
|
+
const resolveEffectiveRuleSeverity = (config, entry) => {
|
|
18899
|
+
const ignoredTags = config?.ignore?.tags ?? [];
|
|
18900
|
+
if (entry.tags.some((tag) => ignoredTags.includes(tag))) return {
|
|
18901
|
+
value: "off",
|
|
18902
|
+
source: "tag"
|
|
18903
|
+
};
|
|
18904
|
+
const ruleOverrides = config?.rules ?? {};
|
|
18905
|
+
for (const equivalentKey of getEquivalentRuleKeys(entry.key)) {
|
|
18906
|
+
const override = ruleOverrides[equivalentKey];
|
|
18907
|
+
if (override !== void 0) return {
|
|
18908
|
+
value: override,
|
|
18909
|
+
source: "rule"
|
|
18910
|
+
};
|
|
17130
18911
|
}
|
|
18912
|
+
const categoryOverride = config?.categories?.[entry.category];
|
|
18913
|
+
if (categoryOverride !== void 0) return {
|
|
18914
|
+
value: categoryOverride,
|
|
18915
|
+
source: "category"
|
|
18916
|
+
};
|
|
18917
|
+
if (COMPILER_CLEANUP_RULE_KEYS.has(entry.key)) {
|
|
18918
|
+
const bucketOverride = config?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
18919
|
+
if (bucketOverride !== void 0) return {
|
|
18920
|
+
value: bucketOverride,
|
|
18921
|
+
source: "bucket"
|
|
18922
|
+
};
|
|
18923
|
+
}
|
|
18924
|
+
return {
|
|
18925
|
+
value: entry.defaultEnabled ? entry.defaultSeverity : "off",
|
|
18926
|
+
source: "default"
|
|
18927
|
+
};
|
|
18928
|
+
};
|
|
18929
|
+
//#endregion
|
|
18930
|
+
//#region src/cli/utils/update-rule-config.ts
|
|
18931
|
+
/**
|
|
18932
|
+
* Sets a per-rule severity, replacing any existing entry for the same
|
|
18933
|
+
* rule (including legacy-aliased keys, so a config still targeting
|
|
18934
|
+
* `react/no-danger` is rewritten to the canonical key instead of
|
|
18935
|
+
* leaving a dead duplicate).
|
|
18936
|
+
*/
|
|
18937
|
+
const setRuleSeverity = (config, ruleKey, severity) => {
|
|
18938
|
+
const equivalentKeys = new Set(getEquivalentRuleKeys(ruleKey));
|
|
18939
|
+
const nextRules = {};
|
|
18940
|
+
for (const [existingKey, existingSeverity] of Object.entries(config.rules ?? {})) if (!equivalentKeys.has(existingKey)) nextRules[existingKey] = existingSeverity;
|
|
18941
|
+
nextRules[ruleKey] = severity;
|
|
18942
|
+
return {
|
|
18943
|
+
...config,
|
|
18944
|
+
rules: nextRules
|
|
18945
|
+
};
|
|
18946
|
+
};
|
|
18947
|
+
const setCategorySeverity = (config, category, severity) => ({
|
|
18948
|
+
...config,
|
|
18949
|
+
categories: {
|
|
18950
|
+
...config.categories,
|
|
18951
|
+
[category]: severity
|
|
18952
|
+
}
|
|
18953
|
+
});
|
|
18954
|
+
const addIgnoredTag = (config, tag) => {
|
|
18955
|
+
const currentTags = config.ignore?.tags ?? [];
|
|
18956
|
+
if (currentTags.includes(tag)) return config;
|
|
18957
|
+
return {
|
|
18958
|
+
...config,
|
|
18959
|
+
ignore: {
|
|
18960
|
+
...config.ignore,
|
|
18961
|
+
tags: [...new Set([...currentTags, tag])].sort()
|
|
18962
|
+
}
|
|
18963
|
+
};
|
|
18964
|
+
};
|
|
18965
|
+
const removeIgnoredTag = (config, tag) => {
|
|
18966
|
+
const currentTags = config.ignore?.tags ?? [];
|
|
18967
|
+
if (!currentTags.includes(tag)) return config;
|
|
18968
|
+
const remainingTags = currentTags.filter((existingTag) => existingTag !== tag);
|
|
18969
|
+
const { tags: _removed, ...remainingIgnore } = config.ignore ?? {};
|
|
18970
|
+
if (remainingTags.length === 0) {
|
|
18971
|
+
if (Object.keys(remainingIgnore).length === 0) {
|
|
18972
|
+
const { ignore: _ignore, ...configWithoutIgnore } = config;
|
|
18973
|
+
return configWithoutIgnore;
|
|
18974
|
+
}
|
|
18975
|
+
return {
|
|
18976
|
+
...config,
|
|
18977
|
+
ignore: remainingIgnore
|
|
18978
|
+
};
|
|
18979
|
+
}
|
|
18980
|
+
return {
|
|
18981
|
+
...config,
|
|
18982
|
+
ignore: {
|
|
18983
|
+
...remainingIgnore,
|
|
18984
|
+
tags: remainingTags
|
|
18985
|
+
}
|
|
18986
|
+
};
|
|
18987
|
+
};
|
|
18988
|
+
//#endregion
|
|
18989
|
+
//#region src/cli/commands/rules.ts
|
|
18990
|
+
const SEVERITY_VALUES = [
|
|
18991
|
+
"off",
|
|
18992
|
+
"warn",
|
|
18993
|
+
"error"
|
|
18994
|
+
];
|
|
18995
|
+
const resolveProjectRoot = (options) => {
|
|
18996
|
+
const requestedDirectory = path.resolve(options.cwd ?? process.cwd());
|
|
18997
|
+
return findNearestPackageDirectory(requestedDirectory) ?? requestedDirectory;
|
|
18998
|
+
};
|
|
18999
|
+
const parseSeverity = (value) => SEVERITY_VALUES.includes(value) ? value : null;
|
|
19000
|
+
const reportInvalidSeverity = (value) => {
|
|
19001
|
+
cliLogger.error(`Invalid severity "${value}". Expected one of: ${SEVERITY_VALUES.join(", ")}.`);
|
|
19002
|
+
process.exitCode = 1;
|
|
19003
|
+
};
|
|
19004
|
+
const reportRuleNotFound = (ruleQuery) => {
|
|
19005
|
+
cliLogger.error(`Unknown rule "${ruleQuery}".`);
|
|
19006
|
+
cliLogger.dim(" Run `react-doctor rules list` to see every available rule.");
|
|
19007
|
+
process.exitCode = 1;
|
|
19008
|
+
};
|
|
19009
|
+
const describeTargetPath = (target) => {
|
|
19010
|
+
const relativePath = path.relative(process.cwd(), target.filePath);
|
|
19011
|
+
const displayPath = relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : target.filePath;
|
|
19012
|
+
return target.exists ? displayPath : `${displayPath} ${highlighter.dim("(created)")}`;
|
|
19013
|
+
};
|
|
19014
|
+
const applyConfigChange = async (options, change) => {
|
|
19015
|
+
const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
|
|
19016
|
+
const nextConfig = change(target.config);
|
|
19017
|
+
const { written } = await writeRuleConfig(target, nextConfig);
|
|
19018
|
+
return {
|
|
19019
|
+
target,
|
|
19020
|
+
nextConfig,
|
|
19021
|
+
written
|
|
19022
|
+
};
|
|
19023
|
+
};
|
|
19024
|
+
const reportManualEdit = (target, nextConfig) => {
|
|
19025
|
+
const managed = {};
|
|
19026
|
+
for (const key of [
|
|
19027
|
+
"rules",
|
|
19028
|
+
"categories",
|
|
19029
|
+
"ignore"
|
|
19030
|
+
]) if (nextConfig[key] !== void 0) managed[key] = nextConfig[key];
|
|
19031
|
+
cliLogger.error(`Couldn't automatically edit ${describeTargetPath(target)} (dynamic config).`);
|
|
19032
|
+
cliLogger.dim(" Apply this to your config's default export, then re-run:");
|
|
19033
|
+
for (const line of JSON.stringify(managed, null, 2).split("\n")) cliLogger.dim(` ${line}`);
|
|
19034
|
+
process.exitCode = 1;
|
|
19035
|
+
};
|
|
19036
|
+
const rulesListAction = async (options) => {
|
|
19037
|
+
const catalog = buildRuleCatalog();
|
|
19038
|
+
const config = validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config);
|
|
19039
|
+
const categoryFilter = options.category?.toLowerCase();
|
|
19040
|
+
const frameworkFilter = options.framework?.toLowerCase();
|
|
19041
|
+
const rows = catalog.filter((entry) => {
|
|
19042
|
+
if (categoryFilter && entry.category.toLowerCase() !== categoryFilter) return false;
|
|
19043
|
+
if (frameworkFilter && entry.framework.toLowerCase() !== frameworkFilter) return false;
|
|
19044
|
+
if (options.tag && !entry.tags.includes(options.tag)) return false;
|
|
19045
|
+
return true;
|
|
19046
|
+
}).map((entry) => ({
|
|
19047
|
+
entry,
|
|
19048
|
+
effective: resolveEffectiveRuleSeverity(config, entry)
|
|
19049
|
+
})).filter((row) => options.configured ? row.effective.source !== "default" : true);
|
|
19050
|
+
if (options.json) {
|
|
19051
|
+
const payload = rows.map((row) => ({
|
|
19052
|
+
key: row.entry.key,
|
|
19053
|
+
id: row.entry.id,
|
|
19054
|
+
category: row.entry.category,
|
|
19055
|
+
framework: row.entry.framework,
|
|
19056
|
+
tags: row.entry.tags,
|
|
19057
|
+
defaultSeverity: row.entry.defaultSeverity,
|
|
19058
|
+
defaultEnabled: row.entry.defaultEnabled,
|
|
19059
|
+
severity: row.effective.value,
|
|
19060
|
+
source: row.effective.source
|
|
19061
|
+
}));
|
|
19062
|
+
cliLogger.log(JSON.stringify(payload, null, 2));
|
|
19063
|
+
return;
|
|
19064
|
+
}
|
|
19065
|
+
cliLogger.log(renderRuleCatalog(rows));
|
|
19066
|
+
};
|
|
19067
|
+
const rulesExplainAction = async (ruleQuery, options) => {
|
|
19068
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
19069
|
+
if (!entry) {
|
|
19070
|
+
reportRuleNotFound(ruleQuery);
|
|
19071
|
+
return;
|
|
19072
|
+
}
|
|
19073
|
+
const effective = resolveEffectiveRuleSeverity(validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config), entry);
|
|
19074
|
+
if (options.json) {
|
|
19075
|
+
cliLogger.log(JSON.stringify({
|
|
19076
|
+
key: entry.key,
|
|
19077
|
+
id: entry.id,
|
|
19078
|
+
category: entry.category,
|
|
19079
|
+
framework: entry.framework,
|
|
19080
|
+
tags: entry.tags,
|
|
19081
|
+
defaultSeverity: entry.defaultSeverity,
|
|
19082
|
+
defaultEnabled: entry.defaultEnabled,
|
|
19083
|
+
severity: effective.value,
|
|
19084
|
+
source: effective.source,
|
|
19085
|
+
recommendation: entry.recommendation ?? null,
|
|
19086
|
+
learnMoreUrl: buildRuleDocsUrl("react-doctor", entry.id)
|
|
19087
|
+
}, null, 2));
|
|
19088
|
+
return;
|
|
19089
|
+
}
|
|
19090
|
+
cliLogger.log(renderRuleExplanation({
|
|
19091
|
+
entry,
|
|
19092
|
+
effective
|
|
19093
|
+
}));
|
|
19094
|
+
};
|
|
19095
|
+
const setRuleSeverityAndReport = async (entry, severity, options) => {
|
|
19096
|
+
const { target, nextConfig, written } = await applyConfigChange(options, (config) => setRuleSeverity(config, entry.key, severity));
|
|
19097
|
+
if (!written) {
|
|
19098
|
+
reportManualEdit(target, nextConfig);
|
|
19099
|
+
return;
|
|
19100
|
+
}
|
|
19101
|
+
cliLogger.success(`Set ${entry.key} → ${severity}`);
|
|
19102
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
19103
|
+
};
|
|
19104
|
+
const rulesSetAction = async (ruleQuery, severityValue, options) => {
|
|
19105
|
+
const severity = parseSeverity(severityValue);
|
|
19106
|
+
if (!severity) {
|
|
19107
|
+
reportInvalidSeverity(severityValue);
|
|
19108
|
+
return;
|
|
19109
|
+
}
|
|
19110
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
19111
|
+
if (!entry) {
|
|
19112
|
+
reportRuleNotFound(ruleQuery);
|
|
19113
|
+
return;
|
|
19114
|
+
}
|
|
19115
|
+
await setRuleSeverityAndReport(entry, severity, options);
|
|
19116
|
+
};
|
|
19117
|
+
const rulesEnableAction = async (ruleQuery, options) => {
|
|
19118
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
19119
|
+
if (!entry) {
|
|
19120
|
+
reportRuleNotFound(ruleQuery);
|
|
19121
|
+
return;
|
|
19122
|
+
}
|
|
19123
|
+
if (options.severity === void 0) {
|
|
19124
|
+
await setRuleSeverityAndReport(entry, entry.defaultSeverity, options);
|
|
19125
|
+
return;
|
|
19126
|
+
}
|
|
19127
|
+
const severity = parseSeverity(options.severity);
|
|
19128
|
+
if (!severity) {
|
|
19129
|
+
reportInvalidSeverity(options.severity);
|
|
19130
|
+
return;
|
|
19131
|
+
}
|
|
19132
|
+
if (severity === "off") {
|
|
19133
|
+
cliLogger.error("`enable` cannot set a rule to off. Use `react-doctor rules disable` instead.");
|
|
19134
|
+
process.exitCode = 1;
|
|
19135
|
+
return;
|
|
19136
|
+
}
|
|
19137
|
+
await setRuleSeverityAndReport(entry, severity, options);
|
|
19138
|
+
};
|
|
19139
|
+
const rulesDisableAction = async (ruleQuery, options) => {
|
|
19140
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
19141
|
+
if (!entry) {
|
|
19142
|
+
reportRuleNotFound(ruleQuery);
|
|
19143
|
+
return;
|
|
19144
|
+
}
|
|
19145
|
+
await setRuleSeverityAndReport(entry, "off", options);
|
|
19146
|
+
};
|
|
19147
|
+
const rulesCategoryAction = async (categoryQuery, severityValue, options) => {
|
|
19148
|
+
const severity = parseSeverity(severityValue);
|
|
19149
|
+
if (!severity) {
|
|
19150
|
+
reportInvalidSeverity(severityValue);
|
|
19151
|
+
return;
|
|
19152
|
+
}
|
|
19153
|
+
const knownCategories = listRuleCategories(buildRuleCatalog());
|
|
19154
|
+
const matchedCategory = knownCategories.find((category) => category.toLowerCase() === categoryQuery.toLowerCase());
|
|
19155
|
+
if (!matchedCategory) {
|
|
19156
|
+
cliLogger.error(`Unknown category "${categoryQuery}".`);
|
|
19157
|
+
cliLogger.dim(` Known categories: ${knownCategories.join(", ")}`);
|
|
19158
|
+
process.exitCode = 1;
|
|
19159
|
+
return;
|
|
19160
|
+
}
|
|
19161
|
+
const { target, nextConfig, written } = await applyConfigChange(options, (config) => setCategorySeverity(config, matchedCategory, severity));
|
|
19162
|
+
if (!written) {
|
|
19163
|
+
reportManualEdit(target, nextConfig);
|
|
19164
|
+
return;
|
|
19165
|
+
}
|
|
19166
|
+
cliLogger.success(`Set category "${matchedCategory}" → ${severity}`);
|
|
19167
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
19168
|
+
};
|
|
19169
|
+
const rulesIgnoreTagAction = async (tag, options) => {
|
|
19170
|
+
const knownTags = listRuleTags(buildRuleCatalog());
|
|
19171
|
+
if (!knownTags.includes(tag)) {
|
|
19172
|
+
cliLogger.error(`Unknown tag "${tag}".`);
|
|
19173
|
+
cliLogger.dim(` Known tags: ${knownTags.join(", ")}`);
|
|
19174
|
+
process.exitCode = 1;
|
|
19175
|
+
return;
|
|
19176
|
+
}
|
|
19177
|
+
const { target, nextConfig, written } = await applyConfigChange(options, (config) => addIgnoredTag(config, tag));
|
|
19178
|
+
if (!written) {
|
|
19179
|
+
reportManualEdit(target, nextConfig);
|
|
19180
|
+
return;
|
|
19181
|
+
}
|
|
19182
|
+
cliLogger.success(`Ignoring tag "${tag}" (rules with this tag are skipped before linting)`);
|
|
19183
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
19184
|
+
};
|
|
19185
|
+
const rulesUnignoreTagAction = async (tag, options) => {
|
|
19186
|
+
const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
|
|
19187
|
+
if (!(target.config.ignore?.tags ?? []).includes(tag)) {
|
|
19188
|
+
cliLogger.dim(`Tag "${tag}" was not being ignored; nothing to change.`);
|
|
19189
|
+
return;
|
|
19190
|
+
}
|
|
19191
|
+
const nextConfig = removeIgnoredTag(target.config, tag);
|
|
19192
|
+
const { written } = await writeRuleConfig(target, nextConfig);
|
|
19193
|
+
if (!written) {
|
|
19194
|
+
reportManualEdit(target, nextConfig);
|
|
19195
|
+
return;
|
|
19196
|
+
}
|
|
19197
|
+
cliLogger.success(`Tag "${tag}" is no longer ignored`);
|
|
19198
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
19199
|
+
};
|
|
19200
|
+
//#endregion
|
|
19201
|
+
//#region src/cli/commands/version.ts
|
|
19202
|
+
/**
|
|
19203
|
+
* oclif-style version line. 12-factor CLI Apps (#3, "What version am I
|
|
19204
|
+
* on?"): the `version` command is the primary place users grab debugging
|
|
19205
|
+
* info, so it carries the Node runtime and platform alongside the CLI
|
|
19206
|
+
* version. The `-v` / `-V` / `--version` flags stay terse (just the
|
|
19207
|
+
* number) so scripts can parse them.
|
|
19208
|
+
*/
|
|
19209
|
+
const buildVersionString = () => `react-doctor/${VERSION} ${process.platform}-${process.arch} node-${process.version}`;
|
|
19210
|
+
const versionAction = () => {
|
|
19211
|
+
process.stdout.write(`${buildVersionString()}\n`);
|
|
19212
|
+
};
|
|
19213
|
+
//#endregion
|
|
19214
|
+
//#region src/cli/utils/apply-color-preference.ts
|
|
19215
|
+
/**
|
|
19216
|
+
* Resolve an explicit color preference from `--color` / `--no-color` or the
|
|
19217
|
+
* app-specific `REACT_DOCTOR_NO_COLOR` / `REACT_DOCTOR_FORCE_COLOR` env vars
|
|
19218
|
+
* (clig.dev Output; 12-factor #6), overriding picocolors' own
|
|
19219
|
+
* `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY detection. Flags win over env
|
|
19220
|
+
* vars; with neither set, picocolors' detection stands.
|
|
19221
|
+
*
|
|
19222
|
+
* A resolved preference is mirrored onto the standard `NO_COLOR` /
|
|
19223
|
+
* `FORCE_COLOR` env vars in addition to our picocolors highlighter, so
|
|
19224
|
+
* libraries with their own color stacks (the `ora` spinner, `prompts`)
|
|
19225
|
+
* honor it too rather than only the scan report. Scanning argv directly
|
|
19226
|
+
* (not Commander's parsed options) applies the preference before Commander
|
|
19227
|
+
* parses, so it reaches every later path. The scan stops at `--`.
|
|
19228
|
+
*/
|
|
19229
|
+
const applyColorPreference = (argv, env = process.env) => {
|
|
19230
|
+
let enabled;
|
|
19231
|
+
for (const argument of argv) {
|
|
19232
|
+
if (argument === "--") break;
|
|
19233
|
+
if (argument === "--no-color") enabled = false;
|
|
19234
|
+
else if (argument === "--color") enabled = true;
|
|
19235
|
+
}
|
|
19236
|
+
if (enabled === void 0) {
|
|
19237
|
+
if (env.REACT_DOCTOR_NO_COLOR) enabled = false;
|
|
19238
|
+
else if (env.REACT_DOCTOR_FORCE_COLOR) enabled = true;
|
|
19239
|
+
}
|
|
19240
|
+
if (enabled === void 0) return;
|
|
19241
|
+
if (enabled) {
|
|
19242
|
+
env.FORCE_COLOR = "1";
|
|
19243
|
+
delete env.NO_COLOR;
|
|
19244
|
+
} else {
|
|
19245
|
+
env.NO_COLOR = "1";
|
|
19246
|
+
delete env.FORCE_COLOR;
|
|
19247
|
+
}
|
|
19248
|
+
setColorEnabled(enabled);
|
|
17131
19249
|
};
|
|
17132
19250
|
//#endregion
|
|
17133
19251
|
//#region src/cli/utils/exit-gracefully.ts
|
|
@@ -17139,21 +19257,54 @@ const exitGracefully = () => {
|
|
|
17139
19257
|
process.exit(130);
|
|
17140
19258
|
};
|
|
17141
19259
|
//#endregion
|
|
19260
|
+
//#region src/cli/utils/normalize-help-command.ts
|
|
19261
|
+
/**
|
|
19262
|
+
* 12-factor CLI Apps (#1, "Great help is essential"): `mycli help` and
|
|
19263
|
+
* `mycli help <command>` must display help. Commander doesn't wire this
|
|
19264
|
+
* up once the root command has its own default action plus a positional
|
|
19265
|
+
* argument — it treats a leading `help` as the `[directory]` to scan,
|
|
19266
|
+
* which then errors with "No React project found in ./help".
|
|
19267
|
+
*
|
|
19268
|
+
* We rewrite the argv up front so the existing `--help` paths handle it:
|
|
19269
|
+
* `react-doctor help` -> `react-doctor --help`
|
|
19270
|
+
* `react-doctor help install` -> `react-doctor install --help`
|
|
19271
|
+
*
|
|
19272
|
+
* Only a *leading* `help` token is rewritten, so a flag value such as
|
|
19273
|
+
* `--project help` is never mistaken for the help command. The target is
|
|
19274
|
+
* the first non-flag token after `help`, so intervening flags like
|
|
19275
|
+
* `help --no-color install` still resolve to `install`. An unknown target
|
|
19276
|
+
* (`help bogus`) falls back to root help rather than erroring.
|
|
19277
|
+
*/
|
|
19278
|
+
const normalizeHelpInvocation = (argv, knownCommands) => {
|
|
19279
|
+
const nodeArguments = argv.slice(0, 2);
|
|
19280
|
+
const userArguments = argv.slice(2);
|
|
19281
|
+
if (userArguments[0] !== "help") return [...argv];
|
|
19282
|
+
const target = userArguments.slice(1).find((argument) => !argument.startsWith("-"));
|
|
19283
|
+
if (target !== void 0 && knownCommands.includes(target)) return [
|
|
19284
|
+
...nodeArguments,
|
|
19285
|
+
target,
|
|
19286
|
+
"--help"
|
|
19287
|
+
];
|
|
19288
|
+
return [...nodeArguments, "--help"];
|
|
19289
|
+
};
|
|
19290
|
+
//#endregion
|
|
17142
19291
|
//#region src/cli/utils/strip-unknown-cli-flags.ts
|
|
17143
|
-
const NODE_ARGUMENT_COUNT = 2;
|
|
17144
19292
|
const ROOT_FLAG_SPEC = {
|
|
17145
19293
|
longOptionsWithoutValues: new Set([
|
|
17146
19294
|
"--annotations",
|
|
19295
|
+
"--color",
|
|
17147
19296
|
"--dead-code",
|
|
17148
19297
|
"--full",
|
|
17149
19298
|
"--help",
|
|
17150
19299
|
"--json",
|
|
17151
19300
|
"--json-compact",
|
|
17152
19301
|
"--lint",
|
|
19302
|
+
"--no-color",
|
|
17153
19303
|
"--no-dead-code",
|
|
17154
19304
|
"--no-lint",
|
|
17155
19305
|
"--no-respect-inline-disables",
|
|
17156
19306
|
"--no-score",
|
|
19307
|
+
"--no-telemetry",
|
|
17157
19308
|
"--no-warnings",
|
|
17158
19309
|
"--pr-comment",
|
|
17159
19310
|
"--respect-inline-disables",
|
|
@@ -17171,7 +19322,7 @@ const ROOT_FLAG_SPEC = {
|
|
|
17171
19322
|
"--project",
|
|
17172
19323
|
"--why"
|
|
17173
19324
|
]),
|
|
17174
|
-
longOptionsWithOptionalValues: new Set(["--diff"]),
|
|
19325
|
+
longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
|
|
17175
19326
|
shortOptionsWithoutValues: new Set([
|
|
17176
19327
|
"-h",
|
|
17177
19328
|
"-v",
|
|
@@ -17182,8 +19333,10 @@ const ROOT_FLAG_SPEC = {
|
|
|
17182
19333
|
const INSTALL_FLAG_SPEC = {
|
|
17183
19334
|
longOptionsWithoutValues: new Set([
|
|
17184
19335
|
"--agent-hooks",
|
|
19336
|
+
"--color",
|
|
17185
19337
|
"--dry-run",
|
|
17186
19338
|
"--help",
|
|
19339
|
+
"--no-color",
|
|
17187
19340
|
"--yes"
|
|
17188
19341
|
]),
|
|
17189
19342
|
longOptionsWithRequiredValues: new Set(["--cwd"]),
|
|
@@ -17191,7 +19344,40 @@ const INSTALL_FLAG_SPEC = {
|
|
|
17191
19344
|
shortOptionsWithoutValues: new Set(["-h", "-y"]),
|
|
17192
19345
|
shortOptionsWithRequiredValues: new Set(["-c"])
|
|
17193
19346
|
};
|
|
17194
|
-
const COMMAND_FLAG_SPECS = new Map([
|
|
19347
|
+
const COMMAND_FLAG_SPECS = new Map([
|
|
19348
|
+
["install", INSTALL_FLAG_SPEC],
|
|
19349
|
+
["setup", INSTALL_FLAG_SPEC],
|
|
19350
|
+
["version", {
|
|
19351
|
+
longOptionsWithoutValues: new Set([
|
|
19352
|
+
"--color",
|
|
19353
|
+
"--help",
|
|
19354
|
+
"--no-color"
|
|
19355
|
+
]),
|
|
19356
|
+
longOptionsWithRequiredValues: /* @__PURE__ */ new Set(),
|
|
19357
|
+
longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
|
|
19358
|
+
shortOptionsWithoutValues: new Set(["-h"]),
|
|
19359
|
+
shortOptionsWithRequiredValues: /* @__PURE__ */ new Set()
|
|
19360
|
+
}],
|
|
19361
|
+
["rules", {
|
|
19362
|
+
longOptionsWithoutValues: new Set([
|
|
19363
|
+
"--color",
|
|
19364
|
+
"--configured",
|
|
19365
|
+
"--help",
|
|
19366
|
+
"--json",
|
|
19367
|
+
"--no-color"
|
|
19368
|
+
]),
|
|
19369
|
+
longOptionsWithRequiredValues: new Set([
|
|
19370
|
+
"--category",
|
|
19371
|
+
"--cwd",
|
|
19372
|
+
"--framework",
|
|
19373
|
+
"--severity",
|
|
19374
|
+
"--tag"
|
|
19375
|
+
]),
|
|
19376
|
+
longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
|
|
19377
|
+
shortOptionsWithoutValues: new Set(["-h"]),
|
|
19378
|
+
shortOptionsWithRequiredValues: new Set(["-c"])
|
|
19379
|
+
}]
|
|
19380
|
+
]);
|
|
17195
19381
|
const isFlagLike = (argument) => argument.startsWith("-") && argument !== "-";
|
|
17196
19382
|
const getLongOptionName = (argument) => {
|
|
17197
19383
|
const equalsIndex = argument.indexOf("=");
|
|
@@ -17245,8 +19431,8 @@ const stripUnknownFlags = (userArguments, flagSpec) => {
|
|
|
17245
19431
|
return sanitizedArguments;
|
|
17246
19432
|
};
|
|
17247
19433
|
const stripUnknownCliFlags = (argv) => {
|
|
17248
|
-
const nodeArguments = argv.slice(0,
|
|
17249
|
-
const userArguments = argv.slice(
|
|
19434
|
+
const nodeArguments = argv.slice(0, 2);
|
|
19435
|
+
const userArguments = argv.slice(2);
|
|
17250
19436
|
const commandIndex = findCommandIndex(userArguments);
|
|
17251
19437
|
if (commandIndex === null) return [...nodeArguments, ...stripUnknownFlags(userArguments, ROOT_FLAG_SPEC)];
|
|
17252
19438
|
const commandName = userArguments[commandIndex];
|
|
@@ -17260,30 +19446,83 @@ const stripUnknownCliFlags = (argv) => {
|
|
|
17260
19446
|
};
|
|
17261
19447
|
//#endregion
|
|
17262
19448
|
//#region src/cli/index.ts
|
|
19449
|
+
initializeSentry();
|
|
17263
19450
|
process.on("SIGINT", exitGracefully);
|
|
17264
19451
|
process.on("SIGTERM", exitGracefully);
|
|
17265
19452
|
unrefStdin();
|
|
17266
|
-
const
|
|
19453
|
+
const formatExampleLines = (examples) => {
|
|
19454
|
+
const width = Math.max(...examples.map(([command]) => command.length));
|
|
19455
|
+
return examples.map(([command, description]) => ` $ ${command.padEnd(width)} ${highlighter.dim(`# ${description}`)}`).join("\n");
|
|
19456
|
+
};
|
|
19457
|
+
const renderRootHelpEpilog = () => `
|
|
19458
|
+
${highlighter.dim("Examples:")}
|
|
19459
|
+
${formatExampleLines([
|
|
19460
|
+
["react-doctor", "scan the current project"],
|
|
19461
|
+
["react-doctor ./apps/web", "scan a specific directory"],
|
|
19462
|
+
["react-doctor --diff main", "scan only files changed vs. main"],
|
|
19463
|
+
["react-doctor --staged", "scan staged files (pre-commit hook)"],
|
|
19464
|
+
["react-doctor --fail-on warning", "exit non-zero on warnings (CI gate)"],
|
|
19465
|
+
["react-doctor --json > report.json", "write a machine-readable report"],
|
|
19466
|
+
["react-doctor --explain src/App.tsx:42", "explain why a rule fired there"],
|
|
19467
|
+
["react-doctor install", "set up the agent skill and git hook"]
|
|
19468
|
+
])}
|
|
19469
|
+
|
|
17267
19470
|
${highlighter.dim("Configuration:")}
|
|
17268
|
-
|
|
17269
|
-
|
|
19471
|
+
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.
|
|
19472
|
+
Use ${highlighter.info("react-doctor rules")} to list, explain, and configure rules. CLI flags always override config values.
|
|
19473
|
+
|
|
19474
|
+
${highlighter.dim("Feedback & bug reports:")}
|
|
19475
|
+
${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
|
|
17270
19476
|
|
|
17271
19477
|
${highlighter.dim("Learn more:")}
|
|
17272
19478
|
${highlighter.info(CANONICAL_GITHUB_URL)}
|
|
17273
|
-
|
|
19479
|
+
`;
|
|
19480
|
+
const renderInstallHelpEpilog = () => `
|
|
19481
|
+
${highlighter.dim("Examples:")}
|
|
19482
|
+
${formatExampleLines([
|
|
19483
|
+
["react-doctor install", "interactive setup"],
|
|
19484
|
+
["react-doctor install --yes", "non-interactive; all detected agents"],
|
|
19485
|
+
["react-doctor install --dry-run", "preview without writing files"],
|
|
19486
|
+
["react-doctor install --agent-hooks", "also install native agent hooks"]
|
|
19487
|
+
])}
|
|
19488
|
+
|
|
19489
|
+
${highlighter.dim("Learn more:")}
|
|
19490
|
+
${highlighter.info(CANONICAL_GITHUB_URL)}
|
|
19491
|
+
`;
|
|
19492
|
+
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);
|
|
17274
19493
|
program.action(inspectAction);
|
|
17275
|
-
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);
|
|
19494
|
+
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);
|
|
19495
|
+
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);
|
|
19496
|
+
const rules = program.command("rules").description("List, explain, and configure which React Doctor rules run");
|
|
19497
|
+
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()));
|
|
19498
|
+
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()));
|
|
19499
|
+
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()));
|
|
19500
|
+
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()));
|
|
19501
|
+
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()));
|
|
19502
|
+
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()));
|
|
19503
|
+
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()));
|
|
19504
|
+
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()));
|
|
17276
19505
|
process.stdout.on("error", (error) => {
|
|
17277
19506
|
if (error.code === "EPIPE") process.exit(0);
|
|
17278
19507
|
});
|
|
17279
|
-
program.
|
|
19508
|
+
const knownCommands = program.commands.flatMap((command) => [command.name(), ...command.aliases()]);
|
|
19509
|
+
const strippedArgv = stripUnknownCliFlags(process.argv);
|
|
19510
|
+
if (process.argv.includes("-V") && !strippedArgv.includes("-V")) {
|
|
19511
|
+
process.stdout.write(`${VERSION}\n`);
|
|
19512
|
+
process.exit(0);
|
|
19513
|
+
}
|
|
19514
|
+
applyColorPreference(strippedArgv);
|
|
19515
|
+
const argv = normalizeHelpInvocation(strippedArgv, knownCommands);
|
|
19516
|
+
program.parseAsync(argv).then(() => flushSentry()).catch(async (error) => {
|
|
19517
|
+
const sentryEventId = await reportErrorToSentry(error);
|
|
17280
19518
|
if (isJsonModeActive()) {
|
|
17281
19519
|
writeJsonErrorReport(error);
|
|
17282
19520
|
process.exit(1);
|
|
17283
19521
|
}
|
|
17284
|
-
handleError(error);
|
|
19522
|
+
handleError(error, { sentryEventId });
|
|
17285
19523
|
});
|
|
17286
19524
|
//#endregion
|
|
17287
19525
|
export {};
|
|
17288
19526
|
|
|
17289
|
-
//# sourceMappingURL=cli.js.map
|
|
19527
|
+
//# sourceMappingURL=cli.js.map
|
|
19528
|
+
//# debugId=74dccde1-47a7-5718-88d7-ea8e5d7b463b
|