react-doctor 0.2.14-dev.8b313ba → 0.2.14-dev.9777f1a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/dist/cli.js +1913 -190
- package/dist/index.d.ts +69 -9
- package/dist/index.js +726 -104
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +8 -4
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,10 @@ import * as Redacted from "effect/Redacted";
|
|
|
15
15
|
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
16
16
|
import * as Otlp from "effect/unstable/observability/Otlp";
|
|
17
17
|
import * as Context from "effect/Context";
|
|
18
|
+
import os, { tmpdir } from "node:os";
|
|
18
19
|
import * as Console from "effect/Console";
|
|
20
|
+
import { parseJSON5 } from "confbox";
|
|
21
|
+
import { createJiti } from "jiti";
|
|
19
22
|
import * as Fiber from "effect/Fiber";
|
|
20
23
|
import * as Filter from "effect/Filter";
|
|
21
24
|
import * as Option from "effect/Option";
|
|
@@ -27,9 +30,9 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
|
|
|
27
30
|
import * as NodePath from "@effect/platform-node-shared/NodePath";
|
|
28
31
|
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
29
32
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
30
|
-
import os, { tmpdir } from "node:os";
|
|
31
33
|
import * as ts from "typescript";
|
|
32
34
|
import { gzipSync } from "node:zlib";
|
|
35
|
+
import * as Sentry from "@sentry/node";
|
|
33
36
|
import { performance } from "node:perf_hooks";
|
|
34
37
|
import { stripVTControlCharacters } from "node:util";
|
|
35
38
|
import tty from "node:tty";
|
|
@@ -39,6 +42,8 @@ import basePrompts from "prompts";
|
|
|
39
42
|
import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
|
|
40
43
|
import { fileURLToPath } from "node:url";
|
|
41
44
|
import Conf from "conf";
|
|
45
|
+
import { generateCode, loadFile, writeFile } from "magicast";
|
|
46
|
+
import { getConfigFromVariableDeclaration, getDefaultExportOptions } from "magicast/helpers";
|
|
42
47
|
//#region \0rolldown/runtime.js
|
|
43
48
|
var __create$1 = Object.create;
|
|
44
49
|
var __defProp$1 = Object.defineProperty;
|
|
@@ -5892,29 +5897,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
5892
5897
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
5893
5898
|
};
|
|
5894
5899
|
};
|
|
5895
|
-
const
|
|
5896
|
-
|
|
5900
|
+
const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
|
|
5901
|
+
const rootValue = select(rootPackageJson);
|
|
5902
|
+
if (rootValue !== null) return rootValue;
|
|
5897
5903
|
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
5898
|
-
if (patterns.length === 0) return
|
|
5904
|
+
if (patterns.length === 0) return null;
|
|
5899
5905
|
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
5900
5906
|
for (const pattern of patterns) {
|
|
5901
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
5907
|
+
const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
|
|
5902
5908
|
for (const workspaceDirectory of directories) {
|
|
5903
5909
|
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
5904
5910
|
visitedDirectories.add(workspaceDirectory);
|
|
5905
|
-
|
|
5911
|
+
const value = select(readPackageJson$1(path.join(workspaceDirectory, "package.json")));
|
|
5912
|
+
if (value !== null) return value;
|
|
5906
5913
|
}
|
|
5907
5914
|
}
|
|
5908
|
-
return
|
|
5915
|
+
return null;
|
|
5909
5916
|
};
|
|
5917
|
+
const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
|
|
5910
5918
|
const NAMES = new Set([
|
|
5911
5919
|
"react-native",
|
|
5912
5920
|
"react-native-tvos",
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5921
|
+
...new Set([
|
|
5922
|
+
"expo",
|
|
5923
|
+
"expo-router",
|
|
5924
|
+
"@expo/cli",
|
|
5925
|
+
"@expo/metro-config",
|
|
5926
|
+
"@expo/metro-runtime"
|
|
5927
|
+
]),
|
|
5918
5928
|
"react-native-windows",
|
|
5919
5929
|
"react-native-macos"
|
|
5920
5930
|
]);
|
|
@@ -5938,6 +5948,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
|
|
|
5938
5948
|
return false;
|
|
5939
5949
|
};
|
|
5940
5950
|
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
|
|
5951
|
+
const getExpoDependencySpec = (packageJson) => {
|
|
5952
|
+
const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
|
|
5953
|
+
return typeof spec === "string" ? spec : null;
|
|
5954
|
+
};
|
|
5955
|
+
const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
|
|
5941
5956
|
const getPreactVersion = (packageJson) => {
|
|
5942
5957
|
return {
|
|
5943
5958
|
...packageJson.peerDependencies,
|
|
@@ -6174,6 +6189,19 @@ const discoverProject = (directory) => {
|
|
|
6174
6189
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
6175
6190
|
const sourceFileCount = countSourceFiles(directory);
|
|
6176
6191
|
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
6192
|
+
let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
|
|
6193
|
+
if (expoVersion !== null && isCatalogReference(expoVersion)) {
|
|
6194
|
+
const catalogName = extractCatalogName(expoVersion);
|
|
6195
|
+
let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
|
|
6196
|
+
if (!resolvedExpoVersion) {
|
|
6197
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
6198
|
+
if (monorepoRoot) {
|
|
6199
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
6200
|
+
if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson$1(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
|
|
6201
|
+
}
|
|
6202
|
+
}
|
|
6203
|
+
expoVersion = resolvedExpoVersion ?? expoVersion;
|
|
6204
|
+
}
|
|
6177
6205
|
const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
|
|
6178
6206
|
const preactVersion = getPreactVersion(packageJson);
|
|
6179
6207
|
const projectInfo = {
|
|
@@ -6191,6 +6219,7 @@ const discoverProject = (directory) => {
|
|
|
6191
6219
|
preactVersion,
|
|
6192
6220
|
preactMajorVersion: parseReactMajor(preactVersion),
|
|
6193
6221
|
hasReactNativeWorkspace,
|
|
6222
|
+
expoVersion,
|
|
6194
6223
|
hasReanimated,
|
|
6195
6224
|
sourceFileCount
|
|
6196
6225
|
};
|
|
@@ -6266,7 +6295,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
|
|
|
6266
6295
|
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
6267
6296
|
const ENTERPRISE_CONTACT_URL = "https://react.doctor/enterprise";
|
|
6268
6297
|
const SHARE_BASE_URL = "https://react.doctor/share";
|
|
6269
|
-
const
|
|
6298
|
+
const DOCS_URL = "https://www.react.doctor/docs";
|
|
6299
|
+
const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
|
|
6270
6300
|
const FETCH_TIMEOUT_MS = 1e4;
|
|
6271
6301
|
const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
|
|
6272
6302
|
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
@@ -6284,11 +6314,19 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
6284
6314
|
"tsconfig.json",
|
|
6285
6315
|
"tsconfig.base.json",
|
|
6286
6316
|
"package.json",
|
|
6287
|
-
"
|
|
6317
|
+
"doctor.config.ts",
|
|
6318
|
+
"doctor.config.mts",
|
|
6319
|
+
"doctor.config.cts",
|
|
6320
|
+
"doctor.config.js",
|
|
6321
|
+
"doctor.config.mjs",
|
|
6322
|
+
"doctor.config.cjs",
|
|
6323
|
+
"doctor.config.json",
|
|
6324
|
+
"doctor.config.jsonc",
|
|
6288
6325
|
"oxlint.json",
|
|
6289
6326
|
".oxlintrc.json"
|
|
6290
6327
|
];
|
|
6291
6328
|
const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
|
|
6329
|
+
const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
|
|
6292
6330
|
const SKILL_NAME = "react-doctor";
|
|
6293
6331
|
const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
6294
6332
|
const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
|
|
@@ -6301,6 +6339,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
|
6301
6339
|
"Accessibility",
|
|
6302
6340
|
"Maintainability"
|
|
6303
6341
|
];
|
|
6342
|
+
const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
|
|
6343
|
+
const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
|
|
6344
|
+
const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
|
|
6304
6345
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
6305
6346
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
6306
6347
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -6420,10 +6461,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
6420
6461
|
*/
|
|
6421
6462
|
const buildRuleSeverityControls = (config) => {
|
|
6422
6463
|
if (!config) return void 0;
|
|
6423
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
6464
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
6424
6465
|
return {
|
|
6425
6466
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
6426
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
6467
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
6468
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
6427
6469
|
};
|
|
6428
6470
|
};
|
|
6429
6471
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -6787,6 +6829,65 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
6787
6829
|
}
|
|
6788
6830
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
6789
6831
|
};
|
|
6832
|
+
const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
|
|
6833
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
6834
|
+
const findNearestPackageDirectory$1 = (filename) => {
|
|
6835
|
+
if (!filename) return null;
|
|
6836
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
6837
|
+
if (fromCache !== void 0) return fromCache;
|
|
6838
|
+
let currentDirectory = path.dirname(filename);
|
|
6839
|
+
while (true) {
|
|
6840
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
6841
|
+
let hasPackageJson = false;
|
|
6842
|
+
try {
|
|
6843
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
6844
|
+
} catch {
|
|
6845
|
+
hasPackageJson = false;
|
|
6846
|
+
}
|
|
6847
|
+
if (hasPackageJson) {
|
|
6848
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
6849
|
+
return currentDirectory;
|
|
6850
|
+
}
|
|
6851
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
6852
|
+
if (parentDirectory === currentDirectory) {
|
|
6853
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
6854
|
+
return null;
|
|
6855
|
+
}
|
|
6856
|
+
currentDirectory = parentDirectory;
|
|
6857
|
+
}
|
|
6858
|
+
};
|
|
6859
|
+
const readManifest = (packageJsonPath) => {
|
|
6860
|
+
try {
|
|
6861
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
6862
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
6863
|
+
return null;
|
|
6864
|
+
} catch {
|
|
6865
|
+
return null;
|
|
6866
|
+
}
|
|
6867
|
+
};
|
|
6868
|
+
const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
|
|
6869
|
+
const classifyByDirectoryCohort = (packageDirectory) => {
|
|
6870
|
+
let current = packageDirectory;
|
|
6871
|
+
while (true) {
|
|
6872
|
+
if (path.basename(current) === "apps") return "app";
|
|
6873
|
+
const parent = path.dirname(current);
|
|
6874
|
+
if (parent === current) return null;
|
|
6875
|
+
current = parent;
|
|
6876
|
+
}
|
|
6877
|
+
};
|
|
6878
|
+
const classifyPackageRole = (filename) => {
|
|
6879
|
+
if (!filename) return "unknown";
|
|
6880
|
+
const packageDirectory = findNearestPackageDirectory$1(filename);
|
|
6881
|
+
if (!packageDirectory) return "unknown";
|
|
6882
|
+
const cached = cachedRoleByPackageDirectory.get(packageDirectory);
|
|
6883
|
+
if (cached !== void 0) return cached;
|
|
6884
|
+
const manifest = readManifest(path.join(packageDirectory, "package.json"));
|
|
6885
|
+
let result;
|
|
6886
|
+
if (manifest && hasPublishContract(manifest)) result = "library";
|
|
6887
|
+
else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
|
|
6888
|
+
cachedRoleByPackageDirectory.set(packageDirectory, result);
|
|
6889
|
+
return result;
|
|
6890
|
+
};
|
|
6790
6891
|
/**
|
|
6791
6892
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
6792
6893
|
* accounting for the various shapes oxlint emits:
|
|
@@ -6949,6 +7050,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6949
7050
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
6950
7051
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
6951
7052
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
7053
|
+
const libraryFileCache = /* @__PURE__ */ new Map();
|
|
7054
|
+
const isLibraryFile = (filePath) => {
|
|
7055
|
+
let cached = libraryFileCache.get(filePath);
|
|
7056
|
+
if (cached === void 0) {
|
|
7057
|
+
cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
|
|
7058
|
+
libraryFileCache.set(filePath, cached);
|
|
7059
|
+
}
|
|
7060
|
+
return cached;
|
|
7061
|
+
};
|
|
6952
7062
|
const getFileLines = (filePath) => {
|
|
6953
7063
|
const cached = fileLinesCache.get(filePath);
|
|
6954
7064
|
if (cached !== void 0) return cached;
|
|
@@ -6975,6 +7085,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6975
7085
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
6976
7086
|
return false;
|
|
6977
7087
|
};
|
|
7088
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
7089
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
7090
|
+
return false;
|
|
7091
|
+
};
|
|
6978
7092
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
6979
7093
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
6980
7094
|
if (diagnostic.line <= 0) return false;
|
|
@@ -6989,8 +7103,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6989
7103
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
6990
7104
|
let current = diagnostic;
|
|
6991
7105
|
let explicitSeverityOverride;
|
|
7106
|
+
let explicitRuleOverride;
|
|
6992
7107
|
if (severityControls) {
|
|
6993
7108
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
7109
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
6994
7110
|
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
6995
7111
|
ruleKey,
|
|
6996
7112
|
category
|
|
@@ -6998,6 +7114,9 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6998
7114
|
if (explicitSeverityOverride === "off") return null;
|
|
6999
7115
|
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
7000
7116
|
}
|
|
7117
|
+
if (explicitRuleOverride === void 0) {
|
|
7118
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
7119
|
+
}
|
|
7001
7120
|
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
7002
7121
|
if (userConfig) {
|
|
7003
7122
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
@@ -7183,6 +7302,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
7183
7302
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
7184
7303
|
}).pipe(Effect.orDie));
|
|
7185
7304
|
/**
|
|
7305
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
7306
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
7307
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
7308
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
7309
|
+
*/
|
|
7310
|
+
const resolveScanConcurrency = (requested) => {
|
|
7311
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
7312
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
7313
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
7314
|
+
};
|
|
7315
|
+
/**
|
|
7186
7316
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
7187
7317
|
* startup so the eval harness can raise the budget under sandbox
|
|
7188
7318
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -7202,6 +7332,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
7202
7332
|
* tests that exercise the cap behavior.
|
|
7203
7333
|
*/
|
|
7204
7334
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
7335
|
+
/**
|
|
7336
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
7337
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
7338
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
7339
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
7340
|
+
* CI callers that never touch the flag:
|
|
7341
|
+
*
|
|
7342
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
7343
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
7344
|
+
* - a positive integer → that many workers (clamped)
|
|
7345
|
+
*
|
|
7346
|
+
* The resolved value is always within
|
|
7347
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
7348
|
+
*/
|
|
7349
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
7350
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
7351
|
+
if (raw === void 0) return 1;
|
|
7352
|
+
const normalized = raw.trim().toLowerCase();
|
|
7353
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
7354
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
7355
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
7356
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
7357
|
+
return resolveScanConcurrency(parsed);
|
|
7358
|
+
} }) {};
|
|
7205
7359
|
const DIAGNOSTIC_SURFACES = [
|
|
7206
7360
|
"cli",
|
|
7207
7361
|
"prComment",
|
|
@@ -7358,66 +7512,135 @@ const validateConfigTypes = (config) => {
|
|
|
7358
7512
|
const warn = (message) => {
|
|
7359
7513
|
Effect.runSync(Console.warn(message));
|
|
7360
7514
|
};
|
|
7361
|
-
const
|
|
7362
|
-
const
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7515
|
+
const CONFIG_BASENAME = "doctor.config";
|
|
7516
|
+
const CONFIG_EXTENSIONS = [
|
|
7517
|
+
"ts",
|
|
7518
|
+
"mts",
|
|
7519
|
+
"cts",
|
|
7520
|
+
"js",
|
|
7521
|
+
"mjs",
|
|
7522
|
+
"cjs",
|
|
7523
|
+
"json",
|
|
7524
|
+
"jsonc"
|
|
7525
|
+
];
|
|
7526
|
+
const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
|
|
7527
|
+
const PACKAGE_JSON_FILENAME = "package.json";
|
|
7528
|
+
const PACKAGE_JSON_CONFIG_KEY$1 = "reactDoctor";
|
|
7529
|
+
const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
|
|
7530
|
+
const jiti = createJiti(import.meta.url);
|
|
7531
|
+
const formatError = (error) => error instanceof Error ? error.message : String(error);
|
|
7532
|
+
const loadModuleConfig = async (filePath) => {
|
|
7533
|
+
const imported = await jiti.import(filePath);
|
|
7534
|
+
return imported?.default ?? imported;
|
|
7535
|
+
};
|
|
7536
|
+
const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
|
|
7537
|
+
const readEmbeddedPackageJsonConfig = (directory) => {
|
|
7538
|
+
const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
|
|
7539
|
+
if (!isFile(packageJsonPath)) return null;
|
|
7540
|
+
try {
|
|
7541
|
+
const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
7380
7542
|
if (isPlainObject(packageJson)) {
|
|
7381
|
-
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
7382
|
-
if (isPlainObject(embeddedConfig)) return
|
|
7383
|
-
|
|
7384
|
-
|
|
7543
|
+
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY$1];
|
|
7544
|
+
if (isPlainObject(embeddedConfig)) return embeddedConfig;
|
|
7545
|
+
}
|
|
7546
|
+
} catch {}
|
|
7547
|
+
return null;
|
|
7548
|
+
};
|
|
7549
|
+
const loadPackageJsonConfig = (directory) => {
|
|
7550
|
+
const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
|
|
7551
|
+
if (!embeddedConfig) return null;
|
|
7552
|
+
return {
|
|
7553
|
+
config: validateConfigTypes(embeddedConfig),
|
|
7554
|
+
sourceDirectory: directory,
|
|
7555
|
+
configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
|
|
7556
|
+
format: "package-json"
|
|
7557
|
+
};
|
|
7558
|
+
};
|
|
7559
|
+
const loadConfigFromDirectory = async (directory) => {
|
|
7560
|
+
let sawBrokenConfigFile = false;
|
|
7561
|
+
for (const extension of CONFIG_EXTENSIONS) {
|
|
7562
|
+
const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
|
|
7563
|
+
if (!isFile(filePath)) continue;
|
|
7564
|
+
const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
|
|
7565
|
+
try {
|
|
7566
|
+
const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
|
|
7567
|
+
if (isPlainObject(parsed)) return {
|
|
7568
|
+
status: "found",
|
|
7569
|
+
loaded: {
|
|
7570
|
+
config: validateConfigTypes(parsed),
|
|
7571
|
+
sourceDirectory: directory,
|
|
7572
|
+
configFilePath: filePath,
|
|
7573
|
+
format: isDataFile ? "json" : "module"
|
|
7574
|
+
}
|
|
7385
7575
|
};
|
|
7576
|
+
warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
|
|
7577
|
+
sawBrokenConfigFile = true;
|
|
7578
|
+
} catch (error) {
|
|
7579
|
+
warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
|
|
7580
|
+
sawBrokenConfigFile = true;
|
|
7386
7581
|
}
|
|
7387
|
-
} catch {
|
|
7388
|
-
return null;
|
|
7389
7582
|
}
|
|
7390
|
-
|
|
7583
|
+
const packageJsonConfig = loadPackageJsonConfig(directory);
|
|
7584
|
+
if (packageJsonConfig) return {
|
|
7585
|
+
status: "found",
|
|
7586
|
+
loaded: packageJsonConfig
|
|
7587
|
+
};
|
|
7588
|
+
if (isFile(path.join(directory, LEGACY_CONFIG_FILENAME))) warn(`${LEGACY_CONFIG_FILENAME} is no longer read — rename it to ${CONFIG_BASENAME}.json (or author a ${CONFIG_BASENAME}.ts).`);
|
|
7589
|
+
return {
|
|
7590
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
7591
|
+
loaded: null
|
|
7592
|
+
};
|
|
7391
7593
|
};
|
|
7392
7594
|
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
|
-
}
|
|
7595
|
+
const clearConfigCache = () => {
|
|
7596
|
+
cachedConfigs.clear();
|
|
7597
|
+
};
|
|
7598
|
+
const loadConfigWalkingUp = async (rootDirectory) => {
|
|
7599
|
+
const localResult = await loadConfigFromDirectory(rootDirectory);
|
|
7600
|
+
if (localResult.status === "found") return localResult.loaded;
|
|
7601
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
|
|
7405
7602
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
7406
7603
|
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
|
-
}
|
|
7604
|
+
const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
|
|
7605
|
+
if (ancestorResult.status === "found") return ancestorResult.loaded;
|
|
7606
|
+
if (isProjectBoundary(ancestorDirectory)) return null;
|
|
7416
7607
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
7417
7608
|
}
|
|
7418
|
-
cachedConfigs.set(rootDirectory, null);
|
|
7419
7609
|
return null;
|
|
7420
7610
|
};
|
|
7611
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
7612
|
+
const cached = cachedConfigs.get(rootDirectory);
|
|
7613
|
+
if (cached !== void 0) return cached;
|
|
7614
|
+
const loadPromise = loadConfigWalkingUp(rootDirectory);
|
|
7615
|
+
cachedConfigs.set(rootDirectory, loadPromise);
|
|
7616
|
+
return loadPromise;
|
|
7617
|
+
};
|
|
7618
|
+
const directoryHasCurrentConfig = (directory) => {
|
|
7619
|
+
for (const extension of CONFIG_EXTENSIONS) if (isFile(path.join(directory, `${CONFIG_BASENAME}.${extension}`))) return true;
|
|
7620
|
+
return readEmbeddedPackageJsonConfig(directory) !== null;
|
|
7621
|
+
};
|
|
7622
|
+
/**
|
|
7623
|
+
* Walks up from `rootDirectory` (same boundary semantics as
|
|
7624
|
+
* `loadConfigWithSource`) looking for a pre-migration
|
|
7625
|
+
* `react-doctor.config.json` that is no longer read. Returns the first one
|
|
7626
|
+
* found, or `null` when a current-format config supersedes it or none exists
|
|
7627
|
+
* before a project boundary. Detection only — the CLI performs the rename.
|
|
7628
|
+
*/
|
|
7629
|
+
const findLegacyConfig = (rootDirectory) => {
|
|
7630
|
+
let directory = rootDirectory;
|
|
7631
|
+
while (true) {
|
|
7632
|
+
if (directoryHasCurrentConfig(directory)) return null;
|
|
7633
|
+
const legacyFilePath = path.join(directory, LEGACY_CONFIG_FILENAME);
|
|
7634
|
+
if (isFile(legacyFilePath)) return {
|
|
7635
|
+
legacyFilePath,
|
|
7636
|
+
directory
|
|
7637
|
+
};
|
|
7638
|
+
if (isProjectBoundary(directory)) return null;
|
|
7639
|
+
const parentDirectory = path.dirname(directory);
|
|
7640
|
+
if (parentDirectory === directory) return null;
|
|
7641
|
+
directory = parentDirectory;
|
|
7642
|
+
}
|
|
7643
|
+
};
|
|
7421
7644
|
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
7422
7645
|
if (!config || !configSourceDirectory) return null;
|
|
7423
7646
|
const rawRootDir = config.rootDir;
|
|
@@ -7432,11 +7655,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
|
7432
7655
|
}
|
|
7433
7656
|
return resolvedRootDir;
|
|
7434
7657
|
};
|
|
7435
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
7658
|
+
const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
7436
7659
|
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
7437
7660
|
const reactSubprojects = discoverReactSubprojects(directory);
|
|
7438
7661
|
if (reactSubprojects.length === 0) return null;
|
|
7439
7662
|
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
7663
|
+
if (options.allowAmbiguous === true) return null;
|
|
7440
7664
|
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
7441
7665
|
};
|
|
7442
7666
|
/**
|
|
@@ -7444,13 +7668,13 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
7444
7668
|
* (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
|
|
7445
7669
|
*
|
|
7446
7670
|
* 1. Resolve the requested directory to absolute.
|
|
7447
|
-
* 2. Load `
|
|
7448
|
-
* if present.
|
|
7671
|
+
* 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
|
|
7449
7672
|
* 3. Honor `config.rootDir` to redirect the scan to a nested
|
|
7450
7673
|
* project root, if configured.
|
|
7451
7674
|
* 4. Walk into a nested React subproject when the requested
|
|
7452
7675
|
* directory has no `package.json` of its own (raises
|
|
7453
|
-
* `AmbiguousProjectError` when multiple candidates exist
|
|
7676
|
+
* `AmbiguousProjectError` when multiple candidates exist unless
|
|
7677
|
+
* the caller opts into keeping the wrapper directory).
|
|
7454
7678
|
*
|
|
7455
7679
|
* Throws `ProjectNotFoundError` when neither the requested directory
|
|
7456
7680
|
* nor any discoverable nested project has a `package.json`.
|
|
@@ -7462,14 +7686,14 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
7462
7686
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
7463
7687
|
* shell in agreement on what "the scan directory" means.
|
|
7464
7688
|
*/
|
|
7465
|
-
const resolveScanTarget = (requestedDirectory) => {
|
|
7689
|
+
const resolveScanTarget = async (requestedDirectory, options = {}) => {
|
|
7466
7690
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
7467
|
-
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
7691
|
+
const loadedConfig = await loadConfigWithSource(absoluteRequested);
|
|
7468
7692
|
const userConfig = loadedConfig?.config ?? null;
|
|
7469
7693
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
7470
7694
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
7471
7695
|
const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
|
|
7472
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
|
|
7696
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
|
|
7473
7697
|
if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
|
|
7474
7698
|
return {
|
|
7475
7699
|
resolvedDirectory,
|
|
@@ -7479,6 +7703,359 @@ const resolveScanTarget = (requestedDirectory) => {
|
|
|
7479
7703
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
7480
7704
|
};
|
|
7481
7705
|
};
|
|
7706
|
+
const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
|
|
7707
|
+
const buildExpoCheckContext = (rootDirectory, expoVersion) => {
|
|
7708
|
+
const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
|
|
7709
|
+
return {
|
|
7710
|
+
rootDirectory,
|
|
7711
|
+
packageJson,
|
|
7712
|
+
directDependencyNames: getDirectDependencyNames(packageJson),
|
|
7713
|
+
expoSdkMajor: getLowestDependencyMajor(expoVersion)
|
|
7714
|
+
};
|
|
7715
|
+
};
|
|
7716
|
+
const buildExpoDiagnostic = (input) => ({
|
|
7717
|
+
filePath: input.filePath ?? "package.json",
|
|
7718
|
+
plugin: "react-doctor",
|
|
7719
|
+
rule: input.rule,
|
|
7720
|
+
severity: input.severity ?? "warning",
|
|
7721
|
+
message: input.message,
|
|
7722
|
+
help: input.help,
|
|
7723
|
+
line: input.line ?? 0,
|
|
7724
|
+
column: input.column ?? 0,
|
|
7725
|
+
category: input.category ?? "Correctness"
|
|
7726
|
+
});
|
|
7727
|
+
const CRITICAL_OVERRIDE_NAMES = new Set([
|
|
7728
|
+
"@expo/cli",
|
|
7729
|
+
"@expo/config",
|
|
7730
|
+
"@expo/metro-config",
|
|
7731
|
+
"@expo/metro-runtime",
|
|
7732
|
+
"@expo/metro",
|
|
7733
|
+
"metro"
|
|
7734
|
+
]);
|
|
7735
|
+
const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
|
|
7736
|
+
const collectOverrideNames = (packageJson) => new Set([
|
|
7737
|
+
...Object.keys(packageJson.overrides ?? {}),
|
|
7738
|
+
...Object.keys(packageJson.resolutions ?? {}),
|
|
7739
|
+
...Object.keys(packageJson.pnpm?.overrides ?? {})
|
|
7740
|
+
]);
|
|
7741
|
+
const checkExpoDependencyOverrides = (context) => {
|
|
7742
|
+
const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
|
|
7743
|
+
if (overriddenCriticalNames.length === 0) return [];
|
|
7744
|
+
const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
|
|
7745
|
+
return [buildExpoDiagnostic({
|
|
7746
|
+
rule: "expo-no-conflicting-dependency-override",
|
|
7747
|
+
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`,
|
|
7748
|
+
help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
|
|
7749
|
+
})];
|
|
7750
|
+
};
|
|
7751
|
+
const isPathGitIgnored = (rootDirectory, absolutePath) => {
|
|
7752
|
+
const result = spawnSync("git", [
|
|
7753
|
+
"check-ignore",
|
|
7754
|
+
"-q",
|
|
7755
|
+
absolutePath
|
|
7756
|
+
], {
|
|
7757
|
+
cwd: rootDirectory,
|
|
7758
|
+
stdio: [
|
|
7759
|
+
"ignore",
|
|
7760
|
+
"ignore",
|
|
7761
|
+
"ignore"
|
|
7762
|
+
]
|
|
7763
|
+
});
|
|
7764
|
+
if (result.error) return null;
|
|
7765
|
+
if (result.status === 0) return true;
|
|
7766
|
+
if (result.status === 1) return false;
|
|
7767
|
+
return null;
|
|
7768
|
+
};
|
|
7769
|
+
const LOCAL_ENV_FILE_NAMES = [
|
|
7770
|
+
".env.local",
|
|
7771
|
+
".env.development.local",
|
|
7772
|
+
".env.production.local",
|
|
7773
|
+
".env.test.local"
|
|
7774
|
+
];
|
|
7775
|
+
const checkExpoEnvLocalFiles = (context) => {
|
|
7776
|
+
const { rootDirectory } = context;
|
|
7777
|
+
const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
|
|
7778
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
7779
|
+
if (!isFile(filePath)) return false;
|
|
7780
|
+
return isPathGitIgnored(rootDirectory, filePath) === false;
|
|
7781
|
+
});
|
|
7782
|
+
if (committedEnvFiles.length === 0) return [];
|
|
7783
|
+
return [buildExpoDiagnostic({
|
|
7784
|
+
rule: "expo-env-local-not-gitignored",
|
|
7785
|
+
category: "Security",
|
|
7786
|
+
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`,
|
|
7787
|
+
help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
|
|
7788
|
+
})];
|
|
7789
|
+
};
|
|
7790
|
+
const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
|
|
7791
|
+
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";
|
|
7792
|
+
const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
|
|
7793
|
+
const unimodulesEntry = (packageName) => ({
|
|
7794
|
+
packageName,
|
|
7795
|
+
rule: "expo-no-unimodules-packages",
|
|
7796
|
+
message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
|
|
7797
|
+
help: UNIMODULES_HELP
|
|
7798
|
+
});
|
|
7799
|
+
const FLAGGED_DEPENDENCIES = [
|
|
7800
|
+
unimodulesEntry("@unimodules/core"),
|
|
7801
|
+
unimodulesEntry("@unimodules/react-native-adapter"),
|
|
7802
|
+
unimodulesEntry("react-native-unimodules"),
|
|
7803
|
+
{
|
|
7804
|
+
packageName: "expo-cli",
|
|
7805
|
+
rule: "expo-no-cli-dependencies",
|
|
7806
|
+
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`",
|
|
7807
|
+
help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
|
|
7808
|
+
},
|
|
7809
|
+
{
|
|
7810
|
+
packageName: "eas-cli",
|
|
7811
|
+
rule: "expo-no-cli-dependencies",
|
|
7812
|
+
message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
|
|
7813
|
+
help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
|
|
7814
|
+
},
|
|
7815
|
+
{
|
|
7816
|
+
packageName: "expo-modules-autolinking",
|
|
7817
|
+
rule: "expo-no-redundant-dependency",
|
|
7818
|
+
message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
|
|
7819
|
+
help: "Remove `expo-modules-autolinking` from your package.json"
|
|
7820
|
+
},
|
|
7821
|
+
{
|
|
7822
|
+
packageName: "expo-dev-launcher",
|
|
7823
|
+
rule: "expo-no-redundant-dependency",
|
|
7824
|
+
message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7825
|
+
help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
|
|
7826
|
+
},
|
|
7827
|
+
{
|
|
7828
|
+
packageName: "expo-dev-menu",
|
|
7829
|
+
rule: "expo-no-redundant-dependency",
|
|
7830
|
+
message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7831
|
+
help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
|
|
7832
|
+
},
|
|
7833
|
+
{
|
|
7834
|
+
packageName: "expo-modules-core",
|
|
7835
|
+
rule: "expo-no-redundant-dependency",
|
|
7836
|
+
message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
|
|
7837
|
+
help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
|
|
7838
|
+
},
|
|
7839
|
+
{
|
|
7840
|
+
packageName: "@expo/metro-config",
|
|
7841
|
+
rule: "expo-no-redundant-dependency",
|
|
7842
|
+
message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
|
|
7843
|
+
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
|
|
7844
|
+
},
|
|
7845
|
+
{
|
|
7846
|
+
packageName: "@types/react-native",
|
|
7847
|
+
rule: "expo-no-redundant-dependency",
|
|
7848
|
+
message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
|
|
7849
|
+
help: "Remove `@types/react-native` from your package.json",
|
|
7850
|
+
minSdkMajor: 48
|
|
7851
|
+
},
|
|
7852
|
+
{
|
|
7853
|
+
packageName: "@expo/config-plugins",
|
|
7854
|
+
rule: "expo-no-redundant-dependency",
|
|
7855
|
+
message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
|
|
7856
|
+
help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
|
|
7857
|
+
minSdkMajor: 48
|
|
7858
|
+
},
|
|
7859
|
+
{
|
|
7860
|
+
packageName: "@expo/prebuild-config",
|
|
7861
|
+
rule: "expo-no-redundant-dependency",
|
|
7862
|
+
message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
|
|
7863
|
+
help: "Remove `@expo/prebuild-config` from your package.json",
|
|
7864
|
+
minSdkMajor: 53
|
|
7865
|
+
},
|
|
7866
|
+
{
|
|
7867
|
+
packageName: "expo-permissions",
|
|
7868
|
+
rule: "expo-no-redundant-dependency",
|
|
7869
|
+
message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
|
|
7870
|
+
help: "Remove `expo-permissions` and request permissions from the relevant module instead",
|
|
7871
|
+
minSdkMajor: 50
|
|
7872
|
+
},
|
|
7873
|
+
{
|
|
7874
|
+
packageName: "expo-app-loading",
|
|
7875
|
+
rule: "expo-no-redundant-dependency",
|
|
7876
|
+
message: "\"expo-app-loading\" was removed in SDK 49",
|
|
7877
|
+
help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
|
|
7878
|
+
minSdkMajor: 49
|
|
7879
|
+
},
|
|
7880
|
+
{
|
|
7881
|
+
packageName: "expo-firebase-analytics",
|
|
7882
|
+
rule: "expo-no-redundant-dependency",
|
|
7883
|
+
message: "\"expo-firebase-analytics\" was removed in SDK 48",
|
|
7884
|
+
help: FIREBASE_HELP,
|
|
7885
|
+
minSdkMajor: 48
|
|
7886
|
+
},
|
|
7887
|
+
{
|
|
7888
|
+
packageName: "expo-firebase-recaptcha",
|
|
7889
|
+
rule: "expo-no-redundant-dependency",
|
|
7890
|
+
message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
|
|
7891
|
+
help: FIREBASE_HELP,
|
|
7892
|
+
minSdkMajor: 48
|
|
7893
|
+
},
|
|
7894
|
+
{
|
|
7895
|
+
packageName: "expo-firebase-core",
|
|
7896
|
+
rule: "expo-no-redundant-dependency",
|
|
7897
|
+
message: "\"expo-firebase-core\" was removed in SDK 48",
|
|
7898
|
+
help: FIREBASE_HELP,
|
|
7899
|
+
minSdkMajor: 48
|
|
7900
|
+
}
|
|
7901
|
+
];
|
|
7902
|
+
const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
|
|
7903
|
+
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
|
|
7904
|
+
if (flaggedDependency.minSdkMajor === void 0) return true;
|
|
7905
|
+
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
|
|
7906
|
+
}).map((flaggedDependency) => buildExpoDiagnostic({
|
|
7907
|
+
rule: flaggedDependency.rule,
|
|
7908
|
+
message: flaggedDependency.message,
|
|
7909
|
+
help: flaggedDependency.help
|
|
7910
|
+
}));
|
|
7911
|
+
const findLocalModuleNativeFiles = (rootDirectory) => {
|
|
7912
|
+
const modulesDirectory = path.join(rootDirectory, "modules");
|
|
7913
|
+
if (!isDirectory(modulesDirectory)) return [];
|
|
7914
|
+
const nativeFilePaths = [];
|
|
7915
|
+
for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
|
|
7916
|
+
if (!moduleEntry.isDirectory()) continue;
|
|
7917
|
+
const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
|
|
7918
|
+
const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
|
|
7919
|
+
if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
|
|
7920
|
+
const iosDirectory = path.join(moduleDirectory, "ios");
|
|
7921
|
+
if (isDirectory(iosDirectory)) {
|
|
7922
|
+
for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
|
|
7923
|
+
}
|
|
7924
|
+
}
|
|
7925
|
+
return nativeFilePaths;
|
|
7926
|
+
};
|
|
7927
|
+
const checkExpoGitignore = (context) => {
|
|
7928
|
+
const { rootDirectory } = context;
|
|
7929
|
+
const diagnostics = [];
|
|
7930
|
+
const expoStateDirectory = path.join(rootDirectory, ".expo");
|
|
7931
|
+
if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
|
|
7932
|
+
rule: "expo-gitignore",
|
|
7933
|
+
message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
|
|
7934
|
+
help: "Add `.expo/` to your .gitignore"
|
|
7935
|
+
}));
|
|
7936
|
+
if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
|
|
7937
|
+
rule: "expo-gitignore",
|
|
7938
|
+
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",
|
|
7939
|
+
help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
|
|
7940
|
+
}));
|
|
7941
|
+
return diagnostics;
|
|
7942
|
+
};
|
|
7943
|
+
const LOCKFILE_NAMES = [
|
|
7944
|
+
"pnpm-lock.yaml",
|
|
7945
|
+
"yarn.lock",
|
|
7946
|
+
"package-lock.json",
|
|
7947
|
+
"bun.lockb",
|
|
7948
|
+
"bun.lock"
|
|
7949
|
+
];
|
|
7950
|
+
const checkExpoLockfile = (context) => {
|
|
7951
|
+
const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
|
|
7952
|
+
const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
|
|
7953
|
+
if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
|
|
7954
|
+
rule: "expo-lockfile",
|
|
7955
|
+
message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
|
|
7956
|
+
help: "Install dependencies with your package manager to generate a lock file, then commit it"
|
|
7957
|
+
})];
|
|
7958
|
+
if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
|
|
7959
|
+
rule: "expo-lockfile",
|
|
7960
|
+
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`,
|
|
7961
|
+
help: "Delete the lock files for the package managers you are not using and keep only one"
|
|
7962
|
+
})];
|
|
7963
|
+
return [];
|
|
7964
|
+
};
|
|
7965
|
+
const METRO_CONFIG_FILE_NAMES = [
|
|
7966
|
+
"metro.config.js",
|
|
7967
|
+
"metro.config.cjs",
|
|
7968
|
+
"metro.config.mjs",
|
|
7969
|
+
"metro.config.ts"
|
|
7970
|
+
];
|
|
7971
|
+
const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
|
|
7972
|
+
"expo/metro-config",
|
|
7973
|
+
"@sentry/react-native/metro",
|
|
7974
|
+
"getSentryExpoConfig"
|
|
7975
|
+
];
|
|
7976
|
+
const checkExpoMetroConfig = (context) => {
|
|
7977
|
+
const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
|
|
7978
|
+
if (metroConfigPath === void 0) return [];
|
|
7979
|
+
let contents;
|
|
7980
|
+
try {
|
|
7981
|
+
contents = fs.readFileSync(metroConfigPath, "utf-8");
|
|
7982
|
+
} catch {
|
|
7983
|
+
return [];
|
|
7984
|
+
}
|
|
7985
|
+
if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
|
|
7986
|
+
return [buildExpoDiagnostic({
|
|
7987
|
+
rule: "expo-metro-config",
|
|
7988
|
+
filePath: path.basename(metroConfigPath),
|
|
7989
|
+
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",
|
|
7990
|
+
help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
|
|
7991
|
+
})];
|
|
7992
|
+
};
|
|
7993
|
+
const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
|
|
7994
|
+
const checkExpoPackageJsonConflicts = (context) => {
|
|
7995
|
+
const { packageJson } = context;
|
|
7996
|
+
const diagnostics = [];
|
|
7997
|
+
const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
|
|
7998
|
+
if (conflictingScriptNames.length > 0) {
|
|
7999
|
+
const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
|
|
8000
|
+
const shadowsExpoCli = conflictingScriptNames.includes("expo");
|
|
8001
|
+
diagnostics.push(buildExpoDiagnostic({
|
|
8002
|
+
rule: "expo-package-json-conflict",
|
|
8003
|
+
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" : ""}`,
|
|
8004
|
+
help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
|
|
8005
|
+
}));
|
|
8006
|
+
}
|
|
8007
|
+
const packageName = packageJson.name;
|
|
8008
|
+
if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
|
|
8009
|
+
rule: "expo-package-json-conflict",
|
|
8010
|
+
message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
|
|
8011
|
+
help: "Rename your package so it no longer matches one of its dependencies"
|
|
8012
|
+
}));
|
|
8013
|
+
return diagnostics;
|
|
8014
|
+
};
|
|
8015
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
8016
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
8017
|
+
const checkExpoRouterReactNavigation = (context) => {
|
|
8018
|
+
const { expoSdkMajor } = context;
|
|
8019
|
+
if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
|
|
8020
|
+
if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
|
|
8021
|
+
if (!context.directDependencyNames.has("expo-router")) return [];
|
|
8022
|
+
const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
|
|
8023
|
+
if (reactNavigationNames.length === 0) return [];
|
|
8024
|
+
return [buildExpoDiagnostic({
|
|
8025
|
+
rule: "expo-router-no-react-navigation",
|
|
8026
|
+
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"}`,
|
|
8027
|
+
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/"
|
|
8028
|
+
})];
|
|
8029
|
+
};
|
|
8030
|
+
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
8031
|
+
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
8032
|
+
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
8033
|
+
const checkExpoVectorIcons = (context) => {
|
|
8034
|
+
if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
|
|
8035
|
+
const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
|
|
8036
|
+
const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
|
|
8037
|
+
if (!hasScopedPackage || !hasConflictingPackage) return [];
|
|
8038
|
+
return [buildExpoDiagnostic({
|
|
8039
|
+
rule: "expo-vector-icons-conflict",
|
|
8040
|
+
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",
|
|
8041
|
+
help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
|
|
8042
|
+
})];
|
|
8043
|
+
};
|
|
8044
|
+
const checkExpoProject = (rootDirectory, project) => {
|
|
8045
|
+
if (project.expoVersion === null) return [];
|
|
8046
|
+
const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
|
|
8047
|
+
return [
|
|
8048
|
+
...checkExpoFlaggedDependencies(context),
|
|
8049
|
+
...checkExpoDependencyOverrides(context),
|
|
8050
|
+
...checkExpoRouterReactNavigation(context),
|
|
8051
|
+
...checkExpoVectorIcons(context),
|
|
8052
|
+
...checkExpoPackageJsonConflicts(context),
|
|
8053
|
+
...checkExpoLockfile(context),
|
|
8054
|
+
...checkExpoGitignore(context),
|
|
8055
|
+
...checkExpoEnvLocalFiles(context),
|
|
8056
|
+
...checkExpoMetroConfig(context)
|
|
8057
|
+
];
|
|
8058
|
+
};
|
|
7482
8059
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
7483
8060
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
7484
8061
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -8158,8 +8735,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
|
8158
8735
|
const cache = yield* Cache.make({
|
|
8159
8736
|
capacity: 16,
|
|
8160
8737
|
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
8161
|
-
lookup: (directory) => Effect.
|
|
8162
|
-
const loaded = loadConfigWithSource(directory);
|
|
8738
|
+
lookup: (directory) => Effect.promise(async () => {
|
|
8739
|
+
const loaded = await loadConfigWithSource(directory);
|
|
8163
8740
|
const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
|
|
8164
8741
|
return {
|
|
8165
8742
|
config: loaded?.config ?? null,
|
|
@@ -8756,6 +9333,7 @@ const buildCapabilities = (project) => {
|
|
|
8756
9333
|
const capabilities = /* @__PURE__ */ new Set();
|
|
8757
9334
|
capabilities.add(project.framework);
|
|
8758
9335
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
9336
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
8759
9337
|
const reactMajor = project.reactMajorVersion;
|
|
8760
9338
|
if (reactMajor !== null) {
|
|
8761
9339
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -8927,10 +9505,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
8927
9505
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
8928
9506
|
return fs.realpathSync(rootDirectory);
|
|
8929
9507
|
};
|
|
9508
|
+
const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
|
|
9509
|
+
if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
|
|
9510
|
+
return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
9511
|
+
};
|
|
8930
9512
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
8931
9513
|
const enabledRules = {};
|
|
8932
9514
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
8933
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
9515
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
8934
9516
|
if (severity === "off") continue;
|
|
8935
9517
|
enabledRules[ruleKey] = severity;
|
|
8936
9518
|
}
|
|
@@ -8972,7 +9554,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
8972
9554
|
category: rule.category
|
|
8973
9555
|
}, severityControls);
|
|
8974
9556
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
8975
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
9557
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
8976
9558
|
if (severity === "off") continue;
|
|
8977
9559
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
8978
9560
|
}
|
|
@@ -9029,6 +9611,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
9029
9611
|
}
|
|
9030
9612
|
return uniqueDiagnostics;
|
|
9031
9613
|
};
|
|
9614
|
+
/**
|
|
9615
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
9616
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
9617
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
9618
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
9619
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
9620
|
+
* batches do).
|
|
9621
|
+
*
|
|
9622
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
9623
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
9624
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
9625
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
9626
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
9627
|
+
* top of a still-running first one.
|
|
9628
|
+
*/
|
|
9629
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
9630
|
+
const results = new Array(items.length);
|
|
9631
|
+
if (items.length === 0) return results;
|
|
9632
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
9633
|
+
let nextIndex = 0;
|
|
9634
|
+
const errors = [];
|
|
9635
|
+
const runWorker = async () => {
|
|
9636
|
+
while (errors.length === 0) {
|
|
9637
|
+
const index = nextIndex;
|
|
9638
|
+
nextIndex += 1;
|
|
9639
|
+
if (index >= items.length) return;
|
|
9640
|
+
try {
|
|
9641
|
+
results[index] = await task(items[index], index);
|
|
9642
|
+
} catch (error) {
|
|
9643
|
+
errors.push(error);
|
|
9644
|
+
return;
|
|
9645
|
+
}
|
|
9646
|
+
}
|
|
9647
|
+
};
|
|
9648
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
9649
|
+
if (errors.length > 0) throw errors[0];
|
|
9650
|
+
return results;
|
|
9651
|
+
};
|
|
9032
9652
|
const getPublicEnvPrefix = (framework) => {
|
|
9033
9653
|
switch (framework) {
|
|
9034
9654
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -9711,6 +10331,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
9711
10331
|
*/
|
|
9712
10332
|
const spawnLintBatches = async (input) => {
|
|
9713
10333
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
10334
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
9714
10335
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
9715
10336
|
const allDiagnostics = [];
|
|
9716
10337
|
const droppedFiles = [];
|
|
@@ -9730,23 +10351,31 @@ const spawnLintBatches = async (input) => {
|
|
|
9730
10351
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
9731
10352
|
}
|
|
9732
10353
|
};
|
|
10354
|
+
let startedFileCount = 0;
|
|
9733
10355
|
let scannedFileCount = 0;
|
|
9734
|
-
|
|
9735
|
-
|
|
9736
|
-
const
|
|
9737
|
-
|
|
9738
|
-
|
|
9739
|
-
|
|
9740
|
-
|
|
9741
|
-
|
|
9742
|
-
|
|
10356
|
+
let displayedFileCount = 0;
|
|
10357
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
10358
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
10359
|
+
if (displayedFileCount < ceiling) {
|
|
10360
|
+
displayedFileCount += 1;
|
|
10361
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10362
|
+
}
|
|
10363
|
+
}, 50) : null;
|
|
10364
|
+
progressTimer?.unref?.();
|
|
10365
|
+
try {
|
|
10366
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
10367
|
+
startedFileCount += batch.length;
|
|
9743
10368
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
9744
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
9745
10369
|
scannedFileCount += batch.length;
|
|
9746
|
-
onFileProgress
|
|
9747
|
-
|
|
9748
|
-
|
|
9749
|
-
|
|
10370
|
+
if (onFileProgress) {
|
|
10371
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
10372
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10373
|
+
}
|
|
10374
|
+
return batchDiagnostics;
|
|
10375
|
+
});
|
|
10376
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
10377
|
+
} finally {
|
|
10378
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
9750
10379
|
}
|
|
9751
10380
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
9752
10381
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -9873,7 +10502,8 @@ const runOxlint = async (options) => {
|
|
|
9873
10502
|
onPartialFailure,
|
|
9874
10503
|
onFileProgress: options.onFileProgress,
|
|
9875
10504
|
spawnTimeoutMs,
|
|
9876
|
-
outputMaxBytes
|
|
10505
|
+
outputMaxBytes,
|
|
10506
|
+
concurrency: options.concurrency
|
|
9877
10507
|
});
|
|
9878
10508
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
9879
10509
|
try {
|
|
@@ -9941,6 +10571,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9941
10571
|
const partialFailures = yield* LintPartialFailures;
|
|
9942
10572
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
9943
10573
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
10574
|
+
const concurrency = yield* OxlintConcurrency;
|
|
9944
10575
|
const collectedFailures = [];
|
|
9945
10576
|
const diagnostics = yield* Effect.tryPromise({
|
|
9946
10577
|
try: () => runOxlint({
|
|
@@ -9959,7 +10590,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9959
10590
|
},
|
|
9960
10591
|
onFileProgress: input.onFileProgress,
|
|
9961
10592
|
spawnTimeoutMs,
|
|
9962
|
-
outputMaxBytes
|
|
10593
|
+
outputMaxBytes,
|
|
10594
|
+
concurrency
|
|
9963
10595
|
}),
|
|
9964
10596
|
catch: ensureReactDoctorError
|
|
9965
10597
|
});
|
|
@@ -10283,7 +10915,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10283
10915
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
10284
10916
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
10285
10917
|
const isDiffMode = input.includePaths.length > 0;
|
|
10286
|
-
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ??
|
|
10918
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
10287
10919
|
const transform = buildDiagnosticPipeline({
|
|
10288
10920
|
rootDirectory: scanDirectory,
|
|
10289
10921
|
userConfig: resolvedConfig.config,
|
|
@@ -10292,7 +10924,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10292
10924
|
showWarnings
|
|
10293
10925
|
});
|
|
10294
10926
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
10295
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10927
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10928
|
+
...checkReducedMotion(scanDirectory),
|
|
10929
|
+
...checkPnpmHardening(scanDirectory),
|
|
10930
|
+
...checkExpoProject(scanDirectory, project)
|
|
10931
|
+
];
|
|
10296
10932
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
10297
10933
|
const lintFailure = yield* Ref.make({
|
|
10298
10934
|
didFail: false,
|
|
@@ -10304,6 +10940,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10304
10940
|
didFail: false,
|
|
10305
10941
|
reason: null
|
|
10306
10942
|
});
|
|
10943
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
10944
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
10307
10945
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
10308
10946
|
const scanStartTime = Date.now();
|
|
10309
10947
|
let lastReportedTotalFileCount = 0;
|
|
@@ -10320,7 +10958,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10320
10958
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
10321
10959
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
10322
10960
|
lastReportedTotalFileCount = totalFileCount;
|
|
10323
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
10961
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
10324
10962
|
}
|
|
10325
10963
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
10326
10964
|
yield* Ref.set(lintFailure, {
|
|
@@ -10352,7 +10990,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10352
10990
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
10353
10991
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
10354
10992
|
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
10355
|
-
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
|
|
10993
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
10356
10994
|
yield* reporterService.finalize;
|
|
10357
10995
|
const finalDiagnostics = [
|
|
10358
10996
|
...envCollected,
|
|
@@ -10404,7 +11042,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10404
11042
|
"inspect.isCi": input.isCi,
|
|
10405
11043
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
10406
11044
|
} }));
|
|
10407
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
10408
11045
|
const parseNodeVersion = (versionString) => {
|
|
10409
11046
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
10410
11047
|
return {
|
|
@@ -10727,6 +11364,26 @@ const buildJsonReport = (input) => {
|
|
|
10727
11364
|
};
|
|
10728
11365
|
};
|
|
10729
11366
|
/**
|
|
11367
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
11368
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
11369
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
11370
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
11371
|
+
* branch logic lives here once.
|
|
11372
|
+
*/
|
|
11373
|
+
const buildSkippedChecks = (input) => {
|
|
11374
|
+
const skippedChecks = [];
|
|
11375
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
11376
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
11377
|
+
const skippedCheckReasons = {};
|
|
11378
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
11379
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
11380
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
11381
|
+
return {
|
|
11382
|
+
skippedChecks,
|
|
11383
|
+
skippedCheckReasons
|
|
11384
|
+
};
|
|
11385
|
+
};
|
|
11386
|
+
/**
|
|
10730
11387
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
10731
11388
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
10732
11389
|
* spawn, not `spawnSync`).
|
|
@@ -10831,12 +11488,32 @@ const highlighter = {
|
|
|
10831
11488
|
bold: import_picocolors.default.bold
|
|
10832
11489
|
};
|
|
10833
11490
|
/**
|
|
10834
|
-
*
|
|
10835
|
-
* `
|
|
10836
|
-
*
|
|
10837
|
-
*
|
|
11491
|
+
* Override picocolors' automatic color detection. picocolors decides
|
|
11492
|
+
* once, at import time, from `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY.
|
|
11493
|
+
* This lets the CLI honor an explicit `--color` / `--no-color` flag
|
|
11494
|
+
* (clig.dev, Output: "Disable color … if the user requested it") by
|
|
11495
|
+
* swapping in a fresh set of formatters. Call it before any colored
|
|
11496
|
+
* output is produced. Every call site reads `highlighter.<method>` at
|
|
11497
|
+
* call time, so reassigning the properties propagates everywhere.
|
|
11498
|
+
*/
|
|
11499
|
+
const setColorEnabled = (enabled) => {
|
|
11500
|
+
const colors = import_picocolors.default.createColors(enabled);
|
|
11501
|
+
highlighter.error = colors.red;
|
|
11502
|
+
highlighter.warn = colors.yellow;
|
|
11503
|
+
highlighter.info = colors.cyan;
|
|
11504
|
+
highlighter.success = colors.green;
|
|
11505
|
+
highlighter.dim = colors.dim;
|
|
11506
|
+
highlighter.gray = colors.gray;
|
|
11507
|
+
highlighter.bold = colors.bold;
|
|
11508
|
+
};
|
|
11509
|
+
/**
|
|
11510
|
+
* Canonical URL for a rule's documentation page — its reviewer-tested fix
|
|
11511
|
+
* recipe rendered for humans — served at
|
|
11512
|
+
* `https://www.react.doctor/docs/rules/<plugin>/<rule>`. The CLI links here
|
|
11513
|
+
* from its fix-recipe directive so each fix follows the canonical recipe
|
|
11514
|
+
* instead of being improvised per diagnostic.
|
|
10838
11515
|
*/
|
|
10839
|
-
const
|
|
11516
|
+
const buildRuleDocsUrl = (plugin, rule) => `${DOCS_RULES_BASE_URL}/${plugin}/${rule}`;
|
|
10840
11517
|
const groupBy = (items, keyFn) => {
|
|
10841
11518
|
const groups = /* @__PURE__ */ new Map();
|
|
10842
11519
|
for (const item of items) {
|
|
@@ -10849,8 +11526,8 @@ const groupBy = (items, keyFn) => {
|
|
|
10849
11526
|
};
|
|
10850
11527
|
/**
|
|
10851
11528
|
* Whether a diagnostic's rule has a published per-rule fix recipe at
|
|
10852
|
-
* `${
|
|
10853
|
-
* (see `
|
|
11529
|
+
* `${DOCS_RULES_BASE_URL}/react-doctor/<rule>`
|
|
11530
|
+
* (see `buildRuleDocsUrl`).
|
|
10854
11531
|
*
|
|
10855
11532
|
* Recipes are generated from react-doctor's own engine rules, so only
|
|
10856
11533
|
* those resolve. Dead-code (`deslop`), the synthetic environment and
|
|
@@ -10862,6 +11539,46 @@ const groupBy = (items, keyFn) => {
|
|
|
10862
11539
|
*/
|
|
10863
11540
|
const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
|
|
10864
11541
|
//#endregion
|
|
11542
|
+
//#region src/cli/utils/constants.ts
|
|
11543
|
+
const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
|
|
11544
|
+
const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
|
|
11545
|
+
const SENTRY_DSN = "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920";
|
|
11546
|
+
//#endregion
|
|
11547
|
+
//#region src/cli/utils/version.ts
|
|
11548
|
+
const VERSION = "0.2.14-dev.9777f1a";
|
|
11549
|
+
//#endregion
|
|
11550
|
+
//#region src/instrument.ts
|
|
11551
|
+
let isInitialized = false;
|
|
11552
|
+
const shouldEnableSentry = () => {
|
|
11553
|
+
if (process.argv.includes("--no-score") || process.argv.includes("--no-telemetry")) return false;
|
|
11554
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
|
|
11555
|
+
return true;
|
|
11556
|
+
};
|
|
11557
|
+
/**
|
|
11558
|
+
* Initializes the Sentry Node SDK for CLI crash reporting. Invoked as
|
|
11559
|
+
* the first statement of the CLI entry (`cli/index.ts`) so the SDK's
|
|
11560
|
+
* global `uncaughtException` / `unhandledRejection` handlers are armed
|
|
11561
|
+
* before any command runs.
|
|
11562
|
+
*
|
|
11563
|
+
* Exported as a function rather than a bare side-effecting import
|
|
11564
|
+
* because the package declares `"sideEffects": false`, which lets the
|
|
11565
|
+
* bundler tree-shake side-effect-only modules. An explicit call keeps
|
|
11566
|
+
* the initialization in the published `dist/cli.js`.
|
|
11567
|
+
*
|
|
11568
|
+
* Scoped to the CLI application only — the programmatic
|
|
11569
|
+
* `@react-doctor/api` library never initializes Sentry, so importing
|
|
11570
|
+
* `diagnose()` into a consumer app can't hijack their telemetry.
|
|
11571
|
+
*/
|
|
11572
|
+
const initializeSentry = () => {
|
|
11573
|
+
if (isInitialized || !shouldEnableSentry()) return;
|
|
11574
|
+
isInitialized = true;
|
|
11575
|
+
Sentry.init({
|
|
11576
|
+
dsn: SENTRY_DSN,
|
|
11577
|
+
sendDefaultPii: true,
|
|
11578
|
+
release: VERSION
|
|
11579
|
+
});
|
|
11580
|
+
};
|
|
11581
|
+
//#endregion
|
|
10865
11582
|
//#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
10866
11583
|
const ANSI_BACKGROUND_OFFSET = 10;
|
|
10867
11584
|
const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
|
|
@@ -13815,23 +14532,60 @@ const CI_ENVIRONMENT_VARIABLES = [
|
|
|
13815
14532
|
"GITLAB_CI",
|
|
13816
14533
|
"CIRCLECI"
|
|
13817
14534
|
];
|
|
13818
|
-
const
|
|
13819
|
-
"
|
|
13820
|
-
"
|
|
13821
|
-
"
|
|
13822
|
-
"
|
|
13823
|
-
"
|
|
13824
|
-
"
|
|
13825
|
-
"
|
|
13826
|
-
"
|
|
13827
|
-
"
|
|
13828
|
-
"
|
|
13829
|
-
"
|
|
14535
|
+
const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
|
|
14536
|
+
["GITHUB_ACTIONS", "github-actions"],
|
|
14537
|
+
["GITLAB_CI", "gitlab-ci"],
|
|
14538
|
+
["CIRCLECI", "circleci"],
|
|
14539
|
+
["BUILDKITE", "buildkite"],
|
|
14540
|
+
["JENKINS_URL", "jenkins"],
|
|
14541
|
+
["TF_BUILD", "azure-pipelines"],
|
|
14542
|
+
["CODEBUILD_BUILD_ID", "aws-codebuild"],
|
|
14543
|
+
["TEAMCITY_VERSION", "teamcity"],
|
|
14544
|
+
["BITBUCKET_BUILD_NUMBER", "bitbucket"],
|
|
14545
|
+
["TRAVIS", "travis"],
|
|
14546
|
+
["DRONE", "drone"]
|
|
14547
|
+
];
|
|
14548
|
+
const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
|
|
14549
|
+
["CLAUDECODE", "claude-code"],
|
|
14550
|
+
["CLAUDE_CODE", "claude-code"],
|
|
14551
|
+
["CURSOR_AGENT", "cursor"],
|
|
14552
|
+
["CODEX_CI", "codex"],
|
|
14553
|
+
["CODEX_SANDBOX", "codex"],
|
|
14554
|
+
["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
|
|
14555
|
+
["OPENCODE", "opencode"],
|
|
14556
|
+
["GOOSE_TERMINAL", "goose"],
|
|
14557
|
+
["AMP_THREAD_ID", "amp"]
|
|
13830
14558
|
];
|
|
14559
|
+
const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
|
|
13831
14560
|
const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
|
|
13832
14561
|
const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
|
|
13833
|
-
|
|
13834
|
-
const
|
|
14562
|
+
[...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
|
|
14563
|
+
const FALSY_CI_FLAG_VALUES = new Set([
|
|
14564
|
+
"",
|
|
14565
|
+
"0",
|
|
14566
|
+
"false"
|
|
14567
|
+
]);
|
|
14568
|
+
const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
|
|
14569
|
+
const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
|
|
14570
|
+
const detectCiProvider = () => {
|
|
14571
|
+
for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
|
|
14572
|
+
return isCiFlagSet(process.env.CI) ? "unknown" : null;
|
|
14573
|
+
};
|
|
14574
|
+
const detectCodingAgentFromValue = () => {
|
|
14575
|
+
for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
|
|
14576
|
+
const value = process.env[environmentVariable]?.toLowerCase();
|
|
14577
|
+
if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
|
|
14578
|
+
}
|
|
14579
|
+
return null;
|
|
14580
|
+
};
|
|
14581
|
+
const detectCodingAgent = () => {
|
|
14582
|
+
for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
|
|
14583
|
+
const agentFromValue = detectCodingAgentFromValue();
|
|
14584
|
+
if (agentFromValue) return agentFromValue;
|
|
14585
|
+
if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
|
|
14586
|
+
return null;
|
|
14587
|
+
};
|
|
14588
|
+
const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
|
|
13835
14589
|
const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
|
|
13836
14590
|
//#endregion
|
|
13837
14591
|
//#region src/cli/utils/is-non-interactive-environment.ts
|
|
@@ -13933,9 +14687,8 @@ const buildSpinnerProgressHandle = (text) => {
|
|
|
13933
14687
|
* construction and post-scan rendering — layer wiring is its own
|
|
13934
14688
|
* concern with its own contract.
|
|
13935
14689
|
*
|
|
13936
|
-
* Same shape as
|
|
13937
|
-
*
|
|
13938
|
-
* differences specific to the CLI path:
|
|
14690
|
+
* Same service shape as `@react-doctor/api → diagnose()`'s
|
|
14691
|
+
* `buildDiagnoseLayer`, with the differences specific to the CLI path:
|
|
13939
14692
|
*
|
|
13940
14693
|
* - **Config**: when the caller passes `configOverride`, the
|
|
13941
14694
|
* already-loaded config is provided via `Config.layerOf` instead
|
|
@@ -13961,7 +14714,8 @@ const buildRuntimeLayers = (input) => {
|
|
|
13961
14714
|
resolvedDirectory: input.directory,
|
|
13962
14715
|
configSourceDirectory: input.configSourceDirectory
|
|
13963
14716
|
}) : Config.layerNode;
|
|
13964
|
-
|
|
14717
|
+
const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
|
|
14718
|
+
return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
|
|
13965
14719
|
};
|
|
13966
14720
|
//#endregion
|
|
13967
14721
|
//#region src/cli/utils/noop-console.ts
|
|
@@ -14048,8 +14802,10 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
|
|
|
14048
14802
|
return priorityB - priorityA;
|
|
14049
14803
|
};
|
|
14050
14804
|
const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
|
|
14051
|
-
const
|
|
14052
|
-
const
|
|
14805
|
+
const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
|
|
14806
|
+
const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
|
|
14807
|
+
const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
|
|
14808
|
+
const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
|
|
14053
14809
|
//#endregion
|
|
14054
14810
|
//#region src/cli/utils/box-text.ts
|
|
14055
14811
|
const ESCAPE = String.fromCharCode(27);
|
|
@@ -14180,15 +14936,17 @@ const buildVerboseSiteMap = (diagnostics) => {
|
|
|
14180
14936
|
return fileSites;
|
|
14181
14937
|
};
|
|
14182
14938
|
const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
|
|
14939
|
+
const formatTrailingSiteBadge = (count) => {
|
|
14940
|
+
const badge = formatSiteCountBadge(count);
|
|
14941
|
+
return badge.length > 0 ? ` ${highlighter.gray(badge)}` : "";
|
|
14942
|
+
};
|
|
14183
14943
|
const categoryTopRuleKey = (categoryGroup) => categoryGroup.ruleGroups[0][0];
|
|
14184
14944
|
const buildCategoryDiagnosticGroups = (diagnostics, rulePriority) => {
|
|
14185
|
-
return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
|
|
14186
|
-
|
|
14187
|
-
|
|
14188
|
-
|
|
14189
|
-
|
|
14190
|
-
};
|
|
14191
|
-
}).toSorted((categoryGroupA, categoryGroupB) => {
|
|
14945
|
+
return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => ({
|
|
14946
|
+
category,
|
|
14947
|
+
diagnostics: categoryDiagnostics,
|
|
14948
|
+
ruleGroups: buildSortedRuleGroups(categoryDiagnostics, rulePriority)
|
|
14949
|
+
})).toSorted((categoryGroupA, categoryGroupB) => {
|
|
14192
14950
|
const priorityDelta = compareByRulePriority(categoryTopRuleKey(categoryGroupA), categoryTopRuleKey(categoryGroupB), rulePriority);
|
|
14193
14951
|
if (priorityDelta !== 0) return priorityDelta;
|
|
14194
14952
|
return categoryGroupA.category.localeCompare(categoryGroupB.category);
|
|
@@ -14204,6 +14962,7 @@ const buildCompactCategoryLine = (categoryGroup) => {
|
|
|
14204
14962
|
};
|
|
14205
14963
|
const TOP_ERROR_DETAIL_INDENT = " ";
|
|
14206
14964
|
const pickRepresentativeDiagnostic = (ruleDiagnostics) => ruleDiagnostics.find((diagnostic) => diagnostic.line > 0) ?? ruleDiagnostics[0];
|
|
14965
|
+
const isErrorRuleGroup = (ruleDiagnostics) => pickRepresentativeDiagnostic(ruleDiagnostics).severity === "error";
|
|
14207
14966
|
const FRAME_CONTEXT_REACH_LINES = 3;
|
|
14208
14967
|
const clusterNearbyDiagnostics = (diagnostics) => {
|
|
14209
14968
|
const byFile = groupBy(diagnostics, (diagnostic) => diagnostic.filePath);
|
|
@@ -14235,17 +14994,17 @@ const formatClusterLocation = (cluster) => {
|
|
|
14235
14994
|
if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
|
|
14236
14995
|
return `${filePath}:${cluster.startLine}`;
|
|
14237
14996
|
};
|
|
14238
|
-
const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
|
|
14997
|
+
const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
|
|
14239
14998
|
const lead = cluster.diagnostics[0];
|
|
14240
14999
|
const isMultiSite = cluster.diagnostics.length > 1;
|
|
14241
15000
|
const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
|
|
14242
|
-
const codeFrame = buildCodeFrame({
|
|
15001
|
+
const codeFrame = renderCodeFrame ? buildCodeFrame({
|
|
14243
15002
|
filePath: lead.filePath,
|
|
14244
15003
|
line: cluster.startLine,
|
|
14245
15004
|
column: isMultiSite ? 0 : lead.column,
|
|
14246
15005
|
endLine: isMultiSite ? cluster.endLine : void 0,
|
|
14247
15006
|
rootDirectory: resolveSourceRoot(lead)
|
|
14248
|
-
});
|
|
15007
|
+
}) : null;
|
|
14249
15008
|
if (codeFrame) lines.push(indentMultilineText(boxText(codeFrame, 60), TOP_ERROR_DETAIL_INDENT));
|
|
14250
15009
|
const seenHints = /* @__PURE__ */ new Set();
|
|
14251
15010
|
for (const diagnostic of cluster.diagnostics) if (diagnostic.suppressionHint && !seenHints.has(diagnostic.suppressionHint)) {
|
|
@@ -14257,23 +15016,60 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
|
|
|
14257
15016
|
const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite) => {
|
|
14258
15017
|
const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
|
|
14259
15018
|
const { severity } = representative;
|
|
14260
|
-
const
|
|
14261
|
-
const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
|
|
15019
|
+
const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
|
|
14262
15020
|
const headline = colorizeBySeverity(`${representative.category}: ${representative.title ?? ruleKey}`, severity);
|
|
14263
15021
|
const lines = [` ${colorizeBySeverity(severity === "error" ? "✗" : "⚠", severity)} ${headline}${trailingBadge}`];
|
|
14264
15022
|
if (!renderEverySite) for (const explanationLine of wrapTextToWidth(representative.message, 60, { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
|
|
14265
15023
|
if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, 60, { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
|
|
15024
|
+
const renderCodeFrame = severity === "error";
|
|
14266
15025
|
const sites = renderEverySite ? ruleDiagnostics : [representative];
|
|
14267
|
-
for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot));
|
|
15026
|
+
for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
|
|
15027
|
+
return lines;
|
|
15028
|
+
};
|
|
15029
|
+
const WARNING_DETAIL_INDENT = " ";
|
|
15030
|
+
const computeRuleNameColumnWidth = (ruleKeys) => ruleKeys.reduce((widest, ruleKey) => Math.max(widest, ruleKey.length), 36);
|
|
15031
|
+
const padRuleNameToColumn = (ruleName, columnWidth) => ruleName.length >= columnWidth ? ruleName : ruleName + " ".repeat(columnWidth - ruleName.length);
|
|
15032
|
+
const buildWarningHeaderLine = (ruleKey, siteCount, ruleNameColumnWidth) => {
|
|
15033
|
+
const ruleName = formatSiteCountBadge(siteCount).length > 0 ? padRuleNameToColumn(ruleKey, ruleNameColumnWidth) : ruleKey;
|
|
15034
|
+
return ` ${highlighter.warn("⚠")} ${ruleName}${formatTrailingSiteBadge(siteCount)}`;
|
|
15035
|
+
};
|
|
15036
|
+
const buildWarningRuleBlock = (ruleKey, ruleDiagnostics, ruleNameColumnWidth, isAgentEnvironment) => {
|
|
15037
|
+
const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
|
|
15038
|
+
const lines = [buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth)];
|
|
15039
|
+
if (!isAgentEnvironment) {
|
|
15040
|
+
const learnMoreLine = formatLearnMoreLine(representative);
|
|
15041
|
+
if (learnMoreLine) lines.push(`${WARNING_DETAIL_INDENT}${highlighter.info(learnMoreLine)}`);
|
|
15042
|
+
}
|
|
15043
|
+
lines.push(highlighter.gray(indentMultilineText(representative.message, WARNING_DETAIL_INDENT)));
|
|
15044
|
+
if (representative.help) lines.push(highlighter.gray(indentMultilineText(`→ ${representative.help}`, WARNING_DETAIL_INDENT)));
|
|
15045
|
+
if (isAgentEnvironment) {
|
|
15046
|
+
const fixRecipeLine = formatFixRecipeLine(representative);
|
|
15047
|
+
if (fixRecipeLine) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${fixRecipeLine}`));
|
|
15048
|
+
}
|
|
15049
|
+
for (const [filePath, sites] of buildVerboseSiteMap(ruleDiagnostics)) {
|
|
15050
|
+
if (sites.length === 0) {
|
|
15051
|
+
lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}`));
|
|
15052
|
+
continue;
|
|
15053
|
+
}
|
|
15054
|
+
for (const site of sites) {
|
|
15055
|
+
lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}:${site.line}`));
|
|
15056
|
+
if (site.suppressionHint) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT} ↳ ${site.suppressionHint}`));
|
|
15057
|
+
}
|
|
15058
|
+
}
|
|
14268
15059
|
return lines;
|
|
14269
15060
|
};
|
|
14270
|
-
const
|
|
14271
|
-
|
|
15061
|
+
const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
|
|
15062
|
+
const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => selectErrorRuleGroups(diagnostics, rulePriority).slice(0, limit);
|
|
15063
|
+
const buildMoreRulesLine = (hiddenRuleCount, severityNoun, accent) => {
|
|
15064
|
+
const ruleNoun = hiddenRuleCount === 1 ? "rule" : "rules";
|
|
15065
|
+
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`)}`;
|
|
14272
15066
|
};
|
|
14273
15067
|
const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
|
|
14274
15068
|
const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
|
|
14275
|
-
const
|
|
15069
|
+
const errorRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority);
|
|
15070
|
+
const topRuleGroups = errorRuleGroups.slice(0, 3);
|
|
14276
15071
|
if (topRuleGroups.length === 0) return [];
|
|
15072
|
+
const hiddenRuleCount = errorRuleGroups.length - topRuleGroups.length;
|
|
14277
15073
|
const lines = [
|
|
14278
15074
|
highlighter.dim(` ${"─".repeat(60)}`),
|
|
14279
15075
|
` ${highlighter.bold(`Top ${topRuleGroups.length} ${topRuleGroups.length === 1 ? "error" : "errors"} you should fix`)}`,
|
|
@@ -14283,12 +15079,29 @@ const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
|
|
|
14283
15079
|
lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false));
|
|
14284
15080
|
lines.push("");
|
|
14285
15081
|
}
|
|
15082
|
+
if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "errors", highlighter.error));
|
|
14286
15083
|
return lines;
|
|
14287
15084
|
};
|
|
14288
|
-
const
|
|
14289
|
-
const
|
|
14290
|
-
|
|
14291
|
-
|
|
15085
|
+
const buildWarningsListLines = (diagnostics, rulePriority) => {
|
|
15086
|
+
const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
|
|
15087
|
+
if (warningDiagnostics.length === 0) return [];
|
|
15088
|
+
const sortedRuleGroups = buildSortedRuleGroups(warningDiagnostics, rulePriority);
|
|
15089
|
+
const shownRuleGroups = sortedRuleGroups.slice(0, 10);
|
|
15090
|
+
const hiddenRuleCount = sortedRuleGroups.length - shownRuleGroups.length;
|
|
15091
|
+
const ruleNameColumnWidth = computeRuleNameColumnWidth(shownRuleGroups.map(([ruleKey]) => ruleKey));
|
|
15092
|
+
const lines = [
|
|
15093
|
+
highlighter.dim(` ${"─".repeat(60)}`),
|
|
15094
|
+
` ${highlighter.bold(`${warningDiagnostics.length} ${warningDiagnostics.length === 1 ? "warning" : "warnings"}`)}`,
|
|
15095
|
+
""
|
|
15096
|
+
];
|
|
15097
|
+
for (const [ruleKey, ruleDiagnostics] of shownRuleGroups) lines.push(buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth));
|
|
15098
|
+
if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "warnings", highlighter.warn));
|
|
15099
|
+
return lines;
|
|
15100
|
+
};
|
|
15101
|
+
const buildCategoryBreakdownLines = (diagnostics, rulePriority) => buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCompactCategoryLine);
|
|
15102
|
+
const joinSections = (...sections) => {
|
|
15103
|
+
const lines = [];
|
|
15104
|
+
for (const section of sections) {
|
|
14292
15105
|
if (section.length === 0) continue;
|
|
14293
15106
|
if (lines.length > 0) lines.push("");
|
|
14294
15107
|
lines.push(...section);
|
|
@@ -14309,12 +15122,18 @@ const buildCountsSummaryLines = (diagnostics) => {
|
|
|
14309
15122
|
* single Effect.forEach over Console.log so failures or fiber
|
|
14310
15123
|
* interruption produce predictable partial output.
|
|
14311
15124
|
*/
|
|
14312
|
-
const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority) => Effect.gen(function* () {
|
|
15125
|
+
const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false) => Effect.gen(function* () {
|
|
14313
15126
|
const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
|
|
14314
15127
|
let detailLines;
|
|
14315
15128
|
if (!isVerbose) detailLines = buildTopErrorsLines(diagnostics, resolveSourceRoot, rulePriority);
|
|
14316
|
-
else
|
|
14317
|
-
|
|
15129
|
+
else {
|
|
15130
|
+
const sortedRuleGroups = buildSortedRuleGroups(diagnostics, rulePriority);
|
|
15131
|
+
const warningRuleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.filter(([, ruleDiagnostics]) => !isErrorRuleGroup(ruleDiagnostics)).map(([ruleKey]) => ruleKey));
|
|
15132
|
+
detailLines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => {
|
|
15133
|
+
return [...isErrorRuleGroup(ruleDiagnostics) ? buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true) : buildWarningRuleBlock(ruleKey, ruleDiagnostics, warningRuleNameColumnWidth, isAgentEnvironment), ""];
|
|
15134
|
+
});
|
|
15135
|
+
}
|
|
15136
|
+
const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines, isVerbose ? [] : buildWarningsListLines(diagnostics, rulePriority));
|
|
14318
15137
|
for (const line of lines) yield* Console.log(line);
|
|
14319
15138
|
});
|
|
14320
15139
|
const formatElapsedTime = (elapsedMilliseconds) => {
|
|
@@ -14364,10 +15183,6 @@ const colorizeByScore = (text, score) => {
|
|
|
14364
15183
|
return highlighter.error(text);
|
|
14365
15184
|
};
|
|
14366
15185
|
//#endregion
|
|
14367
|
-
//#region src/cli/utils/constants.ts
|
|
14368
|
-
const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
|
|
14369
|
-
const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
|
|
14370
|
-
//#endregion
|
|
14371
15186
|
//#region src/cli/utils/render-score-header.ts
|
|
14372
15187
|
const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
|
|
14373
15188
|
const RAINBOW_GRADIENT_WIDTH = 80;
|
|
@@ -14560,8 +15375,7 @@ const printNoScoreHeader = (noScoreMessage) => Effect.gen(function* () {
|
|
|
14560
15375
|
const writeDiagnosticsDirectory = (diagnostics) => {
|
|
14561
15376
|
const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
|
|
14562
15377
|
mkdirSync(outputDirectory, { recursive: true });
|
|
14563
|
-
const
|
|
14564
|
-
for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
|
|
15378
|
+
for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(diagnostics)) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
|
|
14565
15379
|
writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
|
|
14566
15380
|
return outputDirectory;
|
|
14567
15381
|
};
|
|
@@ -14581,7 +15395,14 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
|
|
|
14581
15395
|
};
|
|
14582
15396
|
const printVerboseTip = (diagnostics, isVerbose) => Effect.gen(function* () {
|
|
14583
15397
|
if (isVerbose || diagnostics.length === 0) return;
|
|
14584
|
-
|
|
15398
|
+
const command = highlighter.info("npx react-doctor@latest --verbose");
|
|
15399
|
+
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`;
|
|
15400
|
+
yield* Console.log(highlighter.dim(` Tip: ${message}`));
|
|
15401
|
+
});
|
|
15402
|
+
const printDocsNote = () => Effect.gen(function* () {
|
|
15403
|
+
yield* Console.log("");
|
|
15404
|
+
yield* Console.log(` ${highlighter.bold("Docs:")} ${highlighter.info(DOCS_URL)}`);
|
|
15405
|
+
yield* Console.log(highlighter.dim(" Set up CI/CD, suppress rules with a config file, and scan diffs or PRs."));
|
|
14585
15406
|
});
|
|
14586
15407
|
const printSummary = (input) => Effect.gen(function* () {
|
|
14587
15408
|
if (input.scoreResult) {
|
|
@@ -14752,9 +15573,6 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
|
|
|
14752
15573
|
});
|
|
14753
15574
|
const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
|
|
14754
15575
|
//#endregion
|
|
14755
|
-
//#region src/cli/utils/version.ts
|
|
14756
|
-
const VERSION = "0.2.14-dev.8b313ba";
|
|
14757
|
-
//#endregion
|
|
14758
15576
|
//#region src/inspect.ts
|
|
14759
15577
|
const silentConsole = makeNoopConsole();
|
|
14760
15578
|
const runConsole = (effect) => {
|
|
@@ -14779,11 +15597,12 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
14779
15597
|
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
14780
15598
|
share: userConfig?.share ?? true,
|
|
14781
15599
|
respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
|
|
14782
|
-
warnings: inputOptions.warnings ?? userConfig?.warnings ??
|
|
15600
|
+
warnings: inputOptions.warnings ?? userConfig?.warnings ?? true,
|
|
14783
15601
|
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
14784
15602
|
ignoredTags: buildIgnoredTags(userConfig),
|
|
14785
15603
|
outputSurface: inputOptions.outputSurface ?? "cli",
|
|
14786
|
-
suppressRendering: inputOptions.suppressRendering ?? false
|
|
15604
|
+
suppressRendering: inputOptions.suppressRendering ?? false,
|
|
15605
|
+
concurrency: inputOptions.concurrency
|
|
14787
15606
|
});
|
|
14788
15607
|
const inspect = async (directory, inputOptions = {}) => {
|
|
14789
15608
|
const startTime = performance.now();
|
|
@@ -14796,7 +15615,7 @@ const inspect = async (directory, inputOptions = {}) => {
|
|
|
14796
15615
|
userConfig = inputOptions.configOverride ?? null;
|
|
14797
15616
|
configSourceDirectory = null;
|
|
14798
15617
|
} else {
|
|
14799
|
-
const scanTarget = resolveScanTarget(directory);
|
|
15618
|
+
const scanTarget = await resolveScanTarget(directory);
|
|
14800
15619
|
scanDirectory = scanTarget.resolvedDirectory;
|
|
14801
15620
|
userConfig = scanTarget.userConfig;
|
|
14802
15621
|
configSourceDirectory = scanTarget.configSourceDirectory;
|
|
@@ -14823,7 +15642,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14823
15642
|
shouldSkipLint: !options.lint || lintBindingMissing,
|
|
14824
15643
|
shouldRunDeadCode: options.deadCode,
|
|
14825
15644
|
shouldComputeScore: !options.noScore,
|
|
14826
|
-
shouldShowProgressSpinners
|
|
15645
|
+
shouldShowProgressSpinners,
|
|
15646
|
+
oxlintConcurrency: options.concurrency
|
|
14827
15647
|
});
|
|
14828
15648
|
const program = runInspect({
|
|
14829
15649
|
directory,
|
|
@@ -14879,15 +15699,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14879
15699
|
};
|
|
14880
15700
|
const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
14881
15701
|
const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
|
|
14882
|
-
const skippedChecks =
|
|
14883
|
-
|
|
14884
|
-
|
|
15702
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
|
|
15703
|
+
didLintFail,
|
|
15704
|
+
lintFailureReason,
|
|
15705
|
+
lintPartialFailures,
|
|
15706
|
+
didDeadCodeFail,
|
|
15707
|
+
deadCodeFailureReason
|
|
15708
|
+
});
|
|
14885
15709
|
const hasSkippedChecks = skippedChecks.length > 0;
|
|
14886
15710
|
const noScoreMessage = buildNoScoreMessage(options.noScore);
|
|
14887
|
-
const skippedCheckReasons = {};
|
|
14888
|
-
if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
|
|
14889
|
-
else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
|
|
14890
|
-
if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
|
|
14891
15711
|
const buildResult = () => ({
|
|
14892
15712
|
diagnostics: [...diagnostics],
|
|
14893
15713
|
score,
|
|
@@ -14923,7 +15743,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
14923
15743
|
return buildResult();
|
|
14924
15744
|
}
|
|
14925
15745
|
yield* Console.log("");
|
|
14926
|
-
yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]));
|
|
15746
|
+
yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment());
|
|
14927
15747
|
if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
|
|
14928
15748
|
if (demotedDiagnosticCount > 0) {
|
|
14929
15749
|
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.`));
|
|
@@ -14948,6 +15768,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
14948
15768
|
yield* Console.warn(highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`));
|
|
14949
15769
|
}
|
|
14950
15770
|
yield* printVerboseTip([...surfaceDiagnostics], options.verbose);
|
|
15771
|
+
yield* printDocsNote();
|
|
14951
15772
|
return buildResult();
|
|
14952
15773
|
});
|
|
14953
15774
|
//#endregion
|
|
@@ -15041,6 +15862,7 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
|
|
|
15041
15862
|
yield* Console.error("");
|
|
15042
15863
|
yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
|
|
15043
15864
|
yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
|
|
15865
|
+
yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
|
|
15044
15866
|
yield* Console.error("");
|
|
15045
15867
|
yield* Console.error(highlighter.error(formatErrorForReport(error)));
|
|
15046
15868
|
yield* Console.error("");
|
|
@@ -15058,7 +15880,7 @@ const handleError = (error, options = { shouldExit: true }) => {
|
|
|
15058
15880
|
//#endregion
|
|
15059
15881
|
//#region src/cli/utils/build-handoff-payload.ts
|
|
15060
15882
|
const buildHandoffPayload = (input) => {
|
|
15061
|
-
const topGroups =
|
|
15883
|
+
const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
|
|
15062
15884
|
let diagnosticsDirectory = null;
|
|
15063
15885
|
try {
|
|
15064
15886
|
diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
|
|
@@ -15301,7 +16123,7 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
|
|
|
15301
16123
|
const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
|
|
15302
16124
|
const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
|
|
15303
16125
|
const CURSOR_HOOKS_SCHEMA_VERSION = 1;
|
|
15304
|
-
const JSON_INDENT_SPACES = 2;
|
|
16126
|
+
const JSON_INDENT_SPACES$1 = 2;
|
|
15305
16127
|
const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
|
|
15306
16128
|
const readJsonFile = (filePath, fallback) => {
|
|
15307
16129
|
if (!existsSync(filePath)) return fallback;
|
|
@@ -15311,7 +16133,7 @@ const readJsonFile = (filePath, fallback) => {
|
|
|
15311
16133
|
};
|
|
15312
16134
|
const writeJsonFile = (filePath, value) => {
|
|
15313
16135
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
15314
|
-
writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES)}\n`);
|
|
16136
|
+
writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
|
|
15315
16137
|
};
|
|
15316
16138
|
const writeHookScript = (filePath) => {
|
|
15317
16139
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -16077,6 +16899,29 @@ const getSkillSourceDirectory = () => {
|
|
|
16077
16899
|
const distDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
16078
16900
|
return path.join(distDirectory, "skills", SKILL_NAME);
|
|
16079
16901
|
};
|
|
16902
|
+
const findBundledSiblingSkills = (primarySkillDir) => {
|
|
16903
|
+
const skillsParent = path.dirname(primarySkillDir);
|
|
16904
|
+
if (!existsSync(skillsParent)) return [];
|
|
16905
|
+
const resolvedPrimary = path.resolve(primarySkillDir);
|
|
16906
|
+
return readdirSync(skillsParent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => ({
|
|
16907
|
+
name: entry.name,
|
|
16908
|
+
source: path.join(skillsParent, entry.name)
|
|
16909
|
+
})).filter((sibling) => path.resolve(sibling.source) !== resolvedPrimary && existsSync(path.join(sibling.source, SKILL_MANIFEST_FILE)));
|
|
16910
|
+
};
|
|
16911
|
+
const installBundledSiblingSkills = async (primarySkillDir, agents, projectRoot) => {
|
|
16912
|
+
const installedSkillNames = [];
|
|
16913
|
+
for (const sibling of findBundledSiblingSkills(primarySkillDir)) {
|
|
16914
|
+
const result = await installSkillsFromSource({
|
|
16915
|
+
source: sibling.source,
|
|
16916
|
+
agents: [...agents],
|
|
16917
|
+
cwd: projectRoot,
|
|
16918
|
+
mode: "copy"
|
|
16919
|
+
});
|
|
16920
|
+
if (result.failed.length > 0) throw new Error(result.failed.map((failure) => `${getSkillAgentConfig(failure.agent).displayName}: ${failure.error}`).join("\n"));
|
|
16921
|
+
if (result.skills.length > 0) installedSkillNames.push(sibling.name);
|
|
16922
|
+
}
|
|
16923
|
+
return installedSkillNames;
|
|
16924
|
+
};
|
|
16080
16925
|
const canInstallNativeAgentHooks = (agents) => agents.some((agent) => agent === "claude-code" || agent === "cursor");
|
|
16081
16926
|
const buildWorkflowContent = () => [
|
|
16082
16927
|
"name: React Doctor",
|
|
@@ -16183,6 +17028,7 @@ const runInstallReactDoctor = async (options = {}) => {
|
|
|
16183
17028
|
cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
|
|
16184
17029
|
for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
|
|
16185
17030
|
cliLogger.dim(` Source: ${sourceDir}`);
|
|
17031
|
+
for (const sibling of findBundledSiblingSkills(sourceDir)) cliLogger.dim(` Also installs skill: ${sibling.name}`);
|
|
16186
17032
|
cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
|
|
16187
17033
|
cliLogger.dim(" Dev dependency: react-doctor");
|
|
16188
17034
|
if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
|
|
@@ -16205,6 +17051,12 @@ const runInstallReactDoctor = async (options = {}) => {
|
|
|
16205
17051
|
installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
|
|
16206
17052
|
throw error;
|
|
16207
17053
|
}
|
|
17054
|
+
try {
|
|
17055
|
+
const installedSiblingSkills = await installBundledSiblingSkills(sourceDir, selectedAgents, projectRoot);
|
|
17056
|
+
if (installedSiblingSkills.length > 0) cliLogger.dim(` Also installed the ${installedSiblingSkills.join(", ")} skill.`);
|
|
17057
|
+
} catch {
|
|
17058
|
+
cliLogger.dim(" Skipped bundled sibling skills (install error).");
|
|
17059
|
+
}
|
|
16208
17060
|
await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
|
|
16209
17061
|
if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
|
|
16210
17062
|
const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
|
|
@@ -16390,6 +17242,70 @@ const handoffToAgent = async (input) => {
|
|
|
16390
17242
|
}
|
|
16391
17243
|
};
|
|
16392
17244
|
//#endregion
|
|
17245
|
+
//#region src/cli/utils/read-object-file.ts
|
|
17246
|
+
/**
|
|
17247
|
+
* Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
|
|
17248
|
+
* unparseable, or not an object. JSON5 parsing tolerates comments and
|
|
17249
|
+
* trailing commas so hand-edited config files round-trip.
|
|
17250
|
+
*/
|
|
17251
|
+
const readObjectFile = (filePath) => {
|
|
17252
|
+
try {
|
|
17253
|
+
const parsed = parseJSON5(readFileSync(filePath, "utf-8"));
|
|
17254
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
17255
|
+
} catch {
|
|
17256
|
+
return null;
|
|
17257
|
+
}
|
|
17258
|
+
};
|
|
17259
|
+
//#endregion
|
|
17260
|
+
//#region src/cli/utils/serialize-ts-object-literal.ts
|
|
17261
|
+
const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
17262
|
+
const INDENT_UNIT = " ";
|
|
17263
|
+
const serializeKey = (key) => SAFE_IDENTIFIER_PATTERN.test(key) ? key : JSON.stringify(key);
|
|
17264
|
+
/**
|
|
17265
|
+
* Serializes a JSON-compatible value as an idiomatic TypeScript literal:
|
|
17266
|
+
* identifier-shaped object keys stay unquoted, two-space indented, no blank
|
|
17267
|
+
* lines. Intended for JSON-sourced config values (string / number / boolean /
|
|
17268
|
+
* null / array / plain object); any other type falls back to its JSON form.
|
|
17269
|
+
*/
|
|
17270
|
+
const serializeTsObjectLiteral = (value, depth = 0) => {
|
|
17271
|
+
const indent = INDENT_UNIT.repeat(depth);
|
|
17272
|
+
const childIndent = INDENT_UNIT.repeat(depth + 1);
|
|
17273
|
+
if (Array.isArray(value)) {
|
|
17274
|
+
if (value.length === 0) return "[]";
|
|
17275
|
+
return `[\n${value.map((item) => `${childIndent}${serializeTsObjectLiteral(item, depth + 1)}`).join(",\n")}\n${indent}]`;
|
|
17276
|
+
}
|
|
17277
|
+
if (isPlainObject(value)) {
|
|
17278
|
+
const keys = Object.keys(value);
|
|
17279
|
+
if (keys.length === 0) return "{}";
|
|
17280
|
+
return `{\n${keys.map((key) => `${childIndent}${serializeKey(key)}: ${serializeTsObjectLiteral(value[key], depth + 1)}`).join(",\n")}\n${indent}}`;
|
|
17281
|
+
}
|
|
17282
|
+
return JSON.stringify(value);
|
|
17283
|
+
};
|
|
17284
|
+
//#endregion
|
|
17285
|
+
//#region src/cli/utils/migrate-legacy-config.ts
|
|
17286
|
+
const MIGRATED_CONFIG_FILENAME = "doctor.config.ts";
|
|
17287
|
+
/**
|
|
17288
|
+
* Renames a pre-migration `react-doctor.config.json` to a typed
|
|
17289
|
+
* `doctor.config.ts`, preserving the user's settings as the default export.
|
|
17290
|
+
* `$schema` is dropped — the `ReactDoctorConfig` type supersedes it for
|
|
17291
|
+
* editor autocomplete. Returns the new file's absolute path, or `null` when
|
|
17292
|
+
* the legacy file can't be parsed as an object (left untouched so the user
|
|
17293
|
+
* can resolve it by hand).
|
|
17294
|
+
*/
|
|
17295
|
+
const migrateLegacyConfig = (legacy) => {
|
|
17296
|
+
const parsed = readObjectFile(legacy.legacyFilePath);
|
|
17297
|
+
if (!parsed) return null;
|
|
17298
|
+
const config = { ...parsed };
|
|
17299
|
+
delete config.$schema;
|
|
17300
|
+
const targetPath = path.join(legacy.directory, MIGRATED_CONFIG_FILENAME);
|
|
17301
|
+
writeFileSync(targetPath, `import type { ReactDoctorConfig } from "react-doctor/api";
|
|
17302
|
+
|
|
17303
|
+
export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
|
|
17304
|
+
`);
|
|
17305
|
+
rmSync(legacy.legacyFilePath, { force: true });
|
|
17306
|
+
return targetPath;
|
|
17307
|
+
};
|
|
17308
|
+
//#endregion
|
|
16393
17309
|
//#region src/cli/utils/json-mode.ts
|
|
16394
17310
|
let context = null;
|
|
16395
17311
|
/**
|
|
@@ -16490,6 +17406,78 @@ const printBrandedHeader = Effect.gen(function* () {
|
|
|
16490
17406
|
yield* Console.log("");
|
|
16491
17407
|
});
|
|
16492
17408
|
//#endregion
|
|
17409
|
+
//#region src/cli/utils/build-run-context.ts
|
|
17410
|
+
const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
|
|
17411
|
+
const detectOrigin = () => {
|
|
17412
|
+
if (process.env.GIT_DIR) return "git-hook";
|
|
17413
|
+
if (isCodingAgentEnvironment()) return "agent";
|
|
17414
|
+
if (isCiEnvironment()) return "ci";
|
|
17415
|
+
return "cli";
|
|
17416
|
+
};
|
|
17417
|
+
const detectCommand = (userArguments) => {
|
|
17418
|
+
for (const argument of userArguments) {
|
|
17419
|
+
if (argument === "--") break;
|
|
17420
|
+
if (argument.startsWith("-")) continue;
|
|
17421
|
+
return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
|
|
17422
|
+
}
|
|
17423
|
+
return "inspect";
|
|
17424
|
+
};
|
|
17425
|
+
/**
|
|
17426
|
+
* Snapshot of the current invocation, attached to Sentry events as the
|
|
17427
|
+
* `run` context to make crashes triage-able (which version, platform,
|
|
17428
|
+
* CI/agent, how it was invoked). Every field is cheap, synchronous, and
|
|
17429
|
+
* safe to read at any point — cwd reads fall back, env reads are
|
|
17430
|
+
* booleans — so it's rebuilt lazily at capture time when runtime-only
|
|
17431
|
+
* signals like `jsonMode` are finally known.
|
|
17432
|
+
*/
|
|
17433
|
+
const buildRunContext = () => {
|
|
17434
|
+
const userArguments = process.argv.slice(2);
|
|
17435
|
+
return {
|
|
17436
|
+
version: VERSION,
|
|
17437
|
+
origin: detectOrigin(),
|
|
17438
|
+
command: detectCommand(userArguments),
|
|
17439
|
+
argv: userArguments.join(" "),
|
|
17440
|
+
cwd: process.cwd(),
|
|
17441
|
+
node: process.version,
|
|
17442
|
+
platform: process.platform,
|
|
17443
|
+
arch: process.arch,
|
|
17444
|
+
ci: isCiEnvironment(),
|
|
17445
|
+
ciProvider: detectCiProvider(),
|
|
17446
|
+
codingAgent: detectCodingAgent(),
|
|
17447
|
+
interactive: !isNonInteractiveEnvironment(),
|
|
17448
|
+
jsonMode: isJsonModeActive()
|
|
17449
|
+
};
|
|
17450
|
+
};
|
|
17451
|
+
//#endregion
|
|
17452
|
+
//#region src/cli/utils/report-error.ts
|
|
17453
|
+
/**
|
|
17454
|
+
* Sends an error to Sentry, enriched with a snapshot of the current run
|
|
17455
|
+
* (version, platform, CI/agent, invocation), and waits for delivery
|
|
17456
|
+
* before the caller exits. The CLI tears down the process synchronously
|
|
17457
|
+
* after rendering an error, so the awaited `flush` is what actually gets
|
|
17458
|
+
* the event off the machine (see the Sentry CLI/serverless flush
|
|
17459
|
+
* contract).
|
|
17460
|
+
*
|
|
17461
|
+
* Returns early when Sentry was never initialized (`--no-score`, tests,
|
|
17462
|
+
* or a missing DSN), and swallows any transport failure so telemetry can
|
|
17463
|
+
* never mask the user's original error.
|
|
17464
|
+
*/
|
|
17465
|
+
const reportErrorToSentry = async (error) => {
|
|
17466
|
+
if (!Sentry.isInitialized()) return;
|
|
17467
|
+
try {
|
|
17468
|
+
const runContext = buildRunContext();
|
|
17469
|
+
Sentry.setContext("run", { ...runContext });
|
|
17470
|
+
Sentry.setTags({
|
|
17471
|
+
origin: runContext.origin,
|
|
17472
|
+
command: runContext.command,
|
|
17473
|
+
ciProvider: runContext.ciProvider,
|
|
17474
|
+
codingAgent: runContext.codingAgent
|
|
17475
|
+
});
|
|
17476
|
+
Sentry.captureException(error);
|
|
17477
|
+
await Sentry.flush(2e3);
|
|
17478
|
+
} catch {}
|
|
17479
|
+
};
|
|
17480
|
+
//#endregion
|
|
16493
17481
|
//#region src/cli/utils/path-format.ts
|
|
16494
17482
|
const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
|
|
16495
17483
|
//#endregion
|
|
@@ -16557,7 +17545,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
|
|
|
16557
17545
|
yield* Console.log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalScanElapsedMilliseconds)}`);
|
|
16558
17546
|
if (surfaceDiagnostics.length > 0) {
|
|
16559
17547
|
yield* Console.log("");
|
|
16560
|
-
yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)));
|
|
17548
|
+
yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment());
|
|
16561
17549
|
}
|
|
16562
17550
|
const lowestScoredScan = findLowestScoredScan(completedScans);
|
|
16563
17551
|
const aggregateScore = lowestScoredScan?.result.score ?? null;
|
|
@@ -16587,6 +17575,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
|
|
|
16587
17575
|
for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
|
|
16588
17576
|
yield* Console.log("");
|
|
16589
17577
|
yield* printVerboseTip(surfaceDiagnostics, verbose);
|
|
17578
|
+
yield* printDocsNote();
|
|
16590
17579
|
});
|
|
16591
17580
|
//#endregion
|
|
16592
17581
|
//#region src/cli/utils/prompt-install-setup.ts
|
|
@@ -16634,6 +17623,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
|
|
|
16634
17623
|
for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
|
|
16635
17624
|
};
|
|
16636
17625
|
//#endregion
|
|
17626
|
+
//#region src/cli/utils/resolve-parallel-flag.ts
|
|
17627
|
+
/**
|
|
17628
|
+
* Translates the `--experimental-parallel [workers]` flag into a concrete
|
|
17629
|
+
* worker count for `InspectOptions.concurrency`:
|
|
17630
|
+
*
|
|
17631
|
+
* - flag absent (`undefined`) → `undefined` (defer to the ambient
|
|
17632
|
+
* default: serial unless `REACT_DOCTOR_PARALLEL` is set)
|
|
17633
|
+
* - bare flag / `auto` → auto-detect CPU cores
|
|
17634
|
+
* - `--experimental-parallel <n>` → `n` workers (clamped)
|
|
17635
|
+
* - `false` / `off` / `0` → serial (an explicit opt-out, so
|
|
17636
|
+
* it overrides an env-enabled default rather than deferring to it)
|
|
17637
|
+
* - an unparseable value → auto-detect cores
|
|
17638
|
+
*
|
|
17639
|
+
* Commander yields `true` for a bare flag, the raw string for an explicit
|
|
17640
|
+
* value, and `undefined` when the flag is omitted.
|
|
17641
|
+
*/
|
|
17642
|
+
const resolveParallelFlag = (parallel) => {
|
|
17643
|
+
if (parallel === void 0) return void 0;
|
|
17644
|
+
if (parallel === true) return resolveScanConcurrency("auto");
|
|
17645
|
+
if (parallel === false) return 1;
|
|
17646
|
+
const normalized = parallel.trim().toLowerCase();
|
|
17647
|
+
if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
|
|
17648
|
+
if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
|
|
17649
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
17650
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
|
|
17651
|
+
return resolveScanConcurrency(parsed);
|
|
17652
|
+
};
|
|
17653
|
+
//#endregion
|
|
16637
17654
|
//#region src/cli/utils/resolve-cli-inspect-options.ts
|
|
16638
17655
|
/**
|
|
16639
17656
|
* Translates CLI flags into the `InspectOptions` contract `inspect()`
|
|
@@ -16656,10 +17673,11 @@ const resolveCliInspectOptions = (flags, userConfig) => {
|
|
|
16656
17673
|
respectInlineDisables: flags.respectInlineDisables,
|
|
16657
17674
|
warnings: flags.warnings ?? (wantsWarningGate ? true : void 0),
|
|
16658
17675
|
scoreOnly: flags.score === true,
|
|
16659
|
-
noScore: flags.score === false || (userConfig?.noScore ?? false),
|
|
17676
|
+
noScore: flags.score === false || flags.telemetry === false || (userConfig?.noScore ?? false),
|
|
16660
17677
|
isCi: isCiEnvironment(),
|
|
16661
17678
|
silent: Boolean(flags.json),
|
|
16662
|
-
outputSurface: flags.prComment ? "prComment" : "cli"
|
|
17679
|
+
outputSurface: flags.prComment ? "prComment" : "cli",
|
|
17680
|
+
concurrency: resolveParallelFlag(flags.experimentalParallel)
|
|
16663
17681
|
};
|
|
16664
17682
|
};
|
|
16665
17683
|
//#endregion
|
|
@@ -16818,11 +17836,9 @@ const parseFileLineArgument = (rawArgument) => {
|
|
|
16818
17836
|
//#endregion
|
|
16819
17837
|
//#region src/cli/utils/select-projects.ts
|
|
16820
17838
|
const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
17839
|
+
const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json"));
|
|
16821
17840
|
let packages = listWorkspacePackages(rootDirectory);
|
|
16822
|
-
if (packages.length === 0)
|
|
16823
|
-
if (!isMonorepoRoot(rootDirectory)) return [rootDirectory];
|
|
16824
|
-
packages = discoverReactSubprojects(rootDirectory);
|
|
16825
|
-
}
|
|
17841
|
+
if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) packages = discoverReactSubprojects(rootDirectory);
|
|
16826
17842
|
if (packages.length === 0) return [rootDirectory];
|
|
16827
17843
|
if (packages.length === 1) {
|
|
16828
17844
|
cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages[0].name}`);
|
|
@@ -16926,6 +17942,7 @@ const validateModeFlags = (flags) => {
|
|
|
16926
17942
|
if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
|
|
16927
17943
|
if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
|
|
16928
17944
|
if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
|
|
17945
|
+
if (flags.score && flags.telemetry === false) throw new Error("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
|
|
16929
17946
|
if (flags.prComment && (flags.json || flags.score)) throw new Error("--pr-comment cannot be combined with --json or --score.");
|
|
16930
17947
|
if (flags.annotations && flags.score) throw new Error("--annotations cannot be combined with --score.");
|
|
16931
17948
|
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.");
|
|
@@ -16959,6 +17976,24 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
|
|
|
16959
17976
|
changedFiles,
|
|
16960
17977
|
isCurrentChanges: false
|
|
16961
17978
|
});
|
|
17979
|
+
/**
|
|
17980
|
+
* On an interactive human run, rename a pre-migration
|
|
17981
|
+
* `react-doctor.config.json` to `doctor.config.ts` before config is loaded,
|
|
17982
|
+
* so the scan reads the renamed file and the user is told once. CI, coding
|
|
17983
|
+
* agents, JSON/score output, pre-commit (`--staged`) hooks, and non-TTY runs
|
|
17984
|
+
* are left untouched — the loader's warning still nudges them — so a scan
|
|
17985
|
+
* never mutates the repo unattended.
|
|
17986
|
+
*/
|
|
17987
|
+
const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
|
|
17988
|
+
if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
|
|
17989
|
+
const legacyConfig = findLegacyConfig(requestedDirectory);
|
|
17990
|
+
if (!legacyConfig) return;
|
|
17991
|
+
const migratedPath = migrateLegacyConfig(legacyConfig);
|
|
17992
|
+
if (!migratedPath) return;
|
|
17993
|
+
cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
|
|
17994
|
+
cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
|
|
17995
|
+
cliLogger.break();
|
|
17996
|
+
};
|
|
16962
17997
|
const inspectAction = async (directory, flags) => {
|
|
16963
17998
|
const isScoreOnly = Boolean(flags.score);
|
|
16964
17999
|
const isJsonMode = Boolean(flags.json);
|
|
@@ -16971,7 +18006,11 @@ const inspectAction = async (directory, flags) => {
|
|
|
16971
18006
|
});
|
|
16972
18007
|
try {
|
|
16973
18008
|
validateModeFlags(flags);
|
|
16974
|
-
|
|
18009
|
+
maybeMigrateLegacyConfig(requestedDirectory, {
|
|
18010
|
+
isQuiet,
|
|
18011
|
+
isStaged: Boolean(flags.staged)
|
|
18012
|
+
});
|
|
18013
|
+
const scanTarget = await resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
|
|
16975
18014
|
const userConfig = scanTarget.userConfig;
|
|
16976
18015
|
const resolvedDirectory = scanTarget.resolvedDirectory;
|
|
16977
18016
|
setJsonReportDirectory(resolvedDirectory);
|
|
@@ -17145,6 +18184,7 @@ const inspectAction = async (directory, flags) => {
|
|
|
17145
18184
|
})) printAgentInstallHint();
|
|
17146
18185
|
}
|
|
17147
18186
|
} catch (error) {
|
|
18187
|
+
await reportErrorToSentry(error);
|
|
17148
18188
|
if (isJsonMode) {
|
|
17149
18189
|
writeJsonErrorReport(error);
|
|
17150
18190
|
process.exitCode = 1;
|
|
@@ -17166,10 +18206,573 @@ const installAction = async (options, command) => {
|
|
|
17166
18206
|
projectRoot: options.cwd ?? process.cwd()
|
|
17167
18207
|
});
|
|
17168
18208
|
} catch (error) {
|
|
18209
|
+
await reportErrorToSentry(error);
|
|
17169
18210
|
handleError(error);
|
|
17170
18211
|
}
|
|
17171
18212
|
};
|
|
17172
18213
|
//#endregion
|
|
18214
|
+
//#region src/cli/utils/rule-catalog.ts
|
|
18215
|
+
const buildRuleCatalog = () => REACT_DOCTOR_RULES.map((entry) => ({
|
|
18216
|
+
key: entry.key,
|
|
18217
|
+
id: entry.id,
|
|
18218
|
+
category: entry.rule.category ?? "Other",
|
|
18219
|
+
defaultSeverity: entry.rule.severity,
|
|
18220
|
+
framework: entry.rule.framework ?? "global",
|
|
18221
|
+
tags: entry.rule.tags ?? [],
|
|
18222
|
+
recommendation: entry.rule.recommendation,
|
|
18223
|
+
defaultEnabled: entry.rule.defaultEnabled !== false
|
|
18224
|
+
}));
|
|
18225
|
+
/**
|
|
18226
|
+
* Resolves a user-supplied rule reference to a catalog entry. Accepts the
|
|
18227
|
+
* fully-qualified key (`react-doctor/no-danger`), the bare id (`no-danger`),
|
|
18228
|
+
* and legacy plugin keys (`react/no-danger`) via the shared alias map.
|
|
18229
|
+
*/
|
|
18230
|
+
const findRuleInCatalog = (catalog, ruleQuery) => {
|
|
18231
|
+
const normalizedQuery = ruleQuery.trim();
|
|
18232
|
+
if (normalizedQuery.length === 0) return void 0;
|
|
18233
|
+
const directMatch = catalog.find((entry) => entry.key === normalizedQuery || entry.id === normalizedQuery);
|
|
18234
|
+
if (directMatch) return directMatch;
|
|
18235
|
+
return catalog.find((entry) => isSameRuleKey(entry.key, normalizedQuery));
|
|
18236
|
+
};
|
|
18237
|
+
const listRuleCategories = (catalog) => [...new Set(catalog.map((entry) => entry.category))].sort();
|
|
18238
|
+
const listRuleTags = (catalog) => [...new Set(catalog.flatMap((entry) => [...entry.tags]))].sort();
|
|
18239
|
+
//#endregion
|
|
18240
|
+
//#region src/cli/utils/render-rule-catalog.ts
|
|
18241
|
+
const SEVERITY_COLUMN_WIDTH_CHARS = 6;
|
|
18242
|
+
const colorizeSeverity = (severity, text) => {
|
|
18243
|
+
if (severity === "error") return highlighter.error(text);
|
|
18244
|
+
if (severity === "warn") return highlighter.warn(text);
|
|
18245
|
+
return highlighter.gray(text);
|
|
18246
|
+
};
|
|
18247
|
+
const formatSourceNote = (effective) => effective.source === "default" ? highlighter.dim("(default)") : highlighter.dim(`(${effective.source})`);
|
|
18248
|
+
const renderRuleCatalog = (rows) => {
|
|
18249
|
+
if (rows.length === 0) return highlighter.dim("No rules match the given filters.");
|
|
18250
|
+
const rowsByCategory = /* @__PURE__ */ new Map();
|
|
18251
|
+
for (const row of rows) {
|
|
18252
|
+
const bucket = rowsByCategory.get(row.entry.category) ?? [];
|
|
18253
|
+
bucket.push(row);
|
|
18254
|
+
rowsByCategory.set(row.entry.category, bucket);
|
|
18255
|
+
}
|
|
18256
|
+
const lines = [];
|
|
18257
|
+
for (const category of [...rowsByCategory.keys()].sort()) {
|
|
18258
|
+
const categoryRows = (rowsByCategory.get(category) ?? []).sort((leftRow, rightRow) => leftRow.entry.key.localeCompare(rightRow.entry.key));
|
|
18259
|
+
lines.push(highlighter.bold(`${category} ${highlighter.dim(`(${categoryRows.length})`)}`));
|
|
18260
|
+
for (const row of categoryRows) {
|
|
18261
|
+
const severityBadge = colorizeSeverity(row.effective.value, row.effective.value.padEnd(SEVERITY_COLUMN_WIDTH_CHARS));
|
|
18262
|
+
const tagSuffix = row.entry.tags.length > 0 ? highlighter.dim(` [${row.entry.tags.join(", ")}]`) : "";
|
|
18263
|
+
lines.push(` ${severityBadge} ${row.entry.key} ${formatSourceNote(row.effective)}${tagSuffix}`);
|
|
18264
|
+
}
|
|
18265
|
+
lines.push("");
|
|
18266
|
+
}
|
|
18267
|
+
lines.push(highlighter.dim(`${rows.length} rule${rows.length === 1 ? "" : "s"} shown.`));
|
|
18268
|
+
return lines.join("\n");
|
|
18269
|
+
};
|
|
18270
|
+
const DETAIL_LABEL_COLUMN_WIDTH_CHARS = 18;
|
|
18271
|
+
const formatDetailRow = (label, value) => ` ${highlighter.dim(label.padEnd(DETAIL_LABEL_COLUMN_WIDTH_CHARS))}${value}`;
|
|
18272
|
+
const renderRuleExplanation = (row) => {
|
|
18273
|
+
const { entry, effective } = row;
|
|
18274
|
+
const lines = [highlighter.bold(entry.key), ""];
|
|
18275
|
+
lines.push(formatDetailRow("Category", entry.category));
|
|
18276
|
+
lines.push(formatDetailRow("Default severity", entry.defaultSeverity));
|
|
18277
|
+
lines.push(formatDetailRow("Current severity", `${colorizeSeverity(effective.value, effective.value)} ${formatSourceNote(effective)}`));
|
|
18278
|
+
lines.push(formatDetailRow("Framework", entry.framework));
|
|
18279
|
+
lines.push(formatDetailRow("Tags", entry.tags.length > 0 ? entry.tags.join(", ") : "none"));
|
|
18280
|
+
lines.push(formatDetailRow("Default enabled", entry.defaultEnabled ? "yes" : "no (opt-in)"));
|
|
18281
|
+
lines.push("");
|
|
18282
|
+
lines.push(highlighter.bold("Why it matters"));
|
|
18283
|
+
lines.push(` ${entry.recommendation ?? "No additional guidance recorded for this rule yet."}`);
|
|
18284
|
+
lines.push("");
|
|
18285
|
+
lines.push(highlighter.bold("Configure"));
|
|
18286
|
+
lines.push(highlighter.dim(` react-doctor rules disable ${entry.key}`));
|
|
18287
|
+
lines.push(highlighter.dim(` react-doctor rules enable ${entry.key} --severity error`));
|
|
18288
|
+
lines.push(highlighter.dim(` react-doctor rules set ${entry.key} warn`));
|
|
18289
|
+
lines.push("");
|
|
18290
|
+
lines.push(highlighter.bold("Learn more"));
|
|
18291
|
+
lines.push(highlighter.dim(` ${buildRuleDocsUrl("react-doctor", entry.id)}`));
|
|
18292
|
+
return lines.join("\n");
|
|
18293
|
+
};
|
|
18294
|
+
//#endregion
|
|
18295
|
+
//#region src/cli/utils/rule-config-file.ts
|
|
18296
|
+
const NEW_CONFIG_FILENAME = "doctor.config.json";
|
|
18297
|
+
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
18298
|
+
const JSON_INDENT_SPACES = 2;
|
|
18299
|
+
const MANAGED_KEYS = [
|
|
18300
|
+
"rules",
|
|
18301
|
+
"categories",
|
|
18302
|
+
"ignore"
|
|
18303
|
+
];
|
|
18304
|
+
/**
|
|
18305
|
+
* Decides where a rule-config mutation should be written. Discovery
|
|
18306
|
+
* reuses `loadConfigWithSource` (the loader the scan uses) so edits land
|
|
18307
|
+
* in the file the scan reads — `doctor.config.{ts,js,…}` is preferred,
|
|
18308
|
+
* then `package.json#reactDoctor`. When nothing exists, a fresh
|
|
18309
|
+
* `doctor.config.json` is targeted at `projectRoot`. Data configs are
|
|
18310
|
+
* re-read raw so unrelated fields round-trip untouched.
|
|
18311
|
+
*/
|
|
18312
|
+
const resolveRuleConfigTarget = async (projectRoot) => {
|
|
18313
|
+
clearConfigCache();
|
|
18314
|
+
const loaded = await loadConfigWithSource(projectRoot);
|
|
18315
|
+
if (loaded) {
|
|
18316
|
+
if (loaded.format === "package-json") {
|
|
18317
|
+
const embedded = (readObjectFile(loaded.configFilePath) ?? {})[PACKAGE_JSON_CONFIG_KEY];
|
|
18318
|
+
return {
|
|
18319
|
+
format: "package-json",
|
|
18320
|
+
filePath: loaded.configFilePath,
|
|
18321
|
+
directory: loaded.sourceDirectory,
|
|
18322
|
+
exists: true,
|
|
18323
|
+
config: isPlainObject(embedded) ? embedded : {}
|
|
18324
|
+
};
|
|
18325
|
+
}
|
|
18326
|
+
if (loaded.format === "json") return {
|
|
18327
|
+
format: "json",
|
|
18328
|
+
filePath: loaded.configFilePath,
|
|
18329
|
+
directory: loaded.sourceDirectory,
|
|
18330
|
+
exists: true,
|
|
18331
|
+
config: readObjectFile(loaded.configFilePath) ?? {}
|
|
18332
|
+
};
|
|
18333
|
+
return {
|
|
18334
|
+
format: "module",
|
|
18335
|
+
filePath: loaded.configFilePath,
|
|
18336
|
+
directory: loaded.sourceDirectory,
|
|
18337
|
+
exists: true,
|
|
18338
|
+
config: loaded.config
|
|
18339
|
+
};
|
|
18340
|
+
}
|
|
18341
|
+
return {
|
|
18342
|
+
format: "json",
|
|
18343
|
+
filePath: path.join(projectRoot, NEW_CONFIG_FILENAME),
|
|
18344
|
+
directory: projectRoot,
|
|
18345
|
+
exists: false,
|
|
18346
|
+
config: {}
|
|
18347
|
+
};
|
|
18348
|
+
};
|
|
18349
|
+
const writeJsonConfig = (filePath, nextConfig) => {
|
|
18350
|
+
const { $schema, ...rest } = nextConfig;
|
|
18351
|
+
writeFileSync(filePath, `${JSON.stringify({
|
|
18352
|
+
$schema: $schema ?? "https://react.doctor/schema/config.json",
|
|
18353
|
+
...rest
|
|
18354
|
+
}, null, JSON_INDENT_SPACES)}\n`);
|
|
18355
|
+
};
|
|
18356
|
+
const writePackageJsonConfig = (filePath, nextConfig) => {
|
|
18357
|
+
const packageJson = readObjectFile(filePath) ?? {};
|
|
18358
|
+
writeFileSync(filePath, `${JSON.stringify({
|
|
18359
|
+
...packageJson,
|
|
18360
|
+
[PACKAGE_JSON_CONFIG_KEY]: nextConfig
|
|
18361
|
+
}, null, JSON_INDENT_SPACES)}\n`);
|
|
18362
|
+
};
|
|
18363
|
+
const syncManagedKeys = (target, nextConfig) => {
|
|
18364
|
+
for (const key of MANAGED_KEYS) {
|
|
18365
|
+
const value = nextConfig[key];
|
|
18366
|
+
if (value === void 0) {
|
|
18367
|
+
if (target[key] !== void 0) delete target[key];
|
|
18368
|
+
} else target[key] = value;
|
|
18369
|
+
}
|
|
18370
|
+
};
|
|
18371
|
+
const assignNodeSource = (owner, key, code) => {
|
|
18372
|
+
owner[key] = code;
|
|
18373
|
+
};
|
|
18374
|
+
const editVariableDeclarationConfig = (declaration, config, nextConfig) => {
|
|
18375
|
+
syncManagedKeys(config, nextConfig);
|
|
18376
|
+
const initializer = declaration.init;
|
|
18377
|
+
if (!initializer) return false;
|
|
18378
|
+
const generatedSource = generateCode(config).code;
|
|
18379
|
+
if (initializer.type === "ObjectExpression") {
|
|
18380
|
+
assignNodeSource(declaration, "init", generatedSource);
|
|
18381
|
+
return true;
|
|
18382
|
+
}
|
|
18383
|
+
if (initializer.type === "TSSatisfiesExpression" && initializer.expression.type === "ObjectExpression") {
|
|
18384
|
+
assignNodeSource(initializer, "expression", generatedSource);
|
|
18385
|
+
return true;
|
|
18386
|
+
}
|
|
18387
|
+
return false;
|
|
18388
|
+
};
|
|
18389
|
+
const writeModuleConfig = async (filePath, nextConfig) => {
|
|
18390
|
+
try {
|
|
18391
|
+
const module = await loadFile(filePath);
|
|
18392
|
+
if (module.exports.default?.$type === "identifier") {
|
|
18393
|
+
const { declaration, config } = getConfigFromVariableDeclaration(module);
|
|
18394
|
+
if (!config || !editVariableDeclarationConfig(declaration, config, nextConfig)) return false;
|
|
18395
|
+
} else syncManagedKeys(getDefaultExportOptions(module), nextConfig);
|
|
18396
|
+
await writeFile(module, filePath);
|
|
18397
|
+
return true;
|
|
18398
|
+
} catch {
|
|
18399
|
+
return false;
|
|
18400
|
+
}
|
|
18401
|
+
};
|
|
18402
|
+
const writeRuleConfig = async (target, nextConfig) => {
|
|
18403
|
+
if (target.format === "module") {
|
|
18404
|
+
const written = await writeModuleConfig(target.filePath, nextConfig);
|
|
18405
|
+
if (written) clearConfigCache();
|
|
18406
|
+
return { written };
|
|
18407
|
+
}
|
|
18408
|
+
if (target.format === "package-json") writePackageJsonConfig(target.filePath, nextConfig);
|
|
18409
|
+
else writeJsonConfig(target.filePath, nextConfig);
|
|
18410
|
+
clearConfigCache();
|
|
18411
|
+
return { written: true };
|
|
18412
|
+
};
|
|
18413
|
+
//#endregion
|
|
18414
|
+
//#region src/cli/utils/resolve-effective-rule-severity.ts
|
|
18415
|
+
/**
|
|
18416
|
+
* Resolves what a rule will actually do under the current config without
|
|
18417
|
+
* running a scan. `ignore.tags` is a pre-lint gate: a rule carrying an
|
|
18418
|
+
* ignored tag is dropped (via `shouldEnableRule`) before any severity is
|
|
18419
|
+
* read, so it wins over every override. Among rules that survive the gate,
|
|
18420
|
+
* the scanner's order is `rules` > `categories` > `buckets` > the registry
|
|
18421
|
+
* default.
|
|
18422
|
+
*/
|
|
18423
|
+
const resolveEffectiveRuleSeverity = (config, entry) => {
|
|
18424
|
+
const ignoredTags = config?.ignore?.tags ?? [];
|
|
18425
|
+
if (entry.tags.some((tag) => ignoredTags.includes(tag))) return {
|
|
18426
|
+
value: "off",
|
|
18427
|
+
source: "tag"
|
|
18428
|
+
};
|
|
18429
|
+
const ruleOverrides = config?.rules ?? {};
|
|
18430
|
+
for (const equivalentKey of getEquivalentRuleKeys(entry.key)) {
|
|
18431
|
+
const override = ruleOverrides[equivalentKey];
|
|
18432
|
+
if (override !== void 0) return {
|
|
18433
|
+
value: override,
|
|
18434
|
+
source: "rule"
|
|
18435
|
+
};
|
|
18436
|
+
}
|
|
18437
|
+
const categoryOverride = config?.categories?.[entry.category];
|
|
18438
|
+
if (categoryOverride !== void 0) return {
|
|
18439
|
+
value: categoryOverride,
|
|
18440
|
+
source: "category"
|
|
18441
|
+
};
|
|
18442
|
+
if (COMPILER_CLEANUP_RULE_KEYS.has(entry.key)) {
|
|
18443
|
+
const bucketOverride = config?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
18444
|
+
if (bucketOverride !== void 0) return {
|
|
18445
|
+
value: bucketOverride,
|
|
18446
|
+
source: "bucket"
|
|
18447
|
+
};
|
|
18448
|
+
}
|
|
18449
|
+
return {
|
|
18450
|
+
value: entry.defaultEnabled ? entry.defaultSeverity : "off",
|
|
18451
|
+
source: "default"
|
|
18452
|
+
};
|
|
18453
|
+
};
|
|
18454
|
+
//#endregion
|
|
18455
|
+
//#region src/cli/utils/update-rule-config.ts
|
|
18456
|
+
/**
|
|
18457
|
+
* Sets a per-rule severity, replacing any existing entry for the same
|
|
18458
|
+
* rule (including legacy-aliased keys, so a config still targeting
|
|
18459
|
+
* `react/no-danger` is rewritten to the canonical key instead of
|
|
18460
|
+
* leaving a dead duplicate).
|
|
18461
|
+
*/
|
|
18462
|
+
const setRuleSeverity = (config, ruleKey, severity) => {
|
|
18463
|
+
const equivalentKeys = new Set(getEquivalentRuleKeys(ruleKey));
|
|
18464
|
+
const nextRules = {};
|
|
18465
|
+
for (const [existingKey, existingSeverity] of Object.entries(config.rules ?? {})) if (!equivalentKeys.has(existingKey)) nextRules[existingKey] = existingSeverity;
|
|
18466
|
+
nextRules[ruleKey] = severity;
|
|
18467
|
+
return {
|
|
18468
|
+
...config,
|
|
18469
|
+
rules: nextRules
|
|
18470
|
+
};
|
|
18471
|
+
};
|
|
18472
|
+
const setCategorySeverity = (config, category, severity) => ({
|
|
18473
|
+
...config,
|
|
18474
|
+
categories: {
|
|
18475
|
+
...config.categories,
|
|
18476
|
+
[category]: severity
|
|
18477
|
+
}
|
|
18478
|
+
});
|
|
18479
|
+
const addIgnoredTag = (config, tag) => {
|
|
18480
|
+
const currentTags = config.ignore?.tags ?? [];
|
|
18481
|
+
if (currentTags.includes(tag)) return config;
|
|
18482
|
+
return {
|
|
18483
|
+
...config,
|
|
18484
|
+
ignore: {
|
|
18485
|
+
...config.ignore,
|
|
18486
|
+
tags: [...new Set([...currentTags, tag])].sort()
|
|
18487
|
+
}
|
|
18488
|
+
};
|
|
18489
|
+
};
|
|
18490
|
+
const removeIgnoredTag = (config, tag) => {
|
|
18491
|
+
const currentTags = config.ignore?.tags ?? [];
|
|
18492
|
+
if (!currentTags.includes(tag)) return config;
|
|
18493
|
+
const remainingTags = currentTags.filter((existingTag) => existingTag !== tag);
|
|
18494
|
+
const { tags: _removed, ...remainingIgnore } = config.ignore ?? {};
|
|
18495
|
+
if (remainingTags.length === 0) {
|
|
18496
|
+
if (Object.keys(remainingIgnore).length === 0) {
|
|
18497
|
+
const { ignore: _ignore, ...configWithoutIgnore } = config;
|
|
18498
|
+
return configWithoutIgnore;
|
|
18499
|
+
}
|
|
18500
|
+
return {
|
|
18501
|
+
...config,
|
|
18502
|
+
ignore: remainingIgnore
|
|
18503
|
+
};
|
|
18504
|
+
}
|
|
18505
|
+
return {
|
|
18506
|
+
...config,
|
|
18507
|
+
ignore: {
|
|
18508
|
+
...remainingIgnore,
|
|
18509
|
+
tags: remainingTags
|
|
18510
|
+
}
|
|
18511
|
+
};
|
|
18512
|
+
};
|
|
18513
|
+
//#endregion
|
|
18514
|
+
//#region src/cli/commands/rules.ts
|
|
18515
|
+
const SEVERITY_VALUES = [
|
|
18516
|
+
"off",
|
|
18517
|
+
"warn",
|
|
18518
|
+
"error"
|
|
18519
|
+
];
|
|
18520
|
+
const resolveProjectRoot = (options) => {
|
|
18521
|
+
const requestedDirectory = path.resolve(options.cwd ?? process.cwd());
|
|
18522
|
+
return findNearestPackageDirectory(requestedDirectory) ?? requestedDirectory;
|
|
18523
|
+
};
|
|
18524
|
+
const parseSeverity = (value) => SEVERITY_VALUES.includes(value) ? value : null;
|
|
18525
|
+
const reportInvalidSeverity = (value) => {
|
|
18526
|
+
cliLogger.error(`Invalid severity "${value}". Expected one of: ${SEVERITY_VALUES.join(", ")}.`);
|
|
18527
|
+
process.exitCode = 1;
|
|
18528
|
+
};
|
|
18529
|
+
const reportRuleNotFound = (ruleQuery) => {
|
|
18530
|
+
cliLogger.error(`Unknown rule "${ruleQuery}".`);
|
|
18531
|
+
cliLogger.dim(" Run `react-doctor rules list` to see every available rule.");
|
|
18532
|
+
process.exitCode = 1;
|
|
18533
|
+
};
|
|
18534
|
+
const describeTargetPath = (target) => {
|
|
18535
|
+
const relativePath = path.relative(process.cwd(), target.filePath);
|
|
18536
|
+
const displayPath = relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : target.filePath;
|
|
18537
|
+
return target.exists ? displayPath : `${displayPath} ${highlighter.dim("(created)")}`;
|
|
18538
|
+
};
|
|
18539
|
+
const applyConfigChange = async (options, change) => {
|
|
18540
|
+
const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
|
|
18541
|
+
const nextConfig = change(target.config);
|
|
18542
|
+
const { written } = await writeRuleConfig(target, nextConfig);
|
|
18543
|
+
return {
|
|
18544
|
+
target,
|
|
18545
|
+
nextConfig,
|
|
18546
|
+
written
|
|
18547
|
+
};
|
|
18548
|
+
};
|
|
18549
|
+
const reportManualEdit = (target, nextConfig) => {
|
|
18550
|
+
const managed = {};
|
|
18551
|
+
for (const key of [
|
|
18552
|
+
"rules",
|
|
18553
|
+
"categories",
|
|
18554
|
+
"ignore"
|
|
18555
|
+
]) if (nextConfig[key] !== void 0) managed[key] = nextConfig[key];
|
|
18556
|
+
cliLogger.error(`Couldn't automatically edit ${describeTargetPath(target)} (dynamic config).`);
|
|
18557
|
+
cliLogger.dim(" Apply this to your config's default export, then re-run:");
|
|
18558
|
+
for (const line of JSON.stringify(managed, null, 2).split("\n")) cliLogger.dim(` ${line}`);
|
|
18559
|
+
process.exitCode = 1;
|
|
18560
|
+
};
|
|
18561
|
+
const rulesListAction = async (options) => {
|
|
18562
|
+
const catalog = buildRuleCatalog();
|
|
18563
|
+
const config = validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config);
|
|
18564
|
+
const categoryFilter = options.category?.toLowerCase();
|
|
18565
|
+
const frameworkFilter = options.framework?.toLowerCase();
|
|
18566
|
+
const rows = catalog.filter((entry) => {
|
|
18567
|
+
if (categoryFilter && entry.category.toLowerCase() !== categoryFilter) return false;
|
|
18568
|
+
if (frameworkFilter && entry.framework.toLowerCase() !== frameworkFilter) return false;
|
|
18569
|
+
if (options.tag && !entry.tags.includes(options.tag)) return false;
|
|
18570
|
+
return true;
|
|
18571
|
+
}).map((entry) => ({
|
|
18572
|
+
entry,
|
|
18573
|
+
effective: resolveEffectiveRuleSeverity(config, entry)
|
|
18574
|
+
})).filter((row) => options.configured ? row.effective.source !== "default" : true);
|
|
18575
|
+
if (options.json) {
|
|
18576
|
+
const payload = rows.map((row) => ({
|
|
18577
|
+
key: row.entry.key,
|
|
18578
|
+
id: row.entry.id,
|
|
18579
|
+
category: row.entry.category,
|
|
18580
|
+
framework: row.entry.framework,
|
|
18581
|
+
tags: row.entry.tags,
|
|
18582
|
+
defaultSeverity: row.entry.defaultSeverity,
|
|
18583
|
+
defaultEnabled: row.entry.defaultEnabled,
|
|
18584
|
+
severity: row.effective.value,
|
|
18585
|
+
source: row.effective.source
|
|
18586
|
+
}));
|
|
18587
|
+
cliLogger.log(JSON.stringify(payload, null, 2));
|
|
18588
|
+
return;
|
|
18589
|
+
}
|
|
18590
|
+
cliLogger.log(renderRuleCatalog(rows));
|
|
18591
|
+
};
|
|
18592
|
+
const rulesExplainAction = async (ruleQuery, options) => {
|
|
18593
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
18594
|
+
if (!entry) {
|
|
18595
|
+
reportRuleNotFound(ruleQuery);
|
|
18596
|
+
return;
|
|
18597
|
+
}
|
|
18598
|
+
const effective = resolveEffectiveRuleSeverity(validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config), entry);
|
|
18599
|
+
if (options.json) {
|
|
18600
|
+
cliLogger.log(JSON.stringify({
|
|
18601
|
+
key: entry.key,
|
|
18602
|
+
id: entry.id,
|
|
18603
|
+
category: entry.category,
|
|
18604
|
+
framework: entry.framework,
|
|
18605
|
+
tags: entry.tags,
|
|
18606
|
+
defaultSeverity: entry.defaultSeverity,
|
|
18607
|
+
defaultEnabled: entry.defaultEnabled,
|
|
18608
|
+
severity: effective.value,
|
|
18609
|
+
source: effective.source,
|
|
18610
|
+
recommendation: entry.recommendation ?? null,
|
|
18611
|
+
learnMoreUrl: buildRuleDocsUrl("react-doctor", entry.id)
|
|
18612
|
+
}, null, 2));
|
|
18613
|
+
return;
|
|
18614
|
+
}
|
|
18615
|
+
cliLogger.log(renderRuleExplanation({
|
|
18616
|
+
entry,
|
|
18617
|
+
effective
|
|
18618
|
+
}));
|
|
18619
|
+
};
|
|
18620
|
+
const setRuleSeverityAndReport = async (entry, severity, options) => {
|
|
18621
|
+
const { target, nextConfig, written } = await applyConfigChange(options, (config) => setRuleSeverity(config, entry.key, severity));
|
|
18622
|
+
if (!written) {
|
|
18623
|
+
reportManualEdit(target, nextConfig);
|
|
18624
|
+
return;
|
|
18625
|
+
}
|
|
18626
|
+
cliLogger.success(`Set ${entry.key} → ${severity}`);
|
|
18627
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
18628
|
+
};
|
|
18629
|
+
const rulesSetAction = async (ruleQuery, severityValue, options) => {
|
|
18630
|
+
const severity = parseSeverity(severityValue);
|
|
18631
|
+
if (!severity) {
|
|
18632
|
+
reportInvalidSeverity(severityValue);
|
|
18633
|
+
return;
|
|
18634
|
+
}
|
|
18635
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
18636
|
+
if (!entry) {
|
|
18637
|
+
reportRuleNotFound(ruleQuery);
|
|
18638
|
+
return;
|
|
18639
|
+
}
|
|
18640
|
+
await setRuleSeverityAndReport(entry, severity, options);
|
|
18641
|
+
};
|
|
18642
|
+
const rulesEnableAction = async (ruleQuery, options) => {
|
|
18643
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
18644
|
+
if (!entry) {
|
|
18645
|
+
reportRuleNotFound(ruleQuery);
|
|
18646
|
+
return;
|
|
18647
|
+
}
|
|
18648
|
+
if (options.severity === void 0) {
|
|
18649
|
+
await setRuleSeverityAndReport(entry, entry.defaultSeverity, options);
|
|
18650
|
+
return;
|
|
18651
|
+
}
|
|
18652
|
+
const severity = parseSeverity(options.severity);
|
|
18653
|
+
if (!severity) {
|
|
18654
|
+
reportInvalidSeverity(options.severity);
|
|
18655
|
+
return;
|
|
18656
|
+
}
|
|
18657
|
+
if (severity === "off") {
|
|
18658
|
+
cliLogger.error("`enable` cannot set a rule to off. Use `react-doctor rules disable` instead.");
|
|
18659
|
+
process.exitCode = 1;
|
|
18660
|
+
return;
|
|
18661
|
+
}
|
|
18662
|
+
await setRuleSeverityAndReport(entry, severity, options);
|
|
18663
|
+
};
|
|
18664
|
+
const rulesDisableAction = async (ruleQuery, options) => {
|
|
18665
|
+
const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
|
|
18666
|
+
if (!entry) {
|
|
18667
|
+
reportRuleNotFound(ruleQuery);
|
|
18668
|
+
return;
|
|
18669
|
+
}
|
|
18670
|
+
await setRuleSeverityAndReport(entry, "off", options);
|
|
18671
|
+
};
|
|
18672
|
+
const rulesCategoryAction = async (categoryQuery, severityValue, options) => {
|
|
18673
|
+
const severity = parseSeverity(severityValue);
|
|
18674
|
+
if (!severity) {
|
|
18675
|
+
reportInvalidSeverity(severityValue);
|
|
18676
|
+
return;
|
|
18677
|
+
}
|
|
18678
|
+
const knownCategories = listRuleCategories(buildRuleCatalog());
|
|
18679
|
+
const matchedCategory = knownCategories.find((category) => category.toLowerCase() === categoryQuery.toLowerCase());
|
|
18680
|
+
if (!matchedCategory) {
|
|
18681
|
+
cliLogger.error(`Unknown category "${categoryQuery}".`);
|
|
18682
|
+
cliLogger.dim(` Known categories: ${knownCategories.join(", ")}`);
|
|
18683
|
+
process.exitCode = 1;
|
|
18684
|
+
return;
|
|
18685
|
+
}
|
|
18686
|
+
const { target, nextConfig, written } = await applyConfigChange(options, (config) => setCategorySeverity(config, matchedCategory, severity));
|
|
18687
|
+
if (!written) {
|
|
18688
|
+
reportManualEdit(target, nextConfig);
|
|
18689
|
+
return;
|
|
18690
|
+
}
|
|
18691
|
+
cliLogger.success(`Set category "${matchedCategory}" → ${severity}`);
|
|
18692
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
18693
|
+
};
|
|
18694
|
+
const rulesIgnoreTagAction = async (tag, options) => {
|
|
18695
|
+
const knownTags = listRuleTags(buildRuleCatalog());
|
|
18696
|
+
if (!knownTags.includes(tag)) {
|
|
18697
|
+
cliLogger.error(`Unknown tag "${tag}".`);
|
|
18698
|
+
cliLogger.dim(` Known tags: ${knownTags.join(", ")}`);
|
|
18699
|
+
process.exitCode = 1;
|
|
18700
|
+
return;
|
|
18701
|
+
}
|
|
18702
|
+
const { target, nextConfig, written } = await applyConfigChange(options, (config) => addIgnoredTag(config, tag));
|
|
18703
|
+
if (!written) {
|
|
18704
|
+
reportManualEdit(target, nextConfig);
|
|
18705
|
+
return;
|
|
18706
|
+
}
|
|
18707
|
+
cliLogger.success(`Ignoring tag "${tag}" (rules with this tag are skipped before linting)`);
|
|
18708
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
18709
|
+
};
|
|
18710
|
+
const rulesUnignoreTagAction = async (tag, options) => {
|
|
18711
|
+
const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
|
|
18712
|
+
if (!(target.config.ignore?.tags ?? []).includes(tag)) {
|
|
18713
|
+
cliLogger.dim(`Tag "${tag}" was not being ignored; nothing to change.`);
|
|
18714
|
+
return;
|
|
18715
|
+
}
|
|
18716
|
+
const nextConfig = removeIgnoredTag(target.config, tag);
|
|
18717
|
+
const { written } = await writeRuleConfig(target, nextConfig);
|
|
18718
|
+
if (!written) {
|
|
18719
|
+
reportManualEdit(target, nextConfig);
|
|
18720
|
+
return;
|
|
18721
|
+
}
|
|
18722
|
+
cliLogger.success(`Tag "${tag}" is no longer ignored`);
|
|
18723
|
+
cliLogger.dim(` Updated ${describeTargetPath(target)}`);
|
|
18724
|
+
};
|
|
18725
|
+
//#endregion
|
|
18726
|
+
//#region src/cli/commands/version.ts
|
|
18727
|
+
/**
|
|
18728
|
+
* oclif-style version line. 12-factor CLI Apps (#3, "What version am I
|
|
18729
|
+
* on?"): the `version` command is the primary place users grab debugging
|
|
18730
|
+
* info, so it carries the Node runtime and platform alongside the CLI
|
|
18731
|
+
* version. The `-v` / `-V` / `--version` flags stay terse (just the
|
|
18732
|
+
* number) so scripts can parse them.
|
|
18733
|
+
*/
|
|
18734
|
+
const buildVersionString = () => `react-doctor/${VERSION} ${process.platform}-${process.arch} node-${process.version}`;
|
|
18735
|
+
const versionAction = () => {
|
|
18736
|
+
process.stdout.write(`${buildVersionString()}\n`);
|
|
18737
|
+
};
|
|
18738
|
+
//#endregion
|
|
18739
|
+
//#region src/cli/utils/apply-color-preference.ts
|
|
18740
|
+
/**
|
|
18741
|
+
* Resolve an explicit color preference from `--color` / `--no-color` or the
|
|
18742
|
+
* app-specific `REACT_DOCTOR_NO_COLOR` / `REACT_DOCTOR_FORCE_COLOR` env vars
|
|
18743
|
+
* (clig.dev Output; 12-factor #6), overriding picocolors' own
|
|
18744
|
+
* `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY detection. Flags win over env
|
|
18745
|
+
* vars; with neither set, picocolors' detection stands.
|
|
18746
|
+
*
|
|
18747
|
+
* A resolved preference is mirrored onto the standard `NO_COLOR` /
|
|
18748
|
+
* `FORCE_COLOR` env vars in addition to our picocolors highlighter, so
|
|
18749
|
+
* libraries with their own color stacks (the `ora` spinner, `prompts`)
|
|
18750
|
+
* honor it too rather than only the scan report. Scanning argv directly
|
|
18751
|
+
* (not Commander's parsed options) applies the preference before Commander
|
|
18752
|
+
* parses, so it reaches every later path. The scan stops at `--`.
|
|
18753
|
+
*/
|
|
18754
|
+
const applyColorPreference = (argv, env = process.env) => {
|
|
18755
|
+
let enabled;
|
|
18756
|
+
for (const argument of argv) {
|
|
18757
|
+
if (argument === "--") break;
|
|
18758
|
+
if (argument === "--no-color") enabled = false;
|
|
18759
|
+
else if (argument === "--color") enabled = true;
|
|
18760
|
+
}
|
|
18761
|
+
if (enabled === void 0) {
|
|
18762
|
+
if (env.REACT_DOCTOR_NO_COLOR) enabled = false;
|
|
18763
|
+
else if (env.REACT_DOCTOR_FORCE_COLOR) enabled = true;
|
|
18764
|
+
}
|
|
18765
|
+
if (enabled === void 0) return;
|
|
18766
|
+
if (enabled) {
|
|
18767
|
+
env.FORCE_COLOR = "1";
|
|
18768
|
+
delete env.NO_COLOR;
|
|
18769
|
+
} else {
|
|
18770
|
+
env.NO_COLOR = "1";
|
|
18771
|
+
delete env.FORCE_COLOR;
|
|
18772
|
+
}
|
|
18773
|
+
setColorEnabled(enabled);
|
|
18774
|
+
};
|
|
18775
|
+
//#endregion
|
|
17173
18776
|
//#region src/cli/utils/exit-gracefully.ts
|
|
17174
18777
|
const exitGracefully = () => {
|
|
17175
18778
|
try {
|
|
@@ -17179,21 +18782,54 @@ const exitGracefully = () => {
|
|
|
17179
18782
|
process.exit(130);
|
|
17180
18783
|
};
|
|
17181
18784
|
//#endregion
|
|
18785
|
+
//#region src/cli/utils/normalize-help-command.ts
|
|
18786
|
+
/**
|
|
18787
|
+
* 12-factor CLI Apps (#1, "Great help is essential"): `mycli help` and
|
|
18788
|
+
* `mycli help <command>` must display help. Commander doesn't wire this
|
|
18789
|
+
* up once the root command has its own default action plus a positional
|
|
18790
|
+
* argument — it treats a leading `help` as the `[directory]` to scan,
|
|
18791
|
+
* which then errors with "No React project found in ./help".
|
|
18792
|
+
*
|
|
18793
|
+
* We rewrite the argv up front so the existing `--help` paths handle it:
|
|
18794
|
+
* `react-doctor help` -> `react-doctor --help`
|
|
18795
|
+
* `react-doctor help install` -> `react-doctor install --help`
|
|
18796
|
+
*
|
|
18797
|
+
* Only a *leading* `help` token is rewritten, so a flag value such as
|
|
18798
|
+
* `--project help` is never mistaken for the help command. The target is
|
|
18799
|
+
* the first non-flag token after `help`, so intervening flags like
|
|
18800
|
+
* `help --no-color install` still resolve to `install`. An unknown target
|
|
18801
|
+
* (`help bogus`) falls back to root help rather than erroring.
|
|
18802
|
+
*/
|
|
18803
|
+
const normalizeHelpInvocation = (argv, knownCommands) => {
|
|
18804
|
+
const nodeArguments = argv.slice(0, 2);
|
|
18805
|
+
const userArguments = argv.slice(2);
|
|
18806
|
+
if (userArguments[0] !== "help") return [...argv];
|
|
18807
|
+
const target = userArguments.slice(1).find((argument) => !argument.startsWith("-"));
|
|
18808
|
+
if (target !== void 0 && knownCommands.includes(target)) return [
|
|
18809
|
+
...nodeArguments,
|
|
18810
|
+
target,
|
|
18811
|
+
"--help"
|
|
18812
|
+
];
|
|
18813
|
+
return [...nodeArguments, "--help"];
|
|
18814
|
+
};
|
|
18815
|
+
//#endregion
|
|
17182
18816
|
//#region src/cli/utils/strip-unknown-cli-flags.ts
|
|
17183
|
-
const NODE_ARGUMENT_COUNT = 2;
|
|
17184
18817
|
const ROOT_FLAG_SPEC = {
|
|
17185
18818
|
longOptionsWithoutValues: new Set([
|
|
17186
18819
|
"--annotations",
|
|
18820
|
+
"--color",
|
|
17187
18821
|
"--dead-code",
|
|
17188
18822
|
"--full",
|
|
17189
18823
|
"--help",
|
|
17190
18824
|
"--json",
|
|
17191
18825
|
"--json-compact",
|
|
17192
18826
|
"--lint",
|
|
18827
|
+
"--no-color",
|
|
17193
18828
|
"--no-dead-code",
|
|
17194
18829
|
"--no-lint",
|
|
17195
18830
|
"--no-respect-inline-disables",
|
|
17196
18831
|
"--no-score",
|
|
18832
|
+
"--no-telemetry",
|
|
17197
18833
|
"--no-warnings",
|
|
17198
18834
|
"--pr-comment",
|
|
17199
18835
|
"--respect-inline-disables",
|
|
@@ -17211,7 +18847,7 @@ const ROOT_FLAG_SPEC = {
|
|
|
17211
18847
|
"--project",
|
|
17212
18848
|
"--why"
|
|
17213
18849
|
]),
|
|
17214
|
-
longOptionsWithOptionalValues: new Set(["--diff"]),
|
|
18850
|
+
longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
|
|
17215
18851
|
shortOptionsWithoutValues: new Set([
|
|
17216
18852
|
"-h",
|
|
17217
18853
|
"-v",
|
|
@@ -17222,8 +18858,10 @@ const ROOT_FLAG_SPEC = {
|
|
|
17222
18858
|
const INSTALL_FLAG_SPEC = {
|
|
17223
18859
|
longOptionsWithoutValues: new Set([
|
|
17224
18860
|
"--agent-hooks",
|
|
18861
|
+
"--color",
|
|
17225
18862
|
"--dry-run",
|
|
17226
18863
|
"--help",
|
|
18864
|
+
"--no-color",
|
|
17227
18865
|
"--yes"
|
|
17228
18866
|
]),
|
|
17229
18867
|
longOptionsWithRequiredValues: new Set(["--cwd"]),
|
|
@@ -17231,7 +18869,40 @@ const INSTALL_FLAG_SPEC = {
|
|
|
17231
18869
|
shortOptionsWithoutValues: new Set(["-h", "-y"]),
|
|
17232
18870
|
shortOptionsWithRequiredValues: new Set(["-c"])
|
|
17233
18871
|
};
|
|
17234
|
-
const COMMAND_FLAG_SPECS = new Map([
|
|
18872
|
+
const COMMAND_FLAG_SPECS = new Map([
|
|
18873
|
+
["install", INSTALL_FLAG_SPEC],
|
|
18874
|
+
["setup", INSTALL_FLAG_SPEC],
|
|
18875
|
+
["version", {
|
|
18876
|
+
longOptionsWithoutValues: new Set([
|
|
18877
|
+
"--color",
|
|
18878
|
+
"--help",
|
|
18879
|
+
"--no-color"
|
|
18880
|
+
]),
|
|
18881
|
+
longOptionsWithRequiredValues: /* @__PURE__ */ new Set(),
|
|
18882
|
+
longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
|
|
18883
|
+
shortOptionsWithoutValues: new Set(["-h"]),
|
|
18884
|
+
shortOptionsWithRequiredValues: /* @__PURE__ */ new Set()
|
|
18885
|
+
}],
|
|
18886
|
+
["rules", {
|
|
18887
|
+
longOptionsWithoutValues: new Set([
|
|
18888
|
+
"--color",
|
|
18889
|
+
"--configured",
|
|
18890
|
+
"--help",
|
|
18891
|
+
"--json",
|
|
18892
|
+
"--no-color"
|
|
18893
|
+
]),
|
|
18894
|
+
longOptionsWithRequiredValues: new Set([
|
|
18895
|
+
"--category",
|
|
18896
|
+
"--cwd",
|
|
18897
|
+
"--framework",
|
|
18898
|
+
"--severity",
|
|
18899
|
+
"--tag"
|
|
18900
|
+
]),
|
|
18901
|
+
longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
|
|
18902
|
+
shortOptionsWithoutValues: new Set(["-h"]),
|
|
18903
|
+
shortOptionsWithRequiredValues: new Set(["-c"])
|
|
18904
|
+
}]
|
|
18905
|
+
]);
|
|
17235
18906
|
const isFlagLike = (argument) => argument.startsWith("-") && argument !== "-";
|
|
17236
18907
|
const getLongOptionName = (argument) => {
|
|
17237
18908
|
const equalsIndex = argument.indexOf("=");
|
|
@@ -17285,8 +18956,8 @@ const stripUnknownFlags = (userArguments, flagSpec) => {
|
|
|
17285
18956
|
return sanitizedArguments;
|
|
17286
18957
|
};
|
|
17287
18958
|
const stripUnknownCliFlags = (argv) => {
|
|
17288
|
-
const nodeArguments = argv.slice(0,
|
|
17289
|
-
const userArguments = argv.slice(
|
|
18959
|
+
const nodeArguments = argv.slice(0, 2);
|
|
18960
|
+
const userArguments = argv.slice(2);
|
|
17290
18961
|
const commandIndex = findCommandIndex(userArguments);
|
|
17291
18962
|
if (commandIndex === null) return [...nodeArguments, ...stripUnknownFlags(userArguments, ROOT_FLAG_SPEC)];
|
|
17292
18963
|
const commandName = userArguments[commandIndex];
|
|
@@ -17300,23 +18971,75 @@ const stripUnknownCliFlags = (argv) => {
|
|
|
17300
18971
|
};
|
|
17301
18972
|
//#endregion
|
|
17302
18973
|
//#region src/cli/index.ts
|
|
18974
|
+
initializeSentry();
|
|
17303
18975
|
process.on("SIGINT", exitGracefully);
|
|
17304
18976
|
process.on("SIGTERM", exitGracefully);
|
|
17305
18977
|
unrefStdin();
|
|
17306
|
-
const
|
|
18978
|
+
const formatExampleLines = (examples) => {
|
|
18979
|
+
const width = Math.max(...examples.map(([command]) => command.length));
|
|
18980
|
+
return examples.map(([command, description]) => ` $ ${command.padEnd(width)} ${highlighter.dim(`# ${description}`)}`).join("\n");
|
|
18981
|
+
};
|
|
18982
|
+
const renderRootHelpEpilog = () => `
|
|
18983
|
+
${highlighter.dim("Examples:")}
|
|
18984
|
+
${formatExampleLines([
|
|
18985
|
+
["react-doctor", "scan the current project"],
|
|
18986
|
+
["react-doctor ./apps/web", "scan a specific directory"],
|
|
18987
|
+
["react-doctor --diff main", "scan only files changed vs. main"],
|
|
18988
|
+
["react-doctor --staged", "scan staged files (pre-commit hook)"],
|
|
18989
|
+
["react-doctor --fail-on warning", "exit non-zero on warnings (CI gate)"],
|
|
18990
|
+
["react-doctor --json > report.json", "write a machine-readable report"],
|
|
18991
|
+
["react-doctor --explain src/App.tsx:42", "explain why a rule fired there"],
|
|
18992
|
+
["react-doctor install", "set up the agent skill and git hook"]
|
|
18993
|
+
])}
|
|
18994
|
+
|
|
17307
18995
|
${highlighter.dim("Configuration:")}
|
|
17308
|
-
|
|
17309
|
-
|
|
18996
|
+
Add a ${highlighter.info("doctor.config.ts")} (or .js/.mjs/.json — or a ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
|
|
18997
|
+
Use ${highlighter.info("react-doctor rules")} to list, explain, and configure rules. CLI flags always override config values.
|
|
18998
|
+
|
|
18999
|
+
${highlighter.dim("Feedback & bug reports:")}
|
|
19000
|
+
${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
|
|
17310
19001
|
|
|
17311
19002
|
${highlighter.dim("Learn more:")}
|
|
17312
19003
|
${highlighter.info(CANONICAL_GITHUB_URL)}
|
|
17313
|
-
|
|
19004
|
+
`;
|
|
19005
|
+
const renderInstallHelpEpilog = () => `
|
|
19006
|
+
${highlighter.dim("Examples:")}
|
|
19007
|
+
${formatExampleLines([
|
|
19008
|
+
["react-doctor install", "interactive setup"],
|
|
19009
|
+
["react-doctor install --yes", "non-interactive; all detected agents"],
|
|
19010
|
+
["react-doctor install --dry-run", "preview without writing files"],
|
|
19011
|
+
["react-doctor install --agent-hooks", "also install native agent hooks"]
|
|
19012
|
+
])}
|
|
19013
|
+
|
|
19014
|
+
${highlighter.dim("Learn more:")}
|
|
19015
|
+
${highlighter.info(CANONICAL_GITHUB_URL)}
|
|
19016
|
+
`;
|
|
19017
|
+
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);
|
|
17314
19018
|
program.action(inspectAction);
|
|
17315
|
-
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);
|
|
19019
|
+
program.command("install").alias("setup").description("Install the react-doctor skill into your coding agents and optional git hook").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor").option("-c, --cwd <cwd>", "working directory", process.cwd()).option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderInstallHelpEpilog).action(installAction);
|
|
19020
|
+
program.command("version").description("show the version with Node and platform info").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").action(versionAction);
|
|
19021
|
+
const rules = program.command("rules").description("List, explain, and configure which React Doctor rules run");
|
|
19022
|
+
rules.command("list").description("List rules and the severity they run at under your config").option("--category <name>", "only show rules in a category (e.g. Performance)").option("--tag <name>", "only show rules with a tag (e.g. design, test-noise)").option("--framework <name>", "only show rules for a framework (e.g. global, nextjs)").option("--configured", "only show rules your config has changed from the default").option("--json", "output a structured JSON array").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((_options, command) => rulesListAction(command.optsWithGlobals()));
|
|
19023
|
+
rules.command("explain <rule>").description("Explain why a rule matters, its current severity, and how to configure it").option("--json", "output a structured JSON object").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesExplainAction(rule, command.optsWithGlobals()));
|
|
19024
|
+
rules.command("set <rule> <severity>").description("Set a rule's severity: off, warn, or error").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, severity, _options, command) => rulesSetAction(rule, severity, command.optsWithGlobals()));
|
|
19025
|
+
rules.command("enable <rule>").description("Enable a rule at its recommended severity (or pass --severity)").option("--severity <level>", "severity to enable at: warn or error").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesEnableAction(rule, command.optsWithGlobals()));
|
|
19026
|
+
rules.command("disable <rule>").description("Disable a rule so it never runs").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesDisableAction(rule, command.optsWithGlobals()));
|
|
19027
|
+
rules.command("category <category> <severity>").description("Set the severity for a whole category (off, warn, error)").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((category, severity, _options, command) => rulesCategoryAction(category, severity, command.optsWithGlobals()));
|
|
19028
|
+
rules.command("ignore-tag <tag>").description("Skip a whole rule family by tag before linting (e.g. design)").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((tag, _options, command) => rulesIgnoreTagAction(tag, command.optsWithGlobals()));
|
|
19029
|
+
rules.command("unignore-tag <tag>").description("Stop ignoring a tag previously skipped via ignore-tag").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((tag, _options, command) => rulesUnignoreTagAction(tag, command.optsWithGlobals()));
|
|
17316
19030
|
process.stdout.on("error", (error) => {
|
|
17317
19031
|
if (error.code === "EPIPE") process.exit(0);
|
|
17318
19032
|
});
|
|
17319
|
-
program.
|
|
19033
|
+
const knownCommands = program.commands.flatMap((command) => [command.name(), ...command.aliases()]);
|
|
19034
|
+
const strippedArgv = stripUnknownCliFlags(process.argv);
|
|
19035
|
+
if (process.argv.includes("-V") && !strippedArgv.includes("-V")) {
|
|
19036
|
+
process.stdout.write(`${VERSION}\n`);
|
|
19037
|
+
process.exit(0);
|
|
19038
|
+
}
|
|
19039
|
+
applyColorPreference(strippedArgv);
|
|
19040
|
+
const argv = normalizeHelpInvocation(strippedArgv, knownCommands);
|
|
19041
|
+
program.parseAsync(argv).catch(async (error) => {
|
|
19042
|
+
await reportErrorToSentry(error);
|
|
17320
19043
|
if (isJsonModeActive()) {
|
|
17321
19044
|
writeJsonErrorReport(error);
|
|
17322
19045
|
process.exit(1);
|