react-doctor 0.2.14-dev.8b313ba → 0.2.14-dev.938376
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 +8 -0
- package/dist/cli.js +1151 -152
- package/dist/index.d.ts +45 -7
- package/dist/index.js +661 -76
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ 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";
|
|
19
20
|
import * as Fiber from "effect/Fiber";
|
|
20
21
|
import * as Filter from "effect/Filter";
|
|
@@ -27,9 +28,9 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
|
|
|
27
28
|
import * as NodePath from "@effect/platform-node-shared/NodePath";
|
|
28
29
|
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
29
30
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
30
|
-
import os, { tmpdir } from "node:os";
|
|
31
31
|
import * as ts from "typescript";
|
|
32
32
|
import { gzipSync } from "node:zlib";
|
|
33
|
+
import * as Sentry from "@sentry/node";
|
|
33
34
|
import { performance } from "node:perf_hooks";
|
|
34
35
|
import { stripVTControlCharacters } from "node:util";
|
|
35
36
|
import tty from "node:tty";
|
|
@@ -5892,29 +5893,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
5892
5893
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
5893
5894
|
};
|
|
5894
5895
|
};
|
|
5895
|
-
const
|
|
5896
|
-
|
|
5896
|
+
const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
|
|
5897
|
+
const rootValue = select(rootPackageJson);
|
|
5898
|
+
if (rootValue !== null) return rootValue;
|
|
5897
5899
|
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
5898
|
-
if (patterns.length === 0) return
|
|
5900
|
+
if (patterns.length === 0) return null;
|
|
5899
5901
|
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
5900
5902
|
for (const pattern of patterns) {
|
|
5901
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
5903
|
+
const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
|
|
5902
5904
|
for (const workspaceDirectory of directories) {
|
|
5903
5905
|
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
5904
5906
|
visitedDirectories.add(workspaceDirectory);
|
|
5905
|
-
|
|
5907
|
+
const value = select(readPackageJson$1(path.join(workspaceDirectory, "package.json")));
|
|
5908
|
+
if (value !== null) return value;
|
|
5906
5909
|
}
|
|
5907
5910
|
}
|
|
5908
|
-
return
|
|
5911
|
+
return null;
|
|
5909
5912
|
};
|
|
5913
|
+
const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
|
|
5910
5914
|
const NAMES = new Set([
|
|
5911
5915
|
"react-native",
|
|
5912
5916
|
"react-native-tvos",
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5917
|
+
...new Set([
|
|
5918
|
+
"expo",
|
|
5919
|
+
"expo-router",
|
|
5920
|
+
"@expo/cli",
|
|
5921
|
+
"@expo/metro-config",
|
|
5922
|
+
"@expo/metro-runtime"
|
|
5923
|
+
]),
|
|
5918
5924
|
"react-native-windows",
|
|
5919
5925
|
"react-native-macos"
|
|
5920
5926
|
]);
|
|
@@ -5938,6 +5944,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
|
|
|
5938
5944
|
return false;
|
|
5939
5945
|
};
|
|
5940
5946
|
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
|
|
5947
|
+
const getExpoDependencySpec = (packageJson) => {
|
|
5948
|
+
const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
|
|
5949
|
+
return typeof spec === "string" ? spec : null;
|
|
5950
|
+
};
|
|
5951
|
+
const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
|
|
5941
5952
|
const getPreactVersion = (packageJson) => {
|
|
5942
5953
|
return {
|
|
5943
5954
|
...packageJson.peerDependencies,
|
|
@@ -6174,6 +6185,19 @@ const discoverProject = (directory) => {
|
|
|
6174
6185
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
6175
6186
|
const sourceFileCount = countSourceFiles(directory);
|
|
6176
6187
|
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
6188
|
+
let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
|
|
6189
|
+
if (expoVersion !== null && isCatalogReference(expoVersion)) {
|
|
6190
|
+
const catalogName = extractCatalogName(expoVersion);
|
|
6191
|
+
let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
|
|
6192
|
+
if (!resolvedExpoVersion) {
|
|
6193
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
6194
|
+
if (monorepoRoot) {
|
|
6195
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
6196
|
+
if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson$1(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
|
|
6197
|
+
}
|
|
6198
|
+
}
|
|
6199
|
+
expoVersion = resolvedExpoVersion ?? expoVersion;
|
|
6200
|
+
}
|
|
6177
6201
|
const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
|
|
6178
6202
|
const preactVersion = getPreactVersion(packageJson);
|
|
6179
6203
|
const projectInfo = {
|
|
@@ -6191,6 +6215,7 @@ const discoverProject = (directory) => {
|
|
|
6191
6215
|
preactVersion,
|
|
6192
6216
|
preactMajorVersion: parseReactMajor(preactVersion),
|
|
6193
6217
|
hasReactNativeWorkspace,
|
|
6218
|
+
expoVersion,
|
|
6194
6219
|
hasReanimated,
|
|
6195
6220
|
sourceFileCount
|
|
6196
6221
|
};
|
|
@@ -6266,7 +6291,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
|
|
|
6266
6291
|
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
6267
6292
|
const ENTERPRISE_CONTACT_URL = "https://react.doctor/enterprise";
|
|
6268
6293
|
const SHARE_BASE_URL = "https://react.doctor/share";
|
|
6269
|
-
const
|
|
6294
|
+
const DOCS_URL = "https://www.react.doctor/docs";
|
|
6295
|
+
const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
|
|
6270
6296
|
const FETCH_TIMEOUT_MS = 1e4;
|
|
6271
6297
|
const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
|
|
6272
6298
|
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
@@ -6289,6 +6315,7 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
6289
6315
|
".oxlintrc.json"
|
|
6290
6316
|
];
|
|
6291
6317
|
const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
|
|
6318
|
+
const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
|
|
6292
6319
|
const SKILL_NAME = "react-doctor";
|
|
6293
6320
|
const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
6294
6321
|
const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
|
|
@@ -6301,6 +6328,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
|
6301
6328
|
"Accessibility",
|
|
6302
6329
|
"Maintainability"
|
|
6303
6330
|
];
|
|
6331
|
+
const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
|
|
6332
|
+
const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
|
|
6333
|
+
const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
|
|
6304
6334
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
6305
6335
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
6306
6336
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -6420,10 +6450,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
6420
6450
|
*/
|
|
6421
6451
|
const buildRuleSeverityControls = (config) => {
|
|
6422
6452
|
if (!config) return void 0;
|
|
6423
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
6453
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
6424
6454
|
return {
|
|
6425
6455
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
6426
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
6456
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
6457
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
6427
6458
|
};
|
|
6428
6459
|
};
|
|
6429
6460
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -6787,6 +6818,65 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
6787
6818
|
}
|
|
6788
6819
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
6789
6820
|
};
|
|
6821
|
+
const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
|
|
6822
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
6823
|
+
const findNearestPackageDirectory$1 = (filename) => {
|
|
6824
|
+
if (!filename) return null;
|
|
6825
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
6826
|
+
if (fromCache !== void 0) return fromCache;
|
|
6827
|
+
let currentDirectory = path.dirname(filename);
|
|
6828
|
+
while (true) {
|
|
6829
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
6830
|
+
let hasPackageJson = false;
|
|
6831
|
+
try {
|
|
6832
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
6833
|
+
} catch {
|
|
6834
|
+
hasPackageJson = false;
|
|
6835
|
+
}
|
|
6836
|
+
if (hasPackageJson) {
|
|
6837
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
6838
|
+
return currentDirectory;
|
|
6839
|
+
}
|
|
6840
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
6841
|
+
if (parentDirectory === currentDirectory) {
|
|
6842
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
6843
|
+
return null;
|
|
6844
|
+
}
|
|
6845
|
+
currentDirectory = parentDirectory;
|
|
6846
|
+
}
|
|
6847
|
+
};
|
|
6848
|
+
const readManifest = (packageJsonPath) => {
|
|
6849
|
+
try {
|
|
6850
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
6851
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
6852
|
+
return null;
|
|
6853
|
+
} catch {
|
|
6854
|
+
return null;
|
|
6855
|
+
}
|
|
6856
|
+
};
|
|
6857
|
+
const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
|
|
6858
|
+
const classifyByDirectoryCohort = (packageDirectory) => {
|
|
6859
|
+
let current = packageDirectory;
|
|
6860
|
+
while (true) {
|
|
6861
|
+
if (path.basename(current) === "apps") return "app";
|
|
6862
|
+
const parent = path.dirname(current);
|
|
6863
|
+
if (parent === current) return null;
|
|
6864
|
+
current = parent;
|
|
6865
|
+
}
|
|
6866
|
+
};
|
|
6867
|
+
const classifyPackageRole = (filename) => {
|
|
6868
|
+
if (!filename) return "unknown";
|
|
6869
|
+
const packageDirectory = findNearestPackageDirectory$1(filename);
|
|
6870
|
+
if (!packageDirectory) return "unknown";
|
|
6871
|
+
const cached = cachedRoleByPackageDirectory.get(packageDirectory);
|
|
6872
|
+
if (cached !== void 0) return cached;
|
|
6873
|
+
const manifest = readManifest(path.join(packageDirectory, "package.json"));
|
|
6874
|
+
let result;
|
|
6875
|
+
if (manifest && hasPublishContract(manifest)) result = "library";
|
|
6876
|
+
else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
|
|
6877
|
+
cachedRoleByPackageDirectory.set(packageDirectory, result);
|
|
6878
|
+
return result;
|
|
6879
|
+
};
|
|
6790
6880
|
/**
|
|
6791
6881
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
6792
6882
|
* accounting for the various shapes oxlint emits:
|
|
@@ -6949,6 +7039,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6949
7039
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
6950
7040
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
6951
7041
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
7042
|
+
const libraryFileCache = /* @__PURE__ */ new Map();
|
|
7043
|
+
const isLibraryFile = (filePath) => {
|
|
7044
|
+
let cached = libraryFileCache.get(filePath);
|
|
7045
|
+
if (cached === void 0) {
|
|
7046
|
+
cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
|
|
7047
|
+
libraryFileCache.set(filePath, cached);
|
|
7048
|
+
}
|
|
7049
|
+
return cached;
|
|
7050
|
+
};
|
|
6952
7051
|
const getFileLines = (filePath) => {
|
|
6953
7052
|
const cached = fileLinesCache.get(filePath);
|
|
6954
7053
|
if (cached !== void 0) return cached;
|
|
@@ -6975,6 +7074,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6975
7074
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
6976
7075
|
return false;
|
|
6977
7076
|
};
|
|
7077
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
7078
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
7079
|
+
return false;
|
|
7080
|
+
};
|
|
6978
7081
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
6979
7082
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
6980
7083
|
if (diagnostic.line <= 0) return false;
|
|
@@ -6989,8 +7092,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6989
7092
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
6990
7093
|
let current = diagnostic;
|
|
6991
7094
|
let explicitSeverityOverride;
|
|
7095
|
+
let explicitRuleOverride;
|
|
6992
7096
|
if (severityControls) {
|
|
6993
7097
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
7098
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
6994
7099
|
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
6995
7100
|
ruleKey,
|
|
6996
7101
|
category
|
|
@@ -6998,6 +7103,9 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6998
7103
|
if (explicitSeverityOverride === "off") return null;
|
|
6999
7104
|
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
7000
7105
|
}
|
|
7106
|
+
if (explicitRuleOverride === void 0) {
|
|
7107
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
7108
|
+
}
|
|
7001
7109
|
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
7002
7110
|
if (userConfig) {
|
|
7003
7111
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
@@ -7183,6 +7291,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
7183
7291
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
7184
7292
|
}).pipe(Effect.orDie));
|
|
7185
7293
|
/**
|
|
7294
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
7295
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
7296
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
7297
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
7298
|
+
*/
|
|
7299
|
+
const resolveScanConcurrency = (requested) => {
|
|
7300
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
7301
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
7302
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
7303
|
+
};
|
|
7304
|
+
/**
|
|
7186
7305
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
7187
7306
|
* startup so the eval harness can raise the budget under sandbox
|
|
7188
7307
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -7202,6 +7321,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
7202
7321
|
* tests that exercise the cap behavior.
|
|
7203
7322
|
*/
|
|
7204
7323
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
7324
|
+
/**
|
|
7325
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
7326
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
7327
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
7328
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
7329
|
+
* CI callers that never touch the flag:
|
|
7330
|
+
*
|
|
7331
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
7332
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
7333
|
+
* - a positive integer → that many workers (clamped)
|
|
7334
|
+
*
|
|
7335
|
+
* The resolved value is always within
|
|
7336
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
7337
|
+
*/
|
|
7338
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
7339
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
7340
|
+
if (raw === void 0) return 1;
|
|
7341
|
+
const normalized = raw.trim().toLowerCase();
|
|
7342
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
7343
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
7344
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
7345
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
7346
|
+
return resolveScanConcurrency(parsed);
|
|
7347
|
+
} }) {};
|
|
7205
7348
|
const DIAGNOSTIC_SURFACES = [
|
|
7206
7349
|
"cli",
|
|
7207
7350
|
"prComment",
|
|
@@ -7362,16 +7505,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
|
|
|
7362
7505
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
7363
7506
|
const loadConfigFromDirectory = (directory) => {
|
|
7364
7507
|
const configFilePath = path.join(directory, CONFIG_FILENAME);
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7508
|
+
let sawBrokenConfigFile = false;
|
|
7509
|
+
if (isFile(configFilePath)) {
|
|
7510
|
+
try {
|
|
7511
|
+
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
7512
|
+
const parsed = JSON.parse(fileContent);
|
|
7513
|
+
if (isPlainObject(parsed)) return {
|
|
7514
|
+
status: "found",
|
|
7515
|
+
loaded: {
|
|
7516
|
+
config: validateConfigTypes(parsed),
|
|
7517
|
+
sourceDirectory: directory
|
|
7518
|
+
}
|
|
7519
|
+
};
|
|
7520
|
+
warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
7521
|
+
} catch (error) {
|
|
7522
|
+
warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7523
|
+
}
|
|
7524
|
+
sawBrokenConfigFile = true;
|
|
7375
7525
|
}
|
|
7376
7526
|
const packageJsonPath = path.join(directory, "package.json");
|
|
7377
7527
|
if (isFile(packageJsonPath)) try {
|
|
@@ -7380,34 +7530,38 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
7380
7530
|
if (isPlainObject(packageJson)) {
|
|
7381
7531
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
7382
7532
|
if (isPlainObject(embeddedConfig)) return {
|
|
7383
|
-
|
|
7384
|
-
|
|
7533
|
+
status: "found",
|
|
7534
|
+
loaded: {
|
|
7535
|
+
config: validateConfigTypes(embeddedConfig),
|
|
7536
|
+
sourceDirectory: directory
|
|
7537
|
+
}
|
|
7385
7538
|
};
|
|
7386
7539
|
}
|
|
7387
|
-
} catch {
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7540
|
+
} catch {}
|
|
7541
|
+
return {
|
|
7542
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
7543
|
+
loaded: null
|
|
7544
|
+
};
|
|
7391
7545
|
};
|
|
7392
7546
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
7393
7547
|
const loadConfigWithSource = (rootDirectory) => {
|
|
7394
7548
|
const cached = cachedConfigs.get(rootDirectory);
|
|
7395
7549
|
if (cached !== void 0) return cached;
|
|
7396
|
-
const
|
|
7397
|
-
if (
|
|
7398
|
-
cachedConfigs.set(rootDirectory,
|
|
7399
|
-
return
|
|
7550
|
+
const localResult = loadConfigFromDirectory(rootDirectory);
|
|
7551
|
+
if (localResult.status === "found") {
|
|
7552
|
+
cachedConfigs.set(rootDirectory, localResult.loaded);
|
|
7553
|
+
return localResult.loaded;
|
|
7400
7554
|
}
|
|
7401
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
7555
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
|
|
7402
7556
|
cachedConfigs.set(rootDirectory, null);
|
|
7403
7557
|
return null;
|
|
7404
7558
|
}
|
|
7405
7559
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
7406
7560
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
7407
|
-
const
|
|
7408
|
-
if (
|
|
7409
|
-
cachedConfigs.set(rootDirectory,
|
|
7410
|
-
return
|
|
7561
|
+
const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
|
|
7562
|
+
if (ancestorResult.status === "found") {
|
|
7563
|
+
cachedConfigs.set(rootDirectory, ancestorResult.loaded);
|
|
7564
|
+
return ancestorResult.loaded;
|
|
7411
7565
|
}
|
|
7412
7566
|
if (isProjectBoundary(ancestorDirectory)) {
|
|
7413
7567
|
cachedConfigs.set(rootDirectory, null);
|
|
@@ -7432,11 +7586,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
|
7432
7586
|
}
|
|
7433
7587
|
return resolvedRootDir;
|
|
7434
7588
|
};
|
|
7435
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
7589
|
+
const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
7436
7590
|
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
7437
7591
|
const reactSubprojects = discoverReactSubprojects(directory);
|
|
7438
7592
|
if (reactSubprojects.length === 0) return null;
|
|
7439
7593
|
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
7594
|
+
if (options.allowAmbiguous === true) return null;
|
|
7440
7595
|
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
7441
7596
|
};
|
|
7442
7597
|
/**
|
|
@@ -7450,7 +7605,8 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
7450
7605
|
* project root, if configured.
|
|
7451
7606
|
* 4. Walk into a nested React subproject when the requested
|
|
7452
7607
|
* directory has no `package.json` of its own (raises
|
|
7453
|
-
* `AmbiguousProjectError` when multiple candidates exist
|
|
7608
|
+
* `AmbiguousProjectError` when multiple candidates exist unless
|
|
7609
|
+
* the caller opts into keeping the wrapper directory).
|
|
7454
7610
|
*
|
|
7455
7611
|
* Throws `ProjectNotFoundError` when neither the requested directory
|
|
7456
7612
|
* nor any discoverable nested project has a `package.json`.
|
|
@@ -7462,14 +7618,14 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
7462
7618
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
7463
7619
|
* shell in agreement on what "the scan directory" means.
|
|
7464
7620
|
*/
|
|
7465
|
-
const resolveScanTarget = (requestedDirectory) => {
|
|
7621
|
+
const resolveScanTarget = (requestedDirectory, options = {}) => {
|
|
7466
7622
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
7467
7623
|
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
7468
7624
|
const userConfig = loadedConfig?.config ?? null;
|
|
7469
7625
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
7470
7626
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
7471
7627
|
const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
|
|
7472
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
|
|
7628
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
|
|
7473
7629
|
if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
|
|
7474
7630
|
return {
|
|
7475
7631
|
resolvedDirectory,
|
|
@@ -7479,6 +7635,359 @@ const resolveScanTarget = (requestedDirectory) => {
|
|
|
7479
7635
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
7480
7636
|
};
|
|
7481
7637
|
};
|
|
7638
|
+
const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
|
|
7639
|
+
const buildExpoCheckContext = (rootDirectory, expoVersion) => {
|
|
7640
|
+
const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
|
|
7641
|
+
return {
|
|
7642
|
+
rootDirectory,
|
|
7643
|
+
packageJson,
|
|
7644
|
+
directDependencyNames: getDirectDependencyNames(packageJson),
|
|
7645
|
+
expoSdkMajor: getLowestDependencyMajor(expoVersion)
|
|
7646
|
+
};
|
|
7647
|
+
};
|
|
7648
|
+
const buildExpoDiagnostic = (input) => ({
|
|
7649
|
+
filePath: input.filePath ?? "package.json",
|
|
7650
|
+
plugin: "react-doctor",
|
|
7651
|
+
rule: input.rule,
|
|
7652
|
+
severity: input.severity ?? "warning",
|
|
7653
|
+
message: input.message,
|
|
7654
|
+
help: input.help,
|
|
7655
|
+
line: input.line ?? 0,
|
|
7656
|
+
column: input.column ?? 0,
|
|
7657
|
+
category: input.category ?? "Correctness"
|
|
7658
|
+
});
|
|
7659
|
+
const CRITICAL_OVERRIDE_NAMES = new Set([
|
|
7660
|
+
"@expo/cli",
|
|
7661
|
+
"@expo/config",
|
|
7662
|
+
"@expo/metro-config",
|
|
7663
|
+
"@expo/metro-runtime",
|
|
7664
|
+
"@expo/metro",
|
|
7665
|
+
"metro"
|
|
7666
|
+
]);
|
|
7667
|
+
const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
|
|
7668
|
+
const collectOverrideNames = (packageJson) => new Set([
|
|
7669
|
+
...Object.keys(packageJson.overrides ?? {}),
|
|
7670
|
+
...Object.keys(packageJson.resolutions ?? {}),
|
|
7671
|
+
...Object.keys(packageJson.pnpm?.overrides ?? {})
|
|
7672
|
+
]);
|
|
7673
|
+
const checkExpoDependencyOverrides = (context) => {
|
|
7674
|
+
const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
|
|
7675
|
+
if (overriddenCriticalNames.length === 0) return [];
|
|
7676
|
+
const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
|
|
7677
|
+
return [buildExpoDiagnostic({
|
|
7678
|
+
rule: "expo-no-conflicting-dependency-override",
|
|
7679
|
+
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`,
|
|
7680
|
+
help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
|
|
7681
|
+
})];
|
|
7682
|
+
};
|
|
7683
|
+
const isPathGitIgnored = (rootDirectory, absolutePath) => {
|
|
7684
|
+
const result = spawnSync("git", [
|
|
7685
|
+
"check-ignore",
|
|
7686
|
+
"-q",
|
|
7687
|
+
absolutePath
|
|
7688
|
+
], {
|
|
7689
|
+
cwd: rootDirectory,
|
|
7690
|
+
stdio: [
|
|
7691
|
+
"ignore",
|
|
7692
|
+
"ignore",
|
|
7693
|
+
"ignore"
|
|
7694
|
+
]
|
|
7695
|
+
});
|
|
7696
|
+
if (result.error) return null;
|
|
7697
|
+
if (result.status === 0) return true;
|
|
7698
|
+
if (result.status === 1) return false;
|
|
7699
|
+
return null;
|
|
7700
|
+
};
|
|
7701
|
+
const LOCAL_ENV_FILE_NAMES = [
|
|
7702
|
+
".env.local",
|
|
7703
|
+
".env.development.local",
|
|
7704
|
+
".env.production.local",
|
|
7705
|
+
".env.test.local"
|
|
7706
|
+
];
|
|
7707
|
+
const checkExpoEnvLocalFiles = (context) => {
|
|
7708
|
+
const { rootDirectory } = context;
|
|
7709
|
+
const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
|
|
7710
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
7711
|
+
if (!isFile(filePath)) return false;
|
|
7712
|
+
return isPathGitIgnored(rootDirectory, filePath) === false;
|
|
7713
|
+
});
|
|
7714
|
+
if (committedEnvFiles.length === 0) return [];
|
|
7715
|
+
return [buildExpoDiagnostic({
|
|
7716
|
+
rule: "expo-env-local-not-gitignored",
|
|
7717
|
+
category: "Security",
|
|
7718
|
+
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`,
|
|
7719
|
+
help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
|
|
7720
|
+
})];
|
|
7721
|
+
};
|
|
7722
|
+
const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
|
|
7723
|
+
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";
|
|
7724
|
+
const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
|
|
7725
|
+
const unimodulesEntry = (packageName) => ({
|
|
7726
|
+
packageName,
|
|
7727
|
+
rule: "expo-no-unimodules-packages",
|
|
7728
|
+
message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
|
|
7729
|
+
help: UNIMODULES_HELP
|
|
7730
|
+
});
|
|
7731
|
+
const FLAGGED_DEPENDENCIES = [
|
|
7732
|
+
unimodulesEntry("@unimodules/core"),
|
|
7733
|
+
unimodulesEntry("@unimodules/react-native-adapter"),
|
|
7734
|
+
unimodulesEntry("react-native-unimodules"),
|
|
7735
|
+
{
|
|
7736
|
+
packageName: "expo-cli",
|
|
7737
|
+
rule: "expo-no-cli-dependencies",
|
|
7738
|
+
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`",
|
|
7739
|
+
help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
|
|
7740
|
+
},
|
|
7741
|
+
{
|
|
7742
|
+
packageName: "eas-cli",
|
|
7743
|
+
rule: "expo-no-cli-dependencies",
|
|
7744
|
+
message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
|
|
7745
|
+
help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
|
|
7746
|
+
},
|
|
7747
|
+
{
|
|
7748
|
+
packageName: "expo-modules-autolinking",
|
|
7749
|
+
rule: "expo-no-redundant-dependency",
|
|
7750
|
+
message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
|
|
7751
|
+
help: "Remove `expo-modules-autolinking` from your package.json"
|
|
7752
|
+
},
|
|
7753
|
+
{
|
|
7754
|
+
packageName: "expo-dev-launcher",
|
|
7755
|
+
rule: "expo-no-redundant-dependency",
|
|
7756
|
+
message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7757
|
+
help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
|
|
7758
|
+
},
|
|
7759
|
+
{
|
|
7760
|
+
packageName: "expo-dev-menu",
|
|
7761
|
+
rule: "expo-no-redundant-dependency",
|
|
7762
|
+
message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7763
|
+
help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
|
|
7764
|
+
},
|
|
7765
|
+
{
|
|
7766
|
+
packageName: "expo-modules-core",
|
|
7767
|
+
rule: "expo-no-redundant-dependency",
|
|
7768
|
+
message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
|
|
7769
|
+
help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
|
|
7770
|
+
},
|
|
7771
|
+
{
|
|
7772
|
+
packageName: "@expo/metro-config",
|
|
7773
|
+
rule: "expo-no-redundant-dependency",
|
|
7774
|
+
message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
|
|
7775
|
+
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
|
|
7776
|
+
},
|
|
7777
|
+
{
|
|
7778
|
+
packageName: "@types/react-native",
|
|
7779
|
+
rule: "expo-no-redundant-dependency",
|
|
7780
|
+
message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
|
|
7781
|
+
help: "Remove `@types/react-native` from your package.json",
|
|
7782
|
+
minSdkMajor: 48
|
|
7783
|
+
},
|
|
7784
|
+
{
|
|
7785
|
+
packageName: "@expo/config-plugins",
|
|
7786
|
+
rule: "expo-no-redundant-dependency",
|
|
7787
|
+
message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
|
|
7788
|
+
help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
|
|
7789
|
+
minSdkMajor: 48
|
|
7790
|
+
},
|
|
7791
|
+
{
|
|
7792
|
+
packageName: "@expo/prebuild-config",
|
|
7793
|
+
rule: "expo-no-redundant-dependency",
|
|
7794
|
+
message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
|
|
7795
|
+
help: "Remove `@expo/prebuild-config` from your package.json",
|
|
7796
|
+
minSdkMajor: 53
|
|
7797
|
+
},
|
|
7798
|
+
{
|
|
7799
|
+
packageName: "expo-permissions",
|
|
7800
|
+
rule: "expo-no-redundant-dependency",
|
|
7801
|
+
message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
|
|
7802
|
+
help: "Remove `expo-permissions` and request permissions from the relevant module instead",
|
|
7803
|
+
minSdkMajor: 50
|
|
7804
|
+
},
|
|
7805
|
+
{
|
|
7806
|
+
packageName: "expo-app-loading",
|
|
7807
|
+
rule: "expo-no-redundant-dependency",
|
|
7808
|
+
message: "\"expo-app-loading\" was removed in SDK 49",
|
|
7809
|
+
help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
|
|
7810
|
+
minSdkMajor: 49
|
|
7811
|
+
},
|
|
7812
|
+
{
|
|
7813
|
+
packageName: "expo-firebase-analytics",
|
|
7814
|
+
rule: "expo-no-redundant-dependency",
|
|
7815
|
+
message: "\"expo-firebase-analytics\" was removed in SDK 48",
|
|
7816
|
+
help: FIREBASE_HELP,
|
|
7817
|
+
minSdkMajor: 48
|
|
7818
|
+
},
|
|
7819
|
+
{
|
|
7820
|
+
packageName: "expo-firebase-recaptcha",
|
|
7821
|
+
rule: "expo-no-redundant-dependency",
|
|
7822
|
+
message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
|
|
7823
|
+
help: FIREBASE_HELP,
|
|
7824
|
+
minSdkMajor: 48
|
|
7825
|
+
},
|
|
7826
|
+
{
|
|
7827
|
+
packageName: "expo-firebase-core",
|
|
7828
|
+
rule: "expo-no-redundant-dependency",
|
|
7829
|
+
message: "\"expo-firebase-core\" was removed in SDK 48",
|
|
7830
|
+
help: FIREBASE_HELP,
|
|
7831
|
+
minSdkMajor: 48
|
|
7832
|
+
}
|
|
7833
|
+
];
|
|
7834
|
+
const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
|
|
7835
|
+
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
|
|
7836
|
+
if (flaggedDependency.minSdkMajor === void 0) return true;
|
|
7837
|
+
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
|
|
7838
|
+
}).map((flaggedDependency) => buildExpoDiagnostic({
|
|
7839
|
+
rule: flaggedDependency.rule,
|
|
7840
|
+
message: flaggedDependency.message,
|
|
7841
|
+
help: flaggedDependency.help
|
|
7842
|
+
}));
|
|
7843
|
+
const findLocalModuleNativeFiles = (rootDirectory) => {
|
|
7844
|
+
const modulesDirectory = path.join(rootDirectory, "modules");
|
|
7845
|
+
if (!isDirectory(modulesDirectory)) return [];
|
|
7846
|
+
const nativeFilePaths = [];
|
|
7847
|
+
for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
|
|
7848
|
+
if (!moduleEntry.isDirectory()) continue;
|
|
7849
|
+
const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
|
|
7850
|
+
const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
|
|
7851
|
+
if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
|
|
7852
|
+
const iosDirectory = path.join(moduleDirectory, "ios");
|
|
7853
|
+
if (isDirectory(iosDirectory)) {
|
|
7854
|
+
for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
|
|
7855
|
+
}
|
|
7856
|
+
}
|
|
7857
|
+
return nativeFilePaths;
|
|
7858
|
+
};
|
|
7859
|
+
const checkExpoGitignore = (context) => {
|
|
7860
|
+
const { rootDirectory } = context;
|
|
7861
|
+
const diagnostics = [];
|
|
7862
|
+
const expoStateDirectory = path.join(rootDirectory, ".expo");
|
|
7863
|
+
if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
|
|
7864
|
+
rule: "expo-gitignore",
|
|
7865
|
+
message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
|
|
7866
|
+
help: "Add `.expo/` to your .gitignore"
|
|
7867
|
+
}));
|
|
7868
|
+
if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
|
|
7869
|
+
rule: "expo-gitignore",
|
|
7870
|
+
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",
|
|
7871
|
+
help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
|
|
7872
|
+
}));
|
|
7873
|
+
return diagnostics;
|
|
7874
|
+
};
|
|
7875
|
+
const LOCKFILE_NAMES = [
|
|
7876
|
+
"pnpm-lock.yaml",
|
|
7877
|
+
"yarn.lock",
|
|
7878
|
+
"package-lock.json",
|
|
7879
|
+
"bun.lockb",
|
|
7880
|
+
"bun.lock"
|
|
7881
|
+
];
|
|
7882
|
+
const checkExpoLockfile = (context) => {
|
|
7883
|
+
const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
|
|
7884
|
+
const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
|
|
7885
|
+
if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
|
|
7886
|
+
rule: "expo-lockfile",
|
|
7887
|
+
message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
|
|
7888
|
+
help: "Install dependencies with your package manager to generate a lock file, then commit it"
|
|
7889
|
+
})];
|
|
7890
|
+
if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
|
|
7891
|
+
rule: "expo-lockfile",
|
|
7892
|
+
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`,
|
|
7893
|
+
help: "Delete the lock files for the package managers you are not using and keep only one"
|
|
7894
|
+
})];
|
|
7895
|
+
return [];
|
|
7896
|
+
};
|
|
7897
|
+
const METRO_CONFIG_FILE_NAMES = [
|
|
7898
|
+
"metro.config.js",
|
|
7899
|
+
"metro.config.cjs",
|
|
7900
|
+
"metro.config.mjs",
|
|
7901
|
+
"metro.config.ts"
|
|
7902
|
+
];
|
|
7903
|
+
const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
|
|
7904
|
+
"expo/metro-config",
|
|
7905
|
+
"@sentry/react-native/metro",
|
|
7906
|
+
"getSentryExpoConfig"
|
|
7907
|
+
];
|
|
7908
|
+
const checkExpoMetroConfig = (context) => {
|
|
7909
|
+
const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
|
|
7910
|
+
if (metroConfigPath === void 0) return [];
|
|
7911
|
+
let contents;
|
|
7912
|
+
try {
|
|
7913
|
+
contents = fs.readFileSync(metroConfigPath, "utf-8");
|
|
7914
|
+
} catch {
|
|
7915
|
+
return [];
|
|
7916
|
+
}
|
|
7917
|
+
if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
|
|
7918
|
+
return [buildExpoDiagnostic({
|
|
7919
|
+
rule: "expo-metro-config",
|
|
7920
|
+
filePath: path.basename(metroConfigPath),
|
|
7921
|
+
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",
|
|
7922
|
+
help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
|
|
7923
|
+
})];
|
|
7924
|
+
};
|
|
7925
|
+
const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
|
|
7926
|
+
const checkExpoPackageJsonConflicts = (context) => {
|
|
7927
|
+
const { packageJson } = context;
|
|
7928
|
+
const diagnostics = [];
|
|
7929
|
+
const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
|
|
7930
|
+
if (conflictingScriptNames.length > 0) {
|
|
7931
|
+
const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
|
|
7932
|
+
const shadowsExpoCli = conflictingScriptNames.includes("expo");
|
|
7933
|
+
diagnostics.push(buildExpoDiagnostic({
|
|
7934
|
+
rule: "expo-package-json-conflict",
|
|
7935
|
+
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" : ""}`,
|
|
7936
|
+
help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
|
|
7937
|
+
}));
|
|
7938
|
+
}
|
|
7939
|
+
const packageName = packageJson.name;
|
|
7940
|
+
if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
|
|
7941
|
+
rule: "expo-package-json-conflict",
|
|
7942
|
+
message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
|
|
7943
|
+
help: "Rename your package so it no longer matches one of its dependencies"
|
|
7944
|
+
}));
|
|
7945
|
+
return diagnostics;
|
|
7946
|
+
};
|
|
7947
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
7948
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
7949
|
+
const checkExpoRouterReactNavigation = (context) => {
|
|
7950
|
+
const { expoSdkMajor } = context;
|
|
7951
|
+
if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
|
|
7952
|
+
if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
|
|
7953
|
+
if (!context.directDependencyNames.has("expo-router")) return [];
|
|
7954
|
+
const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
|
|
7955
|
+
if (reactNavigationNames.length === 0) return [];
|
|
7956
|
+
return [buildExpoDiagnostic({
|
|
7957
|
+
rule: "expo-router-no-react-navigation",
|
|
7958
|
+
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"}`,
|
|
7959
|
+
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/"
|
|
7960
|
+
})];
|
|
7961
|
+
};
|
|
7962
|
+
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
7963
|
+
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
7964
|
+
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
7965
|
+
const checkExpoVectorIcons = (context) => {
|
|
7966
|
+
if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
|
|
7967
|
+
const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
|
|
7968
|
+
const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
|
|
7969
|
+
if (!hasScopedPackage || !hasConflictingPackage) return [];
|
|
7970
|
+
return [buildExpoDiagnostic({
|
|
7971
|
+
rule: "expo-vector-icons-conflict",
|
|
7972
|
+
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",
|
|
7973
|
+
help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
|
|
7974
|
+
})];
|
|
7975
|
+
};
|
|
7976
|
+
const checkExpoProject = (rootDirectory, project) => {
|
|
7977
|
+
if (project.expoVersion === null) return [];
|
|
7978
|
+
const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
|
|
7979
|
+
return [
|
|
7980
|
+
...checkExpoFlaggedDependencies(context),
|
|
7981
|
+
...checkExpoDependencyOverrides(context),
|
|
7982
|
+
...checkExpoRouterReactNavigation(context),
|
|
7983
|
+
...checkExpoVectorIcons(context),
|
|
7984
|
+
...checkExpoPackageJsonConflicts(context),
|
|
7985
|
+
...checkExpoLockfile(context),
|
|
7986
|
+
...checkExpoGitignore(context),
|
|
7987
|
+
...checkExpoEnvLocalFiles(context),
|
|
7988
|
+
...checkExpoMetroConfig(context)
|
|
7989
|
+
];
|
|
7990
|
+
};
|
|
7482
7991
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
7483
7992
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
7484
7993
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -8756,6 +9265,7 @@ const buildCapabilities = (project) => {
|
|
|
8756
9265
|
const capabilities = /* @__PURE__ */ new Set();
|
|
8757
9266
|
capabilities.add(project.framework);
|
|
8758
9267
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
9268
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
8759
9269
|
const reactMajor = project.reactMajorVersion;
|
|
8760
9270
|
if (reactMajor !== null) {
|
|
8761
9271
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -8927,10 +9437,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
8927
9437
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
8928
9438
|
return fs.realpathSync(rootDirectory);
|
|
8929
9439
|
};
|
|
9440
|
+
const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
|
|
9441
|
+
if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
|
|
9442
|
+
return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
9443
|
+
};
|
|
8930
9444
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
8931
9445
|
const enabledRules = {};
|
|
8932
9446
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
8933
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
9447
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
8934
9448
|
if (severity === "off") continue;
|
|
8935
9449
|
enabledRules[ruleKey] = severity;
|
|
8936
9450
|
}
|
|
@@ -8972,7 +9486,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
8972
9486
|
category: rule.category
|
|
8973
9487
|
}, severityControls);
|
|
8974
9488
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
8975
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
9489
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
8976
9490
|
if (severity === "off") continue;
|
|
8977
9491
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
8978
9492
|
}
|
|
@@ -9029,6 +9543,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
9029
9543
|
}
|
|
9030
9544
|
return uniqueDiagnostics;
|
|
9031
9545
|
};
|
|
9546
|
+
/**
|
|
9547
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
9548
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
9549
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
9550
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
9551
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
9552
|
+
* batches do).
|
|
9553
|
+
*
|
|
9554
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
9555
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
9556
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
9557
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
9558
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
9559
|
+
* top of a still-running first one.
|
|
9560
|
+
*/
|
|
9561
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
9562
|
+
const results = new Array(items.length);
|
|
9563
|
+
if (items.length === 0) return results;
|
|
9564
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
9565
|
+
let nextIndex = 0;
|
|
9566
|
+
const errors = [];
|
|
9567
|
+
const runWorker = async () => {
|
|
9568
|
+
while (errors.length === 0) {
|
|
9569
|
+
const index = nextIndex;
|
|
9570
|
+
nextIndex += 1;
|
|
9571
|
+
if (index >= items.length) return;
|
|
9572
|
+
try {
|
|
9573
|
+
results[index] = await task(items[index], index);
|
|
9574
|
+
} catch (error) {
|
|
9575
|
+
errors.push(error);
|
|
9576
|
+
return;
|
|
9577
|
+
}
|
|
9578
|
+
}
|
|
9579
|
+
};
|
|
9580
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
9581
|
+
if (errors.length > 0) throw errors[0];
|
|
9582
|
+
return results;
|
|
9583
|
+
};
|
|
9032
9584
|
const getPublicEnvPrefix = (framework) => {
|
|
9033
9585
|
switch (framework) {
|
|
9034
9586
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -9711,6 +10263,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
9711
10263
|
*/
|
|
9712
10264
|
const spawnLintBatches = async (input) => {
|
|
9713
10265
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
10266
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
9714
10267
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
9715
10268
|
const allDiagnostics = [];
|
|
9716
10269
|
const droppedFiles = [];
|
|
@@ -9730,23 +10283,31 @@ const spawnLintBatches = async (input) => {
|
|
|
9730
10283
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
9731
10284
|
}
|
|
9732
10285
|
};
|
|
10286
|
+
let startedFileCount = 0;
|
|
9733
10287
|
let scannedFileCount = 0;
|
|
9734
|
-
|
|
9735
|
-
|
|
9736
|
-
const
|
|
9737
|
-
|
|
9738
|
-
|
|
9739
|
-
|
|
9740
|
-
|
|
9741
|
-
|
|
9742
|
-
|
|
10288
|
+
let displayedFileCount = 0;
|
|
10289
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
10290
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
10291
|
+
if (displayedFileCount < ceiling) {
|
|
10292
|
+
displayedFileCount += 1;
|
|
10293
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10294
|
+
}
|
|
10295
|
+
}, 50) : null;
|
|
10296
|
+
progressTimer?.unref?.();
|
|
10297
|
+
try {
|
|
10298
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
10299
|
+
startedFileCount += batch.length;
|
|
9743
10300
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
9744
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
9745
10301
|
scannedFileCount += batch.length;
|
|
9746
|
-
onFileProgress
|
|
9747
|
-
|
|
9748
|
-
|
|
9749
|
-
|
|
10302
|
+
if (onFileProgress) {
|
|
10303
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
10304
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10305
|
+
}
|
|
10306
|
+
return batchDiagnostics;
|
|
10307
|
+
});
|
|
10308
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
10309
|
+
} finally {
|
|
10310
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
9750
10311
|
}
|
|
9751
10312
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
9752
10313
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -9873,7 +10434,8 @@ const runOxlint = async (options) => {
|
|
|
9873
10434
|
onPartialFailure,
|
|
9874
10435
|
onFileProgress: options.onFileProgress,
|
|
9875
10436
|
spawnTimeoutMs,
|
|
9876
|
-
outputMaxBytes
|
|
10437
|
+
outputMaxBytes,
|
|
10438
|
+
concurrency: options.concurrency
|
|
9877
10439
|
});
|
|
9878
10440
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
9879
10441
|
try {
|
|
@@ -9941,6 +10503,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9941
10503
|
const partialFailures = yield* LintPartialFailures;
|
|
9942
10504
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
9943
10505
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
10506
|
+
const concurrency = yield* OxlintConcurrency;
|
|
9944
10507
|
const collectedFailures = [];
|
|
9945
10508
|
const diagnostics = yield* Effect.tryPromise({
|
|
9946
10509
|
try: () => runOxlint({
|
|
@@ -9959,7 +10522,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9959
10522
|
},
|
|
9960
10523
|
onFileProgress: input.onFileProgress,
|
|
9961
10524
|
spawnTimeoutMs,
|
|
9962
|
-
outputMaxBytes
|
|
10525
|
+
outputMaxBytes,
|
|
10526
|
+
concurrency
|
|
9963
10527
|
}),
|
|
9964
10528
|
catch: ensureReactDoctorError
|
|
9965
10529
|
});
|
|
@@ -10283,7 +10847,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10283
10847
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
10284
10848
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
10285
10849
|
const isDiffMode = input.includePaths.length > 0;
|
|
10286
|
-
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ??
|
|
10850
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
10287
10851
|
const transform = buildDiagnosticPipeline({
|
|
10288
10852
|
rootDirectory: scanDirectory,
|
|
10289
10853
|
userConfig: resolvedConfig.config,
|
|
@@ -10292,7 +10856,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10292
10856
|
showWarnings
|
|
10293
10857
|
});
|
|
10294
10858
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
10295
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10859
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10860
|
+
...checkReducedMotion(scanDirectory),
|
|
10861
|
+
...checkPnpmHardening(scanDirectory),
|
|
10862
|
+
...checkExpoProject(scanDirectory, project)
|
|
10863
|
+
];
|
|
10296
10864
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
10297
10865
|
const lintFailure = yield* Ref.make({
|
|
10298
10866
|
didFail: false,
|
|
@@ -10304,6 +10872,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10304
10872
|
didFail: false,
|
|
10305
10873
|
reason: null
|
|
10306
10874
|
});
|
|
10875
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
10876
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
10307
10877
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
10308
10878
|
const scanStartTime = Date.now();
|
|
10309
10879
|
let lastReportedTotalFileCount = 0;
|
|
@@ -10320,7 +10890,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10320
10890
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
10321
10891
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
10322
10892
|
lastReportedTotalFileCount = totalFileCount;
|
|
10323
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
10893
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
10324
10894
|
}
|
|
10325
10895
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
10326
10896
|
yield* Ref.set(lintFailure, {
|
|
@@ -10352,7 +10922,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10352
10922
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
10353
10923
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
10354
10924
|
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
10355
|
-
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
|
|
10925
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
10356
10926
|
yield* reporterService.finalize;
|
|
10357
10927
|
const finalDiagnostics = [
|
|
10358
10928
|
...envCollected,
|
|
@@ -10404,7 +10974,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10404
10974
|
"inspect.isCi": input.isCi,
|
|
10405
10975
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
10406
10976
|
} }));
|
|
10407
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
10408
10977
|
const parseNodeVersion = (versionString) => {
|
|
10409
10978
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
10410
10979
|
return {
|
|
@@ -10727,6 +11296,26 @@ const buildJsonReport = (input) => {
|
|
|
10727
11296
|
};
|
|
10728
11297
|
};
|
|
10729
11298
|
/**
|
|
11299
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
11300
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
11301
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
11302
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
11303
|
+
* branch logic lives here once.
|
|
11304
|
+
*/
|
|
11305
|
+
const buildSkippedChecks = (input) => {
|
|
11306
|
+
const skippedChecks = [];
|
|
11307
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
11308
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
11309
|
+
const skippedCheckReasons = {};
|
|
11310
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
11311
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
11312
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
11313
|
+
return {
|
|
11314
|
+
skippedChecks,
|
|
11315
|
+
skippedCheckReasons
|
|
11316
|
+
};
|
|
11317
|
+
};
|
|
11318
|
+
/**
|
|
10730
11319
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
10731
11320
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
10732
11321
|
* spawn, not `spawnSync`).
|
|
@@ -10831,12 +11420,32 @@ const highlighter = {
|
|
|
10831
11420
|
bold: import_picocolors.default.bold
|
|
10832
11421
|
};
|
|
10833
11422
|
/**
|
|
10834
|
-
*
|
|
10835
|
-
* `
|
|
10836
|
-
*
|
|
10837
|
-
*
|
|
11423
|
+
* Override picocolors' automatic color detection. picocolors decides
|
|
11424
|
+
* once, at import time, from `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY.
|
|
11425
|
+
* This lets the CLI honor an explicit `--color` / `--no-color` flag
|
|
11426
|
+
* (clig.dev, Output: "Disable color … if the user requested it") by
|
|
11427
|
+
* swapping in a fresh set of formatters. Call it before any colored
|
|
11428
|
+
* output is produced. Every call site reads `highlighter.<method>` at
|
|
11429
|
+
* call time, so reassigning the properties propagates everywhere.
|
|
11430
|
+
*/
|
|
11431
|
+
const setColorEnabled = (enabled) => {
|
|
11432
|
+
const colors = import_picocolors.default.createColors(enabled);
|
|
11433
|
+
highlighter.error = colors.red;
|
|
11434
|
+
highlighter.warn = colors.yellow;
|
|
11435
|
+
highlighter.info = colors.cyan;
|
|
11436
|
+
highlighter.success = colors.green;
|
|
11437
|
+
highlighter.dim = colors.dim;
|
|
11438
|
+
highlighter.gray = colors.gray;
|
|
11439
|
+
highlighter.bold = colors.bold;
|
|
11440
|
+
};
|
|
11441
|
+
/**
|
|
11442
|
+
* Canonical URL for a rule's documentation page — its reviewer-tested fix
|
|
11443
|
+
* recipe rendered for humans — served at
|
|
11444
|
+
* `https://www.react.doctor/docs/rules/<plugin>/<rule>`. The CLI links here
|
|
11445
|
+
* from its fix-recipe directive so each fix follows the canonical recipe
|
|
11446
|
+
* instead of being improvised per diagnostic.
|
|
10838
11447
|
*/
|
|
10839
|
-
const
|
|
11448
|
+
const buildRuleDocsUrl = (plugin, rule) => `${DOCS_RULES_BASE_URL}/${plugin}/${rule}`;
|
|
10840
11449
|
const groupBy = (items, keyFn) => {
|
|
10841
11450
|
const groups = /* @__PURE__ */ new Map();
|
|
10842
11451
|
for (const item of items) {
|
|
@@ -10849,8 +11458,8 @@ const groupBy = (items, keyFn) => {
|
|
|
10849
11458
|
};
|
|
10850
11459
|
/**
|
|
10851
11460
|
* Whether a diagnostic's rule has a published per-rule fix recipe at
|
|
10852
|
-
* `${
|
|
10853
|
-
* (see `
|
|
11461
|
+
* `${DOCS_RULES_BASE_URL}/react-doctor/<rule>`
|
|
11462
|
+
* (see `buildRuleDocsUrl`).
|
|
10854
11463
|
*
|
|
10855
11464
|
* Recipes are generated from react-doctor's own engine rules, so only
|
|
10856
11465
|
* those resolve. Dead-code (`deslop`), the synthetic environment and
|
|
@@ -10862,6 +11471,46 @@ const groupBy = (items, keyFn) => {
|
|
|
10862
11471
|
*/
|
|
10863
11472
|
const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
|
|
10864
11473
|
//#endregion
|
|
11474
|
+
//#region src/cli/utils/constants.ts
|
|
11475
|
+
const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
|
|
11476
|
+
const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
|
|
11477
|
+
const SENTRY_DSN = "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920";
|
|
11478
|
+
//#endregion
|
|
11479
|
+
//#region src/cli/utils/version.ts
|
|
11480
|
+
const VERSION = "0.2.14-dev.0938376";
|
|
11481
|
+
//#endregion
|
|
11482
|
+
//#region src/instrument.ts
|
|
11483
|
+
let isInitialized = false;
|
|
11484
|
+
const shouldEnableSentry = () => {
|
|
11485
|
+
if (process.argv.includes("--no-score") || process.argv.includes("--no-telemetry")) return false;
|
|
11486
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
|
|
11487
|
+
return true;
|
|
11488
|
+
};
|
|
11489
|
+
/**
|
|
11490
|
+
* Initializes the Sentry Node SDK for CLI crash reporting. Invoked as
|
|
11491
|
+
* the first statement of the CLI entry (`cli/index.ts`) so the SDK's
|
|
11492
|
+
* global `uncaughtException` / `unhandledRejection` handlers are armed
|
|
11493
|
+
* before any command runs.
|
|
11494
|
+
*
|
|
11495
|
+
* Exported as a function rather than a bare side-effecting import
|
|
11496
|
+
* because the package declares `"sideEffects": false`, which lets the
|
|
11497
|
+
* bundler tree-shake side-effect-only modules. An explicit call keeps
|
|
11498
|
+
* the initialization in the published `dist/cli.js`.
|
|
11499
|
+
*
|
|
11500
|
+
* Scoped to the CLI application only — the programmatic
|
|
11501
|
+
* `@react-doctor/api` library never initializes Sentry, so importing
|
|
11502
|
+
* `diagnose()` into a consumer app can't hijack their telemetry.
|
|
11503
|
+
*/
|
|
11504
|
+
const initializeSentry = () => {
|
|
11505
|
+
if (isInitialized || !shouldEnableSentry()) return;
|
|
11506
|
+
isInitialized = true;
|
|
11507
|
+
Sentry.init({
|
|
11508
|
+
dsn: SENTRY_DSN,
|
|
11509
|
+
sendDefaultPii: true,
|
|
11510
|
+
release: VERSION
|
|
11511
|
+
});
|
|
11512
|
+
};
|
|
11513
|
+
//#endregion
|
|
10865
11514
|
//#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
10866
11515
|
const ANSI_BACKGROUND_OFFSET = 10;
|
|
10867
11516
|
const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
|
|
@@ -13815,23 +14464,60 @@ const CI_ENVIRONMENT_VARIABLES = [
|
|
|
13815
14464
|
"GITLAB_CI",
|
|
13816
14465
|
"CIRCLECI"
|
|
13817
14466
|
];
|
|
13818
|
-
const
|
|
13819
|
-
"
|
|
13820
|
-
"
|
|
13821
|
-
"
|
|
13822
|
-
"
|
|
13823
|
-
"
|
|
13824
|
-
"
|
|
13825
|
-
"
|
|
13826
|
-
"
|
|
13827
|
-
"
|
|
13828
|
-
"
|
|
13829
|
-
"
|
|
14467
|
+
const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
|
|
14468
|
+
["GITHUB_ACTIONS", "github-actions"],
|
|
14469
|
+
["GITLAB_CI", "gitlab-ci"],
|
|
14470
|
+
["CIRCLECI", "circleci"],
|
|
14471
|
+
["BUILDKITE", "buildkite"],
|
|
14472
|
+
["JENKINS_URL", "jenkins"],
|
|
14473
|
+
["TF_BUILD", "azure-pipelines"],
|
|
14474
|
+
["CODEBUILD_BUILD_ID", "aws-codebuild"],
|
|
14475
|
+
["TEAMCITY_VERSION", "teamcity"],
|
|
14476
|
+
["BITBUCKET_BUILD_NUMBER", "bitbucket"],
|
|
14477
|
+
["TRAVIS", "travis"],
|
|
14478
|
+
["DRONE", "drone"]
|
|
14479
|
+
];
|
|
14480
|
+
const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
|
|
14481
|
+
["CLAUDECODE", "claude-code"],
|
|
14482
|
+
["CLAUDE_CODE", "claude-code"],
|
|
14483
|
+
["CURSOR_AGENT", "cursor"],
|
|
14484
|
+
["CODEX_CI", "codex"],
|
|
14485
|
+
["CODEX_SANDBOX", "codex"],
|
|
14486
|
+
["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
|
|
14487
|
+
["OPENCODE", "opencode"],
|
|
14488
|
+
["GOOSE_TERMINAL", "goose"],
|
|
14489
|
+
["AMP_THREAD_ID", "amp"]
|
|
13830
14490
|
];
|
|
14491
|
+
const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
|
|
13831
14492
|
const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
|
|
13832
14493
|
const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
|
|
13833
|
-
|
|
13834
|
-
const
|
|
14494
|
+
[...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
|
|
14495
|
+
const FALSY_CI_FLAG_VALUES = new Set([
|
|
14496
|
+
"",
|
|
14497
|
+
"0",
|
|
14498
|
+
"false"
|
|
14499
|
+
]);
|
|
14500
|
+
const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
|
|
14501
|
+
const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
|
|
14502
|
+
const detectCiProvider = () => {
|
|
14503
|
+
for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
|
|
14504
|
+
return isCiFlagSet(process.env.CI) ? "unknown" : null;
|
|
14505
|
+
};
|
|
14506
|
+
const detectCodingAgentFromValue = () => {
|
|
14507
|
+
for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
|
|
14508
|
+
const value = process.env[environmentVariable]?.toLowerCase();
|
|
14509
|
+
if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
|
|
14510
|
+
}
|
|
14511
|
+
return null;
|
|
14512
|
+
};
|
|
14513
|
+
const detectCodingAgent = () => {
|
|
14514
|
+
for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
|
|
14515
|
+
const agentFromValue = detectCodingAgentFromValue();
|
|
14516
|
+
if (agentFromValue) return agentFromValue;
|
|
14517
|
+
if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
|
|
14518
|
+
return null;
|
|
14519
|
+
};
|
|
14520
|
+
const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
|
|
13835
14521
|
const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
|
|
13836
14522
|
//#endregion
|
|
13837
14523
|
//#region src/cli/utils/is-non-interactive-environment.ts
|
|
@@ -13933,9 +14619,8 @@ const buildSpinnerProgressHandle = (text) => {
|
|
|
13933
14619
|
* construction and post-scan rendering — layer wiring is its own
|
|
13934
14620
|
* concern with its own contract.
|
|
13935
14621
|
*
|
|
13936
|
-
* Same shape as
|
|
13937
|
-
*
|
|
13938
|
-
* differences specific to the CLI path:
|
|
14622
|
+
* Same service shape as `@react-doctor/api → diagnose()`'s
|
|
14623
|
+
* `buildDiagnoseLayer`, with the differences specific to the CLI path:
|
|
13939
14624
|
*
|
|
13940
14625
|
* - **Config**: when the caller passes `configOverride`, the
|
|
13941
14626
|
* already-loaded config is provided via `Config.layerOf` instead
|
|
@@ -13961,7 +14646,8 @@ const buildRuntimeLayers = (input) => {
|
|
|
13961
14646
|
resolvedDirectory: input.directory,
|
|
13962
14647
|
configSourceDirectory: input.configSourceDirectory
|
|
13963
14648
|
}) : Config.layerNode;
|
|
13964
|
-
|
|
14649
|
+
const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
|
|
14650
|
+
return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
|
|
13965
14651
|
};
|
|
13966
14652
|
//#endregion
|
|
13967
14653
|
//#region src/cli/utils/noop-console.ts
|
|
@@ -14048,8 +14734,10 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
|
|
|
14048
14734
|
return priorityB - priorityA;
|
|
14049
14735
|
};
|
|
14050
14736
|
const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
|
|
14051
|
-
const
|
|
14052
|
-
const
|
|
14737
|
+
const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
|
|
14738
|
+
const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
|
|
14739
|
+
const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
|
|
14740
|
+
const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
|
|
14053
14741
|
//#endregion
|
|
14054
14742
|
//#region src/cli/utils/box-text.ts
|
|
14055
14743
|
const ESCAPE = String.fromCharCode(27);
|
|
@@ -14180,15 +14868,17 @@ const buildVerboseSiteMap = (diagnostics) => {
|
|
|
14180
14868
|
return fileSites;
|
|
14181
14869
|
};
|
|
14182
14870
|
const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
|
|
14871
|
+
const formatTrailingSiteBadge = (count) => {
|
|
14872
|
+
const badge = formatSiteCountBadge(count);
|
|
14873
|
+
return badge.length > 0 ? ` ${highlighter.gray(badge)}` : "";
|
|
14874
|
+
};
|
|
14183
14875
|
const categoryTopRuleKey = (categoryGroup) => categoryGroup.ruleGroups[0][0];
|
|
14184
14876
|
const buildCategoryDiagnosticGroups = (diagnostics, rulePriority) => {
|
|
14185
|
-
return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
|
|
14186
|
-
|
|
14187
|
-
|
|
14188
|
-
|
|
14189
|
-
|
|
14190
|
-
};
|
|
14191
|
-
}).toSorted((categoryGroupA, categoryGroupB) => {
|
|
14877
|
+
return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => ({
|
|
14878
|
+
category,
|
|
14879
|
+
diagnostics: categoryDiagnostics,
|
|
14880
|
+
ruleGroups: buildSortedRuleGroups(categoryDiagnostics, rulePriority)
|
|
14881
|
+
})).toSorted((categoryGroupA, categoryGroupB) => {
|
|
14192
14882
|
const priorityDelta = compareByRulePriority(categoryTopRuleKey(categoryGroupA), categoryTopRuleKey(categoryGroupB), rulePriority);
|
|
14193
14883
|
if (priorityDelta !== 0) return priorityDelta;
|
|
14194
14884
|
return categoryGroupA.category.localeCompare(categoryGroupB.category);
|
|
@@ -14204,6 +14894,7 @@ const buildCompactCategoryLine = (categoryGroup) => {
|
|
|
14204
14894
|
};
|
|
14205
14895
|
const TOP_ERROR_DETAIL_INDENT = " ";
|
|
14206
14896
|
const pickRepresentativeDiagnostic = (ruleDiagnostics) => ruleDiagnostics.find((diagnostic) => diagnostic.line > 0) ?? ruleDiagnostics[0];
|
|
14897
|
+
const isErrorRuleGroup = (ruleDiagnostics) => pickRepresentativeDiagnostic(ruleDiagnostics).severity === "error";
|
|
14207
14898
|
const FRAME_CONTEXT_REACH_LINES = 3;
|
|
14208
14899
|
const clusterNearbyDiagnostics = (diagnostics) => {
|
|
14209
14900
|
const byFile = groupBy(diagnostics, (diagnostic) => diagnostic.filePath);
|
|
@@ -14235,17 +14926,17 @@ const formatClusterLocation = (cluster) => {
|
|
|
14235
14926
|
if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
|
|
14236
14927
|
return `${filePath}:${cluster.startLine}`;
|
|
14237
14928
|
};
|
|
14238
|
-
const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
|
|
14929
|
+
const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
|
|
14239
14930
|
const lead = cluster.diagnostics[0];
|
|
14240
14931
|
const isMultiSite = cluster.diagnostics.length > 1;
|
|
14241
14932
|
const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
|
|
14242
|
-
const codeFrame = buildCodeFrame({
|
|
14933
|
+
const codeFrame = renderCodeFrame ? buildCodeFrame({
|
|
14243
14934
|
filePath: lead.filePath,
|
|
14244
14935
|
line: cluster.startLine,
|
|
14245
14936
|
column: isMultiSite ? 0 : lead.column,
|
|
14246
14937
|
endLine: isMultiSite ? cluster.endLine : void 0,
|
|
14247
14938
|
rootDirectory: resolveSourceRoot(lead)
|
|
14248
|
-
});
|
|
14939
|
+
}) : null;
|
|
14249
14940
|
if (codeFrame) lines.push(indentMultilineText(boxText(codeFrame, 60), TOP_ERROR_DETAIL_INDENT));
|
|
14250
14941
|
const seenHints = /* @__PURE__ */ new Set();
|
|
14251
14942
|
for (const diagnostic of cluster.diagnostics) if (diagnostic.suppressionHint && !seenHints.has(diagnostic.suppressionHint)) {
|
|
@@ -14257,23 +14948,60 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
|
|
|
14257
14948
|
const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite) => {
|
|
14258
14949
|
const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
|
|
14259
14950
|
const { severity } = representative;
|
|
14260
|
-
const
|
|
14261
|
-
const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
|
|
14951
|
+
const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
|
|
14262
14952
|
const headline = colorizeBySeverity(`${representative.category}: ${representative.title ?? ruleKey}`, severity);
|
|
14263
14953
|
const lines = [` ${colorizeBySeverity(severity === "error" ? "✗" : "⚠", severity)} ${headline}${trailingBadge}`];
|
|
14264
14954
|
if (!renderEverySite) for (const explanationLine of wrapTextToWidth(representative.message, 60, { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
|
|
14265
14955
|
if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, 60, { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
|
|
14956
|
+
const renderCodeFrame = severity === "error";
|
|
14266
14957
|
const sites = renderEverySite ? ruleDiagnostics : [representative];
|
|
14267
|
-
for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot));
|
|
14958
|
+
for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
|
|
14959
|
+
return lines;
|
|
14960
|
+
};
|
|
14961
|
+
const WARNING_DETAIL_INDENT = " ";
|
|
14962
|
+
const computeRuleNameColumnWidth = (ruleKeys) => ruleKeys.reduce((widest, ruleKey) => Math.max(widest, ruleKey.length), 36);
|
|
14963
|
+
const padRuleNameToColumn = (ruleName, columnWidth) => ruleName.length >= columnWidth ? ruleName : ruleName + " ".repeat(columnWidth - ruleName.length);
|
|
14964
|
+
const buildWarningHeaderLine = (ruleKey, siteCount, ruleNameColumnWidth) => {
|
|
14965
|
+
const ruleName = formatSiteCountBadge(siteCount).length > 0 ? padRuleNameToColumn(ruleKey, ruleNameColumnWidth) : ruleKey;
|
|
14966
|
+
return ` ${highlighter.warn("⚠")} ${ruleName}${formatTrailingSiteBadge(siteCount)}`;
|
|
14967
|
+
};
|
|
14968
|
+
const buildWarningRuleBlock = (ruleKey, ruleDiagnostics, ruleNameColumnWidth, isAgentEnvironment) => {
|
|
14969
|
+
const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
|
|
14970
|
+
const lines = [buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth)];
|
|
14971
|
+
if (!isAgentEnvironment) {
|
|
14972
|
+
const learnMoreLine = formatLearnMoreLine(representative);
|
|
14973
|
+
if (learnMoreLine) lines.push(`${WARNING_DETAIL_INDENT}${highlighter.info(learnMoreLine)}`);
|
|
14974
|
+
}
|
|
14975
|
+
lines.push(highlighter.gray(indentMultilineText(representative.message, WARNING_DETAIL_INDENT)));
|
|
14976
|
+
if (representative.help) lines.push(highlighter.gray(indentMultilineText(`→ ${representative.help}`, WARNING_DETAIL_INDENT)));
|
|
14977
|
+
if (isAgentEnvironment) {
|
|
14978
|
+
const fixRecipeLine = formatFixRecipeLine(representative);
|
|
14979
|
+
if (fixRecipeLine) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${fixRecipeLine}`));
|
|
14980
|
+
}
|
|
14981
|
+
for (const [filePath, sites] of buildVerboseSiteMap(ruleDiagnostics)) {
|
|
14982
|
+
if (sites.length === 0) {
|
|
14983
|
+
lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}`));
|
|
14984
|
+
continue;
|
|
14985
|
+
}
|
|
14986
|
+
for (const site of sites) {
|
|
14987
|
+
lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}:${site.line}`));
|
|
14988
|
+
if (site.suppressionHint) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT} ↳ ${site.suppressionHint}`));
|
|
14989
|
+
}
|
|
14990
|
+
}
|
|
14268
14991
|
return lines;
|
|
14269
14992
|
};
|
|
14270
|
-
const
|
|
14271
|
-
|
|
14993
|
+
const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
|
|
14994
|
+
const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => selectErrorRuleGroups(diagnostics, rulePriority).slice(0, limit);
|
|
14995
|
+
const buildMoreRulesLine = (hiddenRuleCount, severityNoun, accent) => {
|
|
14996
|
+
const ruleNoun = hiddenRuleCount === 1 ? "rule" : "rules";
|
|
14997
|
+
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
14998
|
};
|
|
14273
14999
|
const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
|
|
14274
15000
|
const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
|
|
14275
|
-
const
|
|
15001
|
+
const errorRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority);
|
|
15002
|
+
const topRuleGroups = errorRuleGroups.slice(0, 3);
|
|
14276
15003
|
if (topRuleGroups.length === 0) return [];
|
|
15004
|
+
const hiddenRuleCount = errorRuleGroups.length - topRuleGroups.length;
|
|
14277
15005
|
const lines = [
|
|
14278
15006
|
highlighter.dim(` ${"─".repeat(60)}`),
|
|
14279
15007
|
` ${highlighter.bold(`Top ${topRuleGroups.length} ${topRuleGroups.length === 1 ? "error" : "errors"} you should fix`)}`,
|
|
@@ -14283,6 +15011,23 @@ const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
|
|
|
14283
15011
|
lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false));
|
|
14284
15012
|
lines.push("");
|
|
14285
15013
|
}
|
|
15014
|
+
if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "errors", highlighter.error));
|
|
15015
|
+
return lines;
|
|
15016
|
+
};
|
|
15017
|
+
const buildWarningsListLines = (diagnostics, rulePriority) => {
|
|
15018
|
+
const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
|
|
15019
|
+
if (warningDiagnostics.length === 0) return [];
|
|
15020
|
+
const sortedRuleGroups = buildSortedRuleGroups(warningDiagnostics, rulePriority);
|
|
15021
|
+
const shownRuleGroups = sortedRuleGroups.slice(0, 10);
|
|
15022
|
+
const hiddenRuleCount = sortedRuleGroups.length - shownRuleGroups.length;
|
|
15023
|
+
const ruleNameColumnWidth = computeRuleNameColumnWidth(shownRuleGroups.map(([ruleKey]) => ruleKey));
|
|
15024
|
+
const lines = [
|
|
15025
|
+
highlighter.dim(` ${"─".repeat(60)}`),
|
|
15026
|
+
` ${highlighter.bold(`${warningDiagnostics.length} ${warningDiagnostics.length === 1 ? "warning" : "warnings"}`)}`,
|
|
15027
|
+
""
|
|
15028
|
+
];
|
|
15029
|
+
for (const [ruleKey, ruleDiagnostics] of shownRuleGroups) lines.push(buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth));
|
|
15030
|
+
if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "warnings", highlighter.warn));
|
|
14286
15031
|
return lines;
|
|
14287
15032
|
};
|
|
14288
15033
|
const buildCategoryBreakdownLines = (diagnostics, rulePriority) => buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCompactCategoryLine);
|
|
@@ -14309,12 +15054,18 @@ const buildCountsSummaryLines = (diagnostics) => {
|
|
|
14309
15054
|
* single Effect.forEach over Console.log so failures or fiber
|
|
14310
15055
|
* interruption produce predictable partial output.
|
|
14311
15056
|
*/
|
|
14312
|
-
const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority) => Effect.gen(function* () {
|
|
15057
|
+
const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false) => Effect.gen(function* () {
|
|
14313
15058
|
const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
|
|
14314
15059
|
let detailLines;
|
|
14315
15060
|
if (!isVerbose) detailLines = buildTopErrorsLines(diagnostics, resolveSourceRoot, rulePriority);
|
|
14316
|
-
else
|
|
14317
|
-
|
|
15061
|
+
else {
|
|
15062
|
+
const sortedRuleGroups = buildSortedRuleGroups(diagnostics, rulePriority);
|
|
15063
|
+
const warningRuleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.filter(([, ruleDiagnostics]) => !isErrorRuleGroup(ruleDiagnostics)).map(([ruleKey]) => ruleKey));
|
|
15064
|
+
detailLines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => {
|
|
15065
|
+
return [...isErrorRuleGroup(ruleDiagnostics) ? buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true) : buildWarningRuleBlock(ruleKey, ruleDiagnostics, warningRuleNameColumnWidth, isAgentEnvironment), ""];
|
|
15066
|
+
});
|
|
15067
|
+
}
|
|
15068
|
+
const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines, isVerbose ? [] : buildWarningsListLines(diagnostics, rulePriority));
|
|
14318
15069
|
for (const line of lines) yield* Console.log(line);
|
|
14319
15070
|
});
|
|
14320
15071
|
const formatElapsedTime = (elapsedMilliseconds) => {
|
|
@@ -14364,10 +15115,6 @@ const colorizeByScore = (text, score) => {
|
|
|
14364
15115
|
return highlighter.error(text);
|
|
14365
15116
|
};
|
|
14366
15117
|
//#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
15118
|
//#region src/cli/utils/render-score-header.ts
|
|
14372
15119
|
const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
|
|
14373
15120
|
const RAINBOW_GRADIENT_WIDTH = 80;
|
|
@@ -14560,8 +15307,7 @@ const printNoScoreHeader = (noScoreMessage) => Effect.gen(function* () {
|
|
|
14560
15307
|
const writeDiagnosticsDirectory = (diagnostics) => {
|
|
14561
15308
|
const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
|
|
14562
15309
|
mkdirSync(outputDirectory, { recursive: true });
|
|
14563
|
-
const
|
|
14564
|
-
for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
|
|
15310
|
+
for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(diagnostics)) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
|
|
14565
15311
|
writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
|
|
14566
15312
|
return outputDirectory;
|
|
14567
15313
|
};
|
|
@@ -14581,7 +15327,14 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
|
|
|
14581
15327
|
};
|
|
14582
15328
|
const printVerboseTip = (diagnostics, isVerbose) => Effect.gen(function* () {
|
|
14583
15329
|
if (isVerbose || diagnostics.length === 0) return;
|
|
14584
|
-
|
|
15330
|
+
const command = highlighter.info("npx react-doctor@latest --verbose");
|
|
15331
|
+
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`;
|
|
15332
|
+
yield* Console.log(highlighter.dim(` Tip: ${message}`));
|
|
15333
|
+
});
|
|
15334
|
+
const printDocsNote = () => Effect.gen(function* () {
|
|
15335
|
+
yield* Console.log("");
|
|
15336
|
+
yield* Console.log(` ${highlighter.bold("Docs:")} ${highlighter.info(DOCS_URL)}`);
|
|
15337
|
+
yield* Console.log(highlighter.dim(" Set up CI/CD, suppress rules with a config file, and scan diffs or PRs."));
|
|
14585
15338
|
});
|
|
14586
15339
|
const printSummary = (input) => Effect.gen(function* () {
|
|
14587
15340
|
if (input.scoreResult) {
|
|
@@ -14752,9 +15505,6 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
|
|
|
14752
15505
|
});
|
|
14753
15506
|
const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
|
|
14754
15507
|
//#endregion
|
|
14755
|
-
//#region src/cli/utils/version.ts
|
|
14756
|
-
const VERSION = "0.2.14-dev.8b313ba";
|
|
14757
|
-
//#endregion
|
|
14758
15508
|
//#region src/inspect.ts
|
|
14759
15509
|
const silentConsole = makeNoopConsole();
|
|
14760
15510
|
const runConsole = (effect) => {
|
|
@@ -14779,11 +15529,12 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
14779
15529
|
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
14780
15530
|
share: userConfig?.share ?? true,
|
|
14781
15531
|
respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
|
|
14782
|
-
warnings: inputOptions.warnings ?? userConfig?.warnings ??
|
|
15532
|
+
warnings: inputOptions.warnings ?? userConfig?.warnings ?? true,
|
|
14783
15533
|
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
14784
15534
|
ignoredTags: buildIgnoredTags(userConfig),
|
|
14785
15535
|
outputSurface: inputOptions.outputSurface ?? "cli",
|
|
14786
|
-
suppressRendering: inputOptions.suppressRendering ?? false
|
|
15536
|
+
suppressRendering: inputOptions.suppressRendering ?? false,
|
|
15537
|
+
concurrency: inputOptions.concurrency
|
|
14787
15538
|
});
|
|
14788
15539
|
const inspect = async (directory, inputOptions = {}) => {
|
|
14789
15540
|
const startTime = performance.now();
|
|
@@ -14823,7 +15574,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14823
15574
|
shouldSkipLint: !options.lint || lintBindingMissing,
|
|
14824
15575
|
shouldRunDeadCode: options.deadCode,
|
|
14825
15576
|
shouldComputeScore: !options.noScore,
|
|
14826
|
-
shouldShowProgressSpinners
|
|
15577
|
+
shouldShowProgressSpinners,
|
|
15578
|
+
oxlintConcurrency: options.concurrency
|
|
14827
15579
|
});
|
|
14828
15580
|
const program = runInspect({
|
|
14829
15581
|
directory,
|
|
@@ -14879,15 +15631,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14879
15631
|
};
|
|
14880
15632
|
const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
14881
15633
|
const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
|
|
14882
|
-
const skippedChecks =
|
|
14883
|
-
|
|
14884
|
-
|
|
15634
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
|
|
15635
|
+
didLintFail,
|
|
15636
|
+
lintFailureReason,
|
|
15637
|
+
lintPartialFailures,
|
|
15638
|
+
didDeadCodeFail,
|
|
15639
|
+
deadCodeFailureReason
|
|
15640
|
+
});
|
|
14885
15641
|
const hasSkippedChecks = skippedChecks.length > 0;
|
|
14886
15642
|
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
15643
|
const buildResult = () => ({
|
|
14892
15644
|
diagnostics: [...diagnostics],
|
|
14893
15645
|
score,
|
|
@@ -14923,7 +15675,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
14923
15675
|
return buildResult();
|
|
14924
15676
|
}
|
|
14925
15677
|
yield* Console.log("");
|
|
14926
|
-
yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]));
|
|
15678
|
+
yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment());
|
|
14927
15679
|
if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
|
|
14928
15680
|
if (demotedDiagnosticCount > 0) {
|
|
14929
15681
|
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 +15700,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
14948
15700
|
yield* Console.warn(highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`));
|
|
14949
15701
|
}
|
|
14950
15702
|
yield* printVerboseTip([...surfaceDiagnostics], options.verbose);
|
|
15703
|
+
yield* printDocsNote();
|
|
14951
15704
|
return buildResult();
|
|
14952
15705
|
});
|
|
14953
15706
|
//#endregion
|
|
@@ -15041,6 +15794,7 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
|
|
|
15041
15794
|
yield* Console.error("");
|
|
15042
15795
|
yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
|
|
15043
15796
|
yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
|
|
15797
|
+
yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
|
|
15044
15798
|
yield* Console.error("");
|
|
15045
15799
|
yield* Console.error(highlighter.error(formatErrorForReport(error)));
|
|
15046
15800
|
yield* Console.error("");
|
|
@@ -15058,7 +15812,7 @@ const handleError = (error, options = { shouldExit: true }) => {
|
|
|
15058
15812
|
//#endregion
|
|
15059
15813
|
//#region src/cli/utils/build-handoff-payload.ts
|
|
15060
15814
|
const buildHandoffPayload = (input) => {
|
|
15061
|
-
const topGroups =
|
|
15815
|
+
const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
|
|
15062
15816
|
let diagnosticsDirectory = null;
|
|
15063
15817
|
try {
|
|
15064
15818
|
diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
|
|
@@ -16490,6 +17244,78 @@ const printBrandedHeader = Effect.gen(function* () {
|
|
|
16490
17244
|
yield* Console.log("");
|
|
16491
17245
|
});
|
|
16492
17246
|
//#endregion
|
|
17247
|
+
//#region src/cli/utils/build-run-context.ts
|
|
17248
|
+
const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
|
|
17249
|
+
const detectOrigin = () => {
|
|
17250
|
+
if (process.env.GIT_DIR) return "git-hook";
|
|
17251
|
+
if (isCodingAgentEnvironment()) return "agent";
|
|
17252
|
+
if (isCiEnvironment()) return "ci";
|
|
17253
|
+
return "cli";
|
|
17254
|
+
};
|
|
17255
|
+
const detectCommand = (userArguments) => {
|
|
17256
|
+
for (const argument of userArguments) {
|
|
17257
|
+
if (argument === "--") break;
|
|
17258
|
+
if (argument.startsWith("-")) continue;
|
|
17259
|
+
return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
|
|
17260
|
+
}
|
|
17261
|
+
return "inspect";
|
|
17262
|
+
};
|
|
17263
|
+
/**
|
|
17264
|
+
* Snapshot of the current invocation, attached to Sentry events as the
|
|
17265
|
+
* `run` context to make crashes triage-able (which version, platform,
|
|
17266
|
+
* CI/agent, how it was invoked). Every field is cheap, synchronous, and
|
|
17267
|
+
* safe to read at any point — cwd reads fall back, env reads are
|
|
17268
|
+
* booleans — so it's rebuilt lazily at capture time when runtime-only
|
|
17269
|
+
* signals like `jsonMode` are finally known.
|
|
17270
|
+
*/
|
|
17271
|
+
const buildRunContext = () => {
|
|
17272
|
+
const userArguments = process.argv.slice(2);
|
|
17273
|
+
return {
|
|
17274
|
+
version: VERSION,
|
|
17275
|
+
origin: detectOrigin(),
|
|
17276
|
+
command: detectCommand(userArguments),
|
|
17277
|
+
argv: userArguments.join(" "),
|
|
17278
|
+
cwd: process.cwd(),
|
|
17279
|
+
node: process.version,
|
|
17280
|
+
platform: process.platform,
|
|
17281
|
+
arch: process.arch,
|
|
17282
|
+
ci: isCiEnvironment(),
|
|
17283
|
+
ciProvider: detectCiProvider(),
|
|
17284
|
+
codingAgent: detectCodingAgent(),
|
|
17285
|
+
interactive: !isNonInteractiveEnvironment(),
|
|
17286
|
+
jsonMode: isJsonModeActive()
|
|
17287
|
+
};
|
|
17288
|
+
};
|
|
17289
|
+
//#endregion
|
|
17290
|
+
//#region src/cli/utils/report-error.ts
|
|
17291
|
+
/**
|
|
17292
|
+
* Sends an error to Sentry, enriched with a snapshot of the current run
|
|
17293
|
+
* (version, platform, CI/agent, invocation), and waits for delivery
|
|
17294
|
+
* before the caller exits. The CLI tears down the process synchronously
|
|
17295
|
+
* after rendering an error, so the awaited `flush` is what actually gets
|
|
17296
|
+
* the event off the machine (see the Sentry CLI/serverless flush
|
|
17297
|
+
* contract).
|
|
17298
|
+
*
|
|
17299
|
+
* Returns early when Sentry was never initialized (`--no-score`, tests,
|
|
17300
|
+
* or a missing DSN), and swallows any transport failure so telemetry can
|
|
17301
|
+
* never mask the user's original error.
|
|
17302
|
+
*/
|
|
17303
|
+
const reportErrorToSentry = async (error) => {
|
|
17304
|
+
if (!Sentry.isInitialized()) return;
|
|
17305
|
+
try {
|
|
17306
|
+
const runContext = buildRunContext();
|
|
17307
|
+
Sentry.setContext("run", { ...runContext });
|
|
17308
|
+
Sentry.setTags({
|
|
17309
|
+
origin: runContext.origin,
|
|
17310
|
+
command: runContext.command,
|
|
17311
|
+
ciProvider: runContext.ciProvider,
|
|
17312
|
+
codingAgent: runContext.codingAgent
|
|
17313
|
+
});
|
|
17314
|
+
Sentry.captureException(error);
|
|
17315
|
+
await Sentry.flush(2e3);
|
|
17316
|
+
} catch {}
|
|
17317
|
+
};
|
|
17318
|
+
//#endregion
|
|
16493
17319
|
//#region src/cli/utils/path-format.ts
|
|
16494
17320
|
const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
|
|
16495
17321
|
//#endregion
|
|
@@ -16557,7 +17383,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
|
|
|
16557
17383
|
yield* Console.log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalScanElapsedMilliseconds)}`);
|
|
16558
17384
|
if (surfaceDiagnostics.length > 0) {
|
|
16559
17385
|
yield* Console.log("");
|
|
16560
|
-
yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)));
|
|
17386
|
+
yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment());
|
|
16561
17387
|
}
|
|
16562
17388
|
const lowestScoredScan = findLowestScoredScan(completedScans);
|
|
16563
17389
|
const aggregateScore = lowestScoredScan?.result.score ?? null;
|
|
@@ -16587,6 +17413,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
|
|
|
16587
17413
|
for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
|
|
16588
17414
|
yield* Console.log("");
|
|
16589
17415
|
yield* printVerboseTip(surfaceDiagnostics, verbose);
|
|
17416
|
+
yield* printDocsNote();
|
|
16590
17417
|
});
|
|
16591
17418
|
//#endregion
|
|
16592
17419
|
//#region src/cli/utils/prompt-install-setup.ts
|
|
@@ -16634,6 +17461,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
|
|
|
16634
17461
|
for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
|
|
16635
17462
|
};
|
|
16636
17463
|
//#endregion
|
|
17464
|
+
//#region src/cli/utils/resolve-parallel-flag.ts
|
|
17465
|
+
/**
|
|
17466
|
+
* Translates the `--experimental-parallel [workers]` flag into a concrete
|
|
17467
|
+
* worker count for `InspectOptions.concurrency`:
|
|
17468
|
+
*
|
|
17469
|
+
* - flag absent (`undefined`) → `undefined` (defer to the ambient
|
|
17470
|
+
* default: serial unless `REACT_DOCTOR_PARALLEL` is set)
|
|
17471
|
+
* - bare flag / `auto` → auto-detect CPU cores
|
|
17472
|
+
* - `--experimental-parallel <n>` → `n` workers (clamped)
|
|
17473
|
+
* - `false` / `off` / `0` → serial (an explicit opt-out, so
|
|
17474
|
+
* it overrides an env-enabled default rather than deferring to it)
|
|
17475
|
+
* - an unparseable value → auto-detect cores
|
|
17476
|
+
*
|
|
17477
|
+
* Commander yields `true` for a bare flag, the raw string for an explicit
|
|
17478
|
+
* value, and `undefined` when the flag is omitted.
|
|
17479
|
+
*/
|
|
17480
|
+
const resolveParallelFlag = (parallel) => {
|
|
17481
|
+
if (parallel === void 0) return void 0;
|
|
17482
|
+
if (parallel === true) return resolveScanConcurrency("auto");
|
|
17483
|
+
if (parallel === false) return 1;
|
|
17484
|
+
const normalized = parallel.trim().toLowerCase();
|
|
17485
|
+
if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
|
|
17486
|
+
if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
|
|
17487
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
17488
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
|
|
17489
|
+
return resolveScanConcurrency(parsed);
|
|
17490
|
+
};
|
|
17491
|
+
//#endregion
|
|
16637
17492
|
//#region src/cli/utils/resolve-cli-inspect-options.ts
|
|
16638
17493
|
/**
|
|
16639
17494
|
* Translates CLI flags into the `InspectOptions` contract `inspect()`
|
|
@@ -16656,10 +17511,11 @@ const resolveCliInspectOptions = (flags, userConfig) => {
|
|
|
16656
17511
|
respectInlineDisables: flags.respectInlineDisables,
|
|
16657
17512
|
warnings: flags.warnings ?? (wantsWarningGate ? true : void 0),
|
|
16658
17513
|
scoreOnly: flags.score === true,
|
|
16659
|
-
noScore: flags.score === false || (userConfig?.noScore ?? false),
|
|
17514
|
+
noScore: flags.score === false || flags.telemetry === false || (userConfig?.noScore ?? false),
|
|
16660
17515
|
isCi: isCiEnvironment(),
|
|
16661
17516
|
silent: Boolean(flags.json),
|
|
16662
|
-
outputSurface: flags.prComment ? "prComment" : "cli"
|
|
17517
|
+
outputSurface: flags.prComment ? "prComment" : "cli",
|
|
17518
|
+
concurrency: resolveParallelFlag(flags.experimentalParallel)
|
|
16663
17519
|
};
|
|
16664
17520
|
};
|
|
16665
17521
|
//#endregion
|
|
@@ -16818,11 +17674,9 @@ const parseFileLineArgument = (rawArgument) => {
|
|
|
16818
17674
|
//#endregion
|
|
16819
17675
|
//#region src/cli/utils/select-projects.ts
|
|
16820
17676
|
const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
17677
|
+
const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json"));
|
|
16821
17678
|
let packages = listWorkspacePackages(rootDirectory);
|
|
16822
|
-
if (packages.length === 0)
|
|
16823
|
-
if (!isMonorepoRoot(rootDirectory)) return [rootDirectory];
|
|
16824
|
-
packages = discoverReactSubprojects(rootDirectory);
|
|
16825
|
-
}
|
|
17679
|
+
if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) packages = discoverReactSubprojects(rootDirectory);
|
|
16826
17680
|
if (packages.length === 0) return [rootDirectory];
|
|
16827
17681
|
if (packages.length === 1) {
|
|
16828
17682
|
cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages[0].name}`);
|
|
@@ -16926,6 +17780,7 @@ const validateModeFlags = (flags) => {
|
|
|
16926
17780
|
if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
|
|
16927
17781
|
if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
|
|
16928
17782
|
if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
|
|
17783
|
+
if (flags.score && flags.telemetry === false) throw new Error("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
|
|
16929
17784
|
if (flags.prComment && (flags.json || flags.score)) throw new Error("--pr-comment cannot be combined with --json or --score.");
|
|
16930
17785
|
if (flags.annotations && flags.score) throw new Error("--annotations cannot be combined with --score.");
|
|
16931
17786
|
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.");
|
|
@@ -16971,7 +17826,7 @@ const inspectAction = async (directory, flags) => {
|
|
|
16971
17826
|
});
|
|
16972
17827
|
try {
|
|
16973
17828
|
validateModeFlags(flags);
|
|
16974
|
-
const scanTarget = resolveScanTarget(requestedDirectory);
|
|
17829
|
+
const scanTarget = resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
|
|
16975
17830
|
const userConfig = scanTarget.userConfig;
|
|
16976
17831
|
const resolvedDirectory = scanTarget.resolvedDirectory;
|
|
16977
17832
|
setJsonReportDirectory(resolvedDirectory);
|
|
@@ -17145,6 +18000,7 @@ const inspectAction = async (directory, flags) => {
|
|
|
17145
18000
|
})) printAgentInstallHint();
|
|
17146
18001
|
}
|
|
17147
18002
|
} catch (error) {
|
|
18003
|
+
await reportErrorToSentry(error);
|
|
17148
18004
|
if (isJsonMode) {
|
|
17149
18005
|
writeJsonErrorReport(error);
|
|
17150
18006
|
process.exitCode = 1;
|
|
@@ -17166,10 +18022,61 @@ const installAction = async (options, command) => {
|
|
|
17166
18022
|
projectRoot: options.cwd ?? process.cwd()
|
|
17167
18023
|
});
|
|
17168
18024
|
} catch (error) {
|
|
18025
|
+
await reportErrorToSentry(error);
|
|
17169
18026
|
handleError(error);
|
|
17170
18027
|
}
|
|
17171
18028
|
};
|
|
17172
18029
|
//#endregion
|
|
18030
|
+
//#region src/cli/commands/version.ts
|
|
18031
|
+
/**
|
|
18032
|
+
* oclif-style version line. 12-factor CLI Apps (#3, "What version am I
|
|
18033
|
+
* on?"): the `version` command is the primary place users grab debugging
|
|
18034
|
+
* info, so it carries the Node runtime and platform alongside the CLI
|
|
18035
|
+
* version. The `-v` / `-V` / `--version` flags stay terse (just the
|
|
18036
|
+
* number) so scripts can parse them.
|
|
18037
|
+
*/
|
|
18038
|
+
const buildVersionString = () => `react-doctor/${VERSION} ${process.platform}-${process.arch} node-${process.version}`;
|
|
18039
|
+
const versionAction = () => {
|
|
18040
|
+
process.stdout.write(`${buildVersionString()}\n`);
|
|
18041
|
+
};
|
|
18042
|
+
//#endregion
|
|
18043
|
+
//#region src/cli/utils/apply-color-preference.ts
|
|
18044
|
+
/**
|
|
18045
|
+
* Resolve an explicit color preference from `--color` / `--no-color` or the
|
|
18046
|
+
* app-specific `REACT_DOCTOR_NO_COLOR` / `REACT_DOCTOR_FORCE_COLOR` env vars
|
|
18047
|
+
* (clig.dev Output; 12-factor #6), overriding picocolors' own
|
|
18048
|
+
* `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY detection. Flags win over env
|
|
18049
|
+
* vars; with neither set, picocolors' detection stands.
|
|
18050
|
+
*
|
|
18051
|
+
* A resolved preference is mirrored onto the standard `NO_COLOR` /
|
|
18052
|
+
* `FORCE_COLOR` env vars in addition to our picocolors highlighter, so
|
|
18053
|
+
* libraries with their own color stacks (the `ora` spinner, `prompts`)
|
|
18054
|
+
* honor it too rather than only the scan report. Scanning argv directly
|
|
18055
|
+
* (not Commander's parsed options) applies the preference before Commander
|
|
18056
|
+
* parses, so it reaches every later path. The scan stops at `--`.
|
|
18057
|
+
*/
|
|
18058
|
+
const applyColorPreference = (argv, env = process.env) => {
|
|
18059
|
+
let enabled;
|
|
18060
|
+
for (const argument of argv) {
|
|
18061
|
+
if (argument === "--") break;
|
|
18062
|
+
if (argument === "--no-color") enabled = false;
|
|
18063
|
+
else if (argument === "--color") enabled = true;
|
|
18064
|
+
}
|
|
18065
|
+
if (enabled === void 0) {
|
|
18066
|
+
if (env.REACT_DOCTOR_NO_COLOR) enabled = false;
|
|
18067
|
+
else if (env.REACT_DOCTOR_FORCE_COLOR) enabled = true;
|
|
18068
|
+
}
|
|
18069
|
+
if (enabled === void 0) return;
|
|
18070
|
+
if (enabled) {
|
|
18071
|
+
env.FORCE_COLOR = "1";
|
|
18072
|
+
delete env.NO_COLOR;
|
|
18073
|
+
} else {
|
|
18074
|
+
env.NO_COLOR = "1";
|
|
18075
|
+
delete env.FORCE_COLOR;
|
|
18076
|
+
}
|
|
18077
|
+
setColorEnabled(enabled);
|
|
18078
|
+
};
|
|
18079
|
+
//#endregion
|
|
17173
18080
|
//#region src/cli/utils/exit-gracefully.ts
|
|
17174
18081
|
const exitGracefully = () => {
|
|
17175
18082
|
try {
|
|
@@ -17179,21 +18086,54 @@ const exitGracefully = () => {
|
|
|
17179
18086
|
process.exit(130);
|
|
17180
18087
|
};
|
|
17181
18088
|
//#endregion
|
|
18089
|
+
//#region src/cli/utils/normalize-help-command.ts
|
|
18090
|
+
/**
|
|
18091
|
+
* 12-factor CLI Apps (#1, "Great help is essential"): `mycli help` and
|
|
18092
|
+
* `mycli help <command>` must display help. Commander doesn't wire this
|
|
18093
|
+
* up once the root command has its own default action plus a positional
|
|
18094
|
+
* argument — it treats a leading `help` as the `[directory]` to scan,
|
|
18095
|
+
* which then errors with "No React project found in ./help".
|
|
18096
|
+
*
|
|
18097
|
+
* We rewrite the argv up front so the existing `--help` paths handle it:
|
|
18098
|
+
* `react-doctor help` -> `react-doctor --help`
|
|
18099
|
+
* `react-doctor help install` -> `react-doctor install --help`
|
|
18100
|
+
*
|
|
18101
|
+
* Only a *leading* `help` token is rewritten, so a flag value such as
|
|
18102
|
+
* `--project help` is never mistaken for the help command. The target is
|
|
18103
|
+
* the first non-flag token after `help`, so intervening flags like
|
|
18104
|
+
* `help --no-color install` still resolve to `install`. An unknown target
|
|
18105
|
+
* (`help bogus`) falls back to root help rather than erroring.
|
|
18106
|
+
*/
|
|
18107
|
+
const normalizeHelpInvocation = (argv, knownCommands) => {
|
|
18108
|
+
const nodeArguments = argv.slice(0, 2);
|
|
18109
|
+
const userArguments = argv.slice(2);
|
|
18110
|
+
if (userArguments[0] !== "help") return [...argv];
|
|
18111
|
+
const target = userArguments.slice(1).find((argument) => !argument.startsWith("-"));
|
|
18112
|
+
if (target !== void 0 && knownCommands.includes(target)) return [
|
|
18113
|
+
...nodeArguments,
|
|
18114
|
+
target,
|
|
18115
|
+
"--help"
|
|
18116
|
+
];
|
|
18117
|
+
return [...nodeArguments, "--help"];
|
|
18118
|
+
};
|
|
18119
|
+
//#endregion
|
|
17182
18120
|
//#region src/cli/utils/strip-unknown-cli-flags.ts
|
|
17183
|
-
const NODE_ARGUMENT_COUNT = 2;
|
|
17184
18121
|
const ROOT_FLAG_SPEC = {
|
|
17185
18122
|
longOptionsWithoutValues: new Set([
|
|
17186
18123
|
"--annotations",
|
|
18124
|
+
"--color",
|
|
17187
18125
|
"--dead-code",
|
|
17188
18126
|
"--full",
|
|
17189
18127
|
"--help",
|
|
17190
18128
|
"--json",
|
|
17191
18129
|
"--json-compact",
|
|
17192
18130
|
"--lint",
|
|
18131
|
+
"--no-color",
|
|
17193
18132
|
"--no-dead-code",
|
|
17194
18133
|
"--no-lint",
|
|
17195
18134
|
"--no-respect-inline-disables",
|
|
17196
18135
|
"--no-score",
|
|
18136
|
+
"--no-telemetry",
|
|
17197
18137
|
"--no-warnings",
|
|
17198
18138
|
"--pr-comment",
|
|
17199
18139
|
"--respect-inline-disables",
|
|
@@ -17211,7 +18151,7 @@ const ROOT_FLAG_SPEC = {
|
|
|
17211
18151
|
"--project",
|
|
17212
18152
|
"--why"
|
|
17213
18153
|
]),
|
|
17214
|
-
longOptionsWithOptionalValues: new Set(["--diff"]),
|
|
18154
|
+
longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
|
|
17215
18155
|
shortOptionsWithoutValues: new Set([
|
|
17216
18156
|
"-h",
|
|
17217
18157
|
"-v",
|
|
@@ -17222,8 +18162,10 @@ const ROOT_FLAG_SPEC = {
|
|
|
17222
18162
|
const INSTALL_FLAG_SPEC = {
|
|
17223
18163
|
longOptionsWithoutValues: new Set([
|
|
17224
18164
|
"--agent-hooks",
|
|
18165
|
+
"--color",
|
|
17225
18166
|
"--dry-run",
|
|
17226
18167
|
"--help",
|
|
18168
|
+
"--no-color",
|
|
17227
18169
|
"--yes"
|
|
17228
18170
|
]),
|
|
17229
18171
|
longOptionsWithRequiredValues: new Set(["--cwd"]),
|
|
@@ -17231,7 +18173,21 @@ const INSTALL_FLAG_SPEC = {
|
|
|
17231
18173
|
shortOptionsWithoutValues: new Set(["-h", "-y"]),
|
|
17232
18174
|
shortOptionsWithRequiredValues: new Set(["-c"])
|
|
17233
18175
|
};
|
|
17234
|
-
const COMMAND_FLAG_SPECS = new Map([
|
|
18176
|
+
const COMMAND_FLAG_SPECS = new Map([
|
|
18177
|
+
["install", INSTALL_FLAG_SPEC],
|
|
18178
|
+
["setup", INSTALL_FLAG_SPEC],
|
|
18179
|
+
["version", {
|
|
18180
|
+
longOptionsWithoutValues: new Set([
|
|
18181
|
+
"--color",
|
|
18182
|
+
"--help",
|
|
18183
|
+
"--no-color"
|
|
18184
|
+
]),
|
|
18185
|
+
longOptionsWithRequiredValues: /* @__PURE__ */ new Set(),
|
|
18186
|
+
longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
|
|
18187
|
+
shortOptionsWithoutValues: new Set(["-h"]),
|
|
18188
|
+
shortOptionsWithRequiredValues: /* @__PURE__ */ new Set()
|
|
18189
|
+
}]
|
|
18190
|
+
]);
|
|
17235
18191
|
const isFlagLike = (argument) => argument.startsWith("-") && argument !== "-";
|
|
17236
18192
|
const getLongOptionName = (argument) => {
|
|
17237
18193
|
const equalsIndex = argument.indexOf("=");
|
|
@@ -17285,8 +18241,8 @@ const stripUnknownFlags = (userArguments, flagSpec) => {
|
|
|
17285
18241
|
return sanitizedArguments;
|
|
17286
18242
|
};
|
|
17287
18243
|
const stripUnknownCliFlags = (argv) => {
|
|
17288
|
-
const nodeArguments = argv.slice(0,
|
|
17289
|
-
const userArguments = argv.slice(
|
|
18244
|
+
const nodeArguments = argv.slice(0, 2);
|
|
18245
|
+
const userArguments = argv.slice(2);
|
|
17290
18246
|
const commandIndex = findCommandIndex(userArguments);
|
|
17291
18247
|
if (commandIndex === null) return [...nodeArguments, ...stripUnknownFlags(userArguments, ROOT_FLAG_SPEC)];
|
|
17292
18248
|
const commandName = userArguments[commandIndex];
|
|
@@ -17300,23 +18256,66 @@ const stripUnknownCliFlags = (argv) => {
|
|
|
17300
18256
|
};
|
|
17301
18257
|
//#endregion
|
|
17302
18258
|
//#region src/cli/index.ts
|
|
18259
|
+
initializeSentry();
|
|
17303
18260
|
process.on("SIGINT", exitGracefully);
|
|
17304
18261
|
process.on("SIGTERM", exitGracefully);
|
|
17305
18262
|
unrefStdin();
|
|
17306
|
-
const
|
|
18263
|
+
const formatExampleLines = (examples) => {
|
|
18264
|
+
const width = Math.max(...examples.map(([command]) => command.length));
|
|
18265
|
+
return examples.map(([command, description]) => ` $ ${command.padEnd(width)} ${highlighter.dim(`# ${description}`)}`).join("\n");
|
|
18266
|
+
};
|
|
18267
|
+
const renderRootHelpEpilog = () => `
|
|
18268
|
+
${highlighter.dim("Examples:")}
|
|
18269
|
+
${formatExampleLines([
|
|
18270
|
+
["react-doctor", "scan the current project"],
|
|
18271
|
+
["react-doctor ./apps/web", "scan a specific directory"],
|
|
18272
|
+
["react-doctor --diff main", "scan only files changed vs. main"],
|
|
18273
|
+
["react-doctor --staged", "scan staged files (pre-commit hook)"],
|
|
18274
|
+
["react-doctor --fail-on warning", "exit non-zero on warnings (CI gate)"],
|
|
18275
|
+
["react-doctor --json > report.json", "write a machine-readable report"],
|
|
18276
|
+
["react-doctor --explain src/App.tsx:42", "explain why a rule fired there"],
|
|
18277
|
+
["react-doctor install", "set up the agent skill and git hook"]
|
|
18278
|
+
])}
|
|
18279
|
+
|
|
17307
18280
|
${highlighter.dim("Configuration:")}
|
|
17308
18281
|
Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
|
|
17309
18282
|
CLI flags always override config values. See the README for the full schema.
|
|
17310
18283
|
|
|
18284
|
+
${highlighter.dim("Feedback & bug reports:")}
|
|
18285
|
+
${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
|
|
18286
|
+
|
|
17311
18287
|
${highlighter.dim("Learn more:")}
|
|
17312
18288
|
${highlighter.info(CANONICAL_GITHUB_URL)}
|
|
17313
|
-
|
|
18289
|
+
`;
|
|
18290
|
+
const renderInstallHelpEpilog = () => `
|
|
18291
|
+
${highlighter.dim("Examples:")}
|
|
18292
|
+
${formatExampleLines([
|
|
18293
|
+
["react-doctor install", "interactive setup"],
|
|
18294
|
+
["react-doctor install --yes", "non-interactive; all detected agents"],
|
|
18295
|
+
["react-doctor install --dry-run", "preview without writing files"],
|
|
18296
|
+
["react-doctor install --agent-hooks", "also install native agent hooks"]
|
|
18297
|
+
])}
|
|
18298
|
+
|
|
18299
|
+
${highlighter.dim("Learn more:")}
|
|
18300
|
+
${highlighter.info(CANONICAL_GITHUB_URL)}
|
|
18301
|
+
`;
|
|
18302
|
+
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
18303
|
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);
|
|
18304
|
+
program.command("install").alias("setup").description("Install the react-doctor skill into your coding agents and optional git hook").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor").option("-c, --cwd <cwd>", "working directory", process.cwd()).option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderInstallHelpEpilog).action(installAction);
|
|
18305
|
+
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);
|
|
17316
18306
|
process.stdout.on("error", (error) => {
|
|
17317
18307
|
if (error.code === "EPIPE") process.exit(0);
|
|
17318
18308
|
});
|
|
17319
|
-
program.
|
|
18309
|
+
const knownCommands = program.commands.flatMap((command) => [command.name(), ...command.aliases()]);
|
|
18310
|
+
const strippedArgv = stripUnknownCliFlags(process.argv);
|
|
18311
|
+
if (process.argv.includes("-V") && !strippedArgv.includes("-V")) {
|
|
18312
|
+
process.stdout.write(`${VERSION}\n`);
|
|
18313
|
+
process.exit(0);
|
|
18314
|
+
}
|
|
18315
|
+
applyColorPreference(strippedArgv);
|
|
18316
|
+
const argv = normalizeHelpInvocation(strippedArgv, knownCommands);
|
|
18317
|
+
program.parseAsync(argv).catch(async (error) => {
|
|
18318
|
+
await reportErrorToSentry(error);
|
|
17320
18319
|
if (isJsonModeActive()) {
|
|
17321
18320
|
writeJsonErrorReport(error);
|
|
17322
18321
|
process.exit(1);
|