react-doctor 0.2.14-dev.09fe1ff → 0.2.14-dev.24425b1
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 +863 -99
- package/dist/index.d.ts +39 -1
- package/dist/index.js +653 -70
- package/package.json +5 -4
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
|
};
|
|
@@ -6289,6 +6314,7 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
6289
6314
|
".oxlintrc.json"
|
|
6290
6315
|
];
|
|
6291
6316
|
const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
|
|
6317
|
+
const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
|
|
6292
6318
|
const SKILL_NAME = "react-doctor";
|
|
6293
6319
|
const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
6294
6320
|
const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
|
|
@@ -6301,6 +6327,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
|
6301
6327
|
"Accessibility",
|
|
6302
6328
|
"Maintainability"
|
|
6303
6329
|
];
|
|
6330
|
+
const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
|
|
6331
|
+
const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
|
|
6332
|
+
const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
|
|
6304
6333
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
6305
6334
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
6306
6335
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -6420,10 +6449,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
6420
6449
|
*/
|
|
6421
6450
|
const buildRuleSeverityControls = (config) => {
|
|
6422
6451
|
if (!config) return void 0;
|
|
6423
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
6452
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
6424
6453
|
return {
|
|
6425
6454
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
6426
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
6455
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
6456
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
6427
6457
|
};
|
|
6428
6458
|
};
|
|
6429
6459
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -6787,6 +6817,65 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
6787
6817
|
}
|
|
6788
6818
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
6789
6819
|
};
|
|
6820
|
+
const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
|
|
6821
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
6822
|
+
const findNearestPackageDirectory$1 = (filename) => {
|
|
6823
|
+
if (!filename) return null;
|
|
6824
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
6825
|
+
if (fromCache !== void 0) return fromCache;
|
|
6826
|
+
let currentDirectory = path.dirname(filename);
|
|
6827
|
+
while (true) {
|
|
6828
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
6829
|
+
let hasPackageJson = false;
|
|
6830
|
+
try {
|
|
6831
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
6832
|
+
} catch {
|
|
6833
|
+
hasPackageJson = false;
|
|
6834
|
+
}
|
|
6835
|
+
if (hasPackageJson) {
|
|
6836
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
6837
|
+
return currentDirectory;
|
|
6838
|
+
}
|
|
6839
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
6840
|
+
if (parentDirectory === currentDirectory) {
|
|
6841
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
6842
|
+
return null;
|
|
6843
|
+
}
|
|
6844
|
+
currentDirectory = parentDirectory;
|
|
6845
|
+
}
|
|
6846
|
+
};
|
|
6847
|
+
const readManifest = (packageJsonPath) => {
|
|
6848
|
+
try {
|
|
6849
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
6850
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
6851
|
+
return null;
|
|
6852
|
+
} catch {
|
|
6853
|
+
return null;
|
|
6854
|
+
}
|
|
6855
|
+
};
|
|
6856
|
+
const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
|
|
6857
|
+
const classifyByDirectoryCohort = (packageDirectory) => {
|
|
6858
|
+
let current = packageDirectory;
|
|
6859
|
+
while (true) {
|
|
6860
|
+
if (path.basename(current) === "apps") return "app";
|
|
6861
|
+
const parent = path.dirname(current);
|
|
6862
|
+
if (parent === current) return null;
|
|
6863
|
+
current = parent;
|
|
6864
|
+
}
|
|
6865
|
+
};
|
|
6866
|
+
const classifyPackageRole = (filename) => {
|
|
6867
|
+
if (!filename) return "unknown";
|
|
6868
|
+
const packageDirectory = findNearestPackageDirectory$1(filename);
|
|
6869
|
+
if (!packageDirectory) return "unknown";
|
|
6870
|
+
const cached = cachedRoleByPackageDirectory.get(packageDirectory);
|
|
6871
|
+
if (cached !== void 0) return cached;
|
|
6872
|
+
const manifest = readManifest(path.join(packageDirectory, "package.json"));
|
|
6873
|
+
let result;
|
|
6874
|
+
if (manifest && hasPublishContract(manifest)) result = "library";
|
|
6875
|
+
else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
|
|
6876
|
+
cachedRoleByPackageDirectory.set(packageDirectory, result);
|
|
6877
|
+
return result;
|
|
6878
|
+
};
|
|
6790
6879
|
/**
|
|
6791
6880
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
6792
6881
|
* accounting for the various shapes oxlint emits:
|
|
@@ -6949,6 +7038,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6949
7038
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
6950
7039
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
6951
7040
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
7041
|
+
const libraryFileCache = /* @__PURE__ */ new Map();
|
|
7042
|
+
const isLibraryFile = (filePath) => {
|
|
7043
|
+
let cached = libraryFileCache.get(filePath);
|
|
7044
|
+
if (cached === void 0) {
|
|
7045
|
+
cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
|
|
7046
|
+
libraryFileCache.set(filePath, cached);
|
|
7047
|
+
}
|
|
7048
|
+
return cached;
|
|
7049
|
+
};
|
|
6952
7050
|
const getFileLines = (filePath) => {
|
|
6953
7051
|
const cached = fileLinesCache.get(filePath);
|
|
6954
7052
|
if (cached !== void 0) return cached;
|
|
@@ -6975,6 +7073,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6975
7073
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
6976
7074
|
return false;
|
|
6977
7075
|
};
|
|
7076
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
7077
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
7078
|
+
return false;
|
|
7079
|
+
};
|
|
6978
7080
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
6979
7081
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
6980
7082
|
if (diagnostic.line <= 0) return false;
|
|
@@ -6989,8 +7091,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6989
7091
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
6990
7092
|
let current = diagnostic;
|
|
6991
7093
|
let explicitSeverityOverride;
|
|
7094
|
+
let explicitRuleOverride;
|
|
6992
7095
|
if (severityControls) {
|
|
6993
7096
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
7097
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
6994
7098
|
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
6995
7099
|
ruleKey,
|
|
6996
7100
|
category
|
|
@@ -6998,6 +7102,9 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6998
7102
|
if (explicitSeverityOverride === "off") return null;
|
|
6999
7103
|
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
7000
7104
|
}
|
|
7105
|
+
if (explicitRuleOverride === void 0) {
|
|
7106
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
7107
|
+
}
|
|
7001
7108
|
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
7002
7109
|
if (userConfig) {
|
|
7003
7110
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
@@ -7183,6 +7290,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
7183
7290
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
7184
7291
|
}).pipe(Effect.orDie));
|
|
7185
7292
|
/**
|
|
7293
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
7294
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
7295
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
7296
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
7297
|
+
*/
|
|
7298
|
+
const resolveScanConcurrency = (requested) => {
|
|
7299
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
7300
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
7301
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
7302
|
+
};
|
|
7303
|
+
/**
|
|
7186
7304
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
7187
7305
|
* startup so the eval harness can raise the budget under sandbox
|
|
7188
7306
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -7202,6 +7320,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
7202
7320
|
* tests that exercise the cap behavior.
|
|
7203
7321
|
*/
|
|
7204
7322
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
7323
|
+
/**
|
|
7324
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
7325
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
7326
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
7327
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
7328
|
+
* CI callers that never touch the flag:
|
|
7329
|
+
*
|
|
7330
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
7331
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
7332
|
+
* - a positive integer → that many workers (clamped)
|
|
7333
|
+
*
|
|
7334
|
+
* The resolved value is always within
|
|
7335
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
7336
|
+
*/
|
|
7337
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
7338
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
7339
|
+
if (raw === void 0) return 1;
|
|
7340
|
+
const normalized = raw.trim().toLowerCase();
|
|
7341
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
7342
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
7343
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
7344
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
7345
|
+
return resolveScanConcurrency(parsed);
|
|
7346
|
+
} }) {};
|
|
7205
7347
|
const DIAGNOSTIC_SURFACES = [
|
|
7206
7348
|
"cli",
|
|
7207
7349
|
"prComment",
|
|
@@ -7362,16 +7504,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
|
|
|
7362
7504
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
7363
7505
|
const loadConfigFromDirectory = (directory) => {
|
|
7364
7506
|
const configFilePath = path.join(directory, CONFIG_FILENAME);
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7507
|
+
let sawBrokenConfigFile = false;
|
|
7508
|
+
if (isFile(configFilePath)) {
|
|
7509
|
+
try {
|
|
7510
|
+
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
7511
|
+
const parsed = JSON.parse(fileContent);
|
|
7512
|
+
if (isPlainObject(parsed)) return {
|
|
7513
|
+
status: "found",
|
|
7514
|
+
loaded: {
|
|
7515
|
+
config: validateConfigTypes(parsed),
|
|
7516
|
+
sourceDirectory: directory
|
|
7517
|
+
}
|
|
7518
|
+
};
|
|
7519
|
+
warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
7520
|
+
} catch (error) {
|
|
7521
|
+
warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7522
|
+
}
|
|
7523
|
+
sawBrokenConfigFile = true;
|
|
7375
7524
|
}
|
|
7376
7525
|
const packageJsonPath = path.join(directory, "package.json");
|
|
7377
7526
|
if (isFile(packageJsonPath)) try {
|
|
@@ -7380,34 +7529,38 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
7380
7529
|
if (isPlainObject(packageJson)) {
|
|
7381
7530
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
7382
7531
|
if (isPlainObject(embeddedConfig)) return {
|
|
7383
|
-
|
|
7384
|
-
|
|
7532
|
+
status: "found",
|
|
7533
|
+
loaded: {
|
|
7534
|
+
config: validateConfigTypes(embeddedConfig),
|
|
7535
|
+
sourceDirectory: directory
|
|
7536
|
+
}
|
|
7385
7537
|
};
|
|
7386
7538
|
}
|
|
7387
|
-
} catch {
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7539
|
+
} catch {}
|
|
7540
|
+
return {
|
|
7541
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
7542
|
+
loaded: null
|
|
7543
|
+
};
|
|
7391
7544
|
};
|
|
7392
7545
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
7393
7546
|
const loadConfigWithSource = (rootDirectory) => {
|
|
7394
7547
|
const cached = cachedConfigs.get(rootDirectory);
|
|
7395
7548
|
if (cached !== void 0) return cached;
|
|
7396
|
-
const
|
|
7397
|
-
if (
|
|
7398
|
-
cachedConfigs.set(rootDirectory,
|
|
7399
|
-
return
|
|
7549
|
+
const localResult = loadConfigFromDirectory(rootDirectory);
|
|
7550
|
+
if (localResult.status === "found") {
|
|
7551
|
+
cachedConfigs.set(rootDirectory, localResult.loaded);
|
|
7552
|
+
return localResult.loaded;
|
|
7400
7553
|
}
|
|
7401
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
7554
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
|
|
7402
7555
|
cachedConfigs.set(rootDirectory, null);
|
|
7403
7556
|
return null;
|
|
7404
7557
|
}
|
|
7405
7558
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
7406
7559
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
7407
|
-
const
|
|
7408
|
-
if (
|
|
7409
|
-
cachedConfigs.set(rootDirectory,
|
|
7410
|
-
return
|
|
7560
|
+
const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
|
|
7561
|
+
if (ancestorResult.status === "found") {
|
|
7562
|
+
cachedConfigs.set(rootDirectory, ancestorResult.loaded);
|
|
7563
|
+
return ancestorResult.loaded;
|
|
7411
7564
|
}
|
|
7412
7565
|
if (isProjectBoundary(ancestorDirectory)) {
|
|
7413
7566
|
cachedConfigs.set(rootDirectory, null);
|
|
@@ -7481,6 +7634,359 @@ const resolveScanTarget = (requestedDirectory, options = {}) => {
|
|
|
7481
7634
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
7482
7635
|
};
|
|
7483
7636
|
};
|
|
7637
|
+
const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
|
|
7638
|
+
const buildExpoCheckContext = (rootDirectory, expoVersion) => {
|
|
7639
|
+
const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
|
|
7640
|
+
return {
|
|
7641
|
+
rootDirectory,
|
|
7642
|
+
packageJson,
|
|
7643
|
+
directDependencyNames: getDirectDependencyNames(packageJson),
|
|
7644
|
+
expoSdkMajor: getLowestDependencyMajor(expoVersion)
|
|
7645
|
+
};
|
|
7646
|
+
};
|
|
7647
|
+
const buildExpoDiagnostic = (input) => ({
|
|
7648
|
+
filePath: input.filePath ?? "package.json",
|
|
7649
|
+
plugin: "react-doctor",
|
|
7650
|
+
rule: input.rule,
|
|
7651
|
+
severity: input.severity ?? "warning",
|
|
7652
|
+
message: input.message,
|
|
7653
|
+
help: input.help,
|
|
7654
|
+
line: input.line ?? 0,
|
|
7655
|
+
column: input.column ?? 0,
|
|
7656
|
+
category: input.category ?? "Correctness"
|
|
7657
|
+
});
|
|
7658
|
+
const CRITICAL_OVERRIDE_NAMES = new Set([
|
|
7659
|
+
"@expo/cli",
|
|
7660
|
+
"@expo/config",
|
|
7661
|
+
"@expo/metro-config",
|
|
7662
|
+
"@expo/metro-runtime",
|
|
7663
|
+
"@expo/metro",
|
|
7664
|
+
"metro"
|
|
7665
|
+
]);
|
|
7666
|
+
const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
|
|
7667
|
+
const collectOverrideNames = (packageJson) => new Set([
|
|
7668
|
+
...Object.keys(packageJson.overrides ?? {}),
|
|
7669
|
+
...Object.keys(packageJson.resolutions ?? {}),
|
|
7670
|
+
...Object.keys(packageJson.pnpm?.overrides ?? {})
|
|
7671
|
+
]);
|
|
7672
|
+
const checkExpoDependencyOverrides = (context) => {
|
|
7673
|
+
const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
|
|
7674
|
+
if (overriddenCriticalNames.length === 0) return [];
|
|
7675
|
+
const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
|
|
7676
|
+
return [buildExpoDiagnostic({
|
|
7677
|
+
rule: "expo-no-conflicting-dependency-override",
|
|
7678
|
+
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`,
|
|
7679
|
+
help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
|
|
7680
|
+
})];
|
|
7681
|
+
};
|
|
7682
|
+
const isPathGitIgnored = (rootDirectory, absolutePath) => {
|
|
7683
|
+
const result = spawnSync("git", [
|
|
7684
|
+
"check-ignore",
|
|
7685
|
+
"-q",
|
|
7686
|
+
absolutePath
|
|
7687
|
+
], {
|
|
7688
|
+
cwd: rootDirectory,
|
|
7689
|
+
stdio: [
|
|
7690
|
+
"ignore",
|
|
7691
|
+
"ignore",
|
|
7692
|
+
"ignore"
|
|
7693
|
+
]
|
|
7694
|
+
});
|
|
7695
|
+
if (result.error) return null;
|
|
7696
|
+
if (result.status === 0) return true;
|
|
7697
|
+
if (result.status === 1) return false;
|
|
7698
|
+
return null;
|
|
7699
|
+
};
|
|
7700
|
+
const LOCAL_ENV_FILE_NAMES = [
|
|
7701
|
+
".env.local",
|
|
7702
|
+
".env.development.local",
|
|
7703
|
+
".env.production.local",
|
|
7704
|
+
".env.test.local"
|
|
7705
|
+
];
|
|
7706
|
+
const checkExpoEnvLocalFiles = (context) => {
|
|
7707
|
+
const { rootDirectory } = context;
|
|
7708
|
+
const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
|
|
7709
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
7710
|
+
if (!isFile(filePath)) return false;
|
|
7711
|
+
return isPathGitIgnored(rootDirectory, filePath) === false;
|
|
7712
|
+
});
|
|
7713
|
+
if (committedEnvFiles.length === 0) return [];
|
|
7714
|
+
return [buildExpoDiagnostic({
|
|
7715
|
+
rule: "expo-env-local-not-gitignored",
|
|
7716
|
+
category: "Security",
|
|
7717
|
+
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`,
|
|
7718
|
+
help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
|
|
7719
|
+
})];
|
|
7720
|
+
};
|
|
7721
|
+
const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
|
|
7722
|
+
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";
|
|
7723
|
+
const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
|
|
7724
|
+
const unimodulesEntry = (packageName) => ({
|
|
7725
|
+
packageName,
|
|
7726
|
+
rule: "expo-no-unimodules-packages",
|
|
7727
|
+
message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
|
|
7728
|
+
help: UNIMODULES_HELP
|
|
7729
|
+
});
|
|
7730
|
+
const FLAGGED_DEPENDENCIES = [
|
|
7731
|
+
unimodulesEntry("@unimodules/core"),
|
|
7732
|
+
unimodulesEntry("@unimodules/react-native-adapter"),
|
|
7733
|
+
unimodulesEntry("react-native-unimodules"),
|
|
7734
|
+
{
|
|
7735
|
+
packageName: "expo-cli",
|
|
7736
|
+
rule: "expo-no-cli-dependencies",
|
|
7737
|
+
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`",
|
|
7738
|
+
help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
|
|
7739
|
+
},
|
|
7740
|
+
{
|
|
7741
|
+
packageName: "eas-cli",
|
|
7742
|
+
rule: "expo-no-cli-dependencies",
|
|
7743
|
+
message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
|
|
7744
|
+
help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
|
|
7745
|
+
},
|
|
7746
|
+
{
|
|
7747
|
+
packageName: "expo-modules-autolinking",
|
|
7748
|
+
rule: "expo-no-redundant-dependency",
|
|
7749
|
+
message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
|
|
7750
|
+
help: "Remove `expo-modules-autolinking` from your package.json"
|
|
7751
|
+
},
|
|
7752
|
+
{
|
|
7753
|
+
packageName: "expo-dev-launcher",
|
|
7754
|
+
rule: "expo-no-redundant-dependency",
|
|
7755
|
+
message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7756
|
+
help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
|
|
7757
|
+
},
|
|
7758
|
+
{
|
|
7759
|
+
packageName: "expo-dev-menu",
|
|
7760
|
+
rule: "expo-no-redundant-dependency",
|
|
7761
|
+
message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7762
|
+
help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
|
|
7763
|
+
},
|
|
7764
|
+
{
|
|
7765
|
+
packageName: "expo-modules-core",
|
|
7766
|
+
rule: "expo-no-redundant-dependency",
|
|
7767
|
+
message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
|
|
7768
|
+
help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
|
|
7769
|
+
},
|
|
7770
|
+
{
|
|
7771
|
+
packageName: "@expo/metro-config",
|
|
7772
|
+
rule: "expo-no-redundant-dependency",
|
|
7773
|
+
message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
|
|
7774
|
+
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
|
|
7775
|
+
},
|
|
7776
|
+
{
|
|
7777
|
+
packageName: "@types/react-native",
|
|
7778
|
+
rule: "expo-no-redundant-dependency",
|
|
7779
|
+
message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
|
|
7780
|
+
help: "Remove `@types/react-native` from your package.json",
|
|
7781
|
+
minSdkMajor: 48
|
|
7782
|
+
},
|
|
7783
|
+
{
|
|
7784
|
+
packageName: "@expo/config-plugins",
|
|
7785
|
+
rule: "expo-no-redundant-dependency",
|
|
7786
|
+
message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
|
|
7787
|
+
help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
|
|
7788
|
+
minSdkMajor: 48
|
|
7789
|
+
},
|
|
7790
|
+
{
|
|
7791
|
+
packageName: "@expo/prebuild-config",
|
|
7792
|
+
rule: "expo-no-redundant-dependency",
|
|
7793
|
+
message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
|
|
7794
|
+
help: "Remove `@expo/prebuild-config` from your package.json",
|
|
7795
|
+
minSdkMajor: 53
|
|
7796
|
+
},
|
|
7797
|
+
{
|
|
7798
|
+
packageName: "expo-permissions",
|
|
7799
|
+
rule: "expo-no-redundant-dependency",
|
|
7800
|
+
message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
|
|
7801
|
+
help: "Remove `expo-permissions` and request permissions from the relevant module instead",
|
|
7802
|
+
minSdkMajor: 50
|
|
7803
|
+
},
|
|
7804
|
+
{
|
|
7805
|
+
packageName: "expo-app-loading",
|
|
7806
|
+
rule: "expo-no-redundant-dependency",
|
|
7807
|
+
message: "\"expo-app-loading\" was removed in SDK 49",
|
|
7808
|
+
help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
|
|
7809
|
+
minSdkMajor: 49
|
|
7810
|
+
},
|
|
7811
|
+
{
|
|
7812
|
+
packageName: "expo-firebase-analytics",
|
|
7813
|
+
rule: "expo-no-redundant-dependency",
|
|
7814
|
+
message: "\"expo-firebase-analytics\" was removed in SDK 48",
|
|
7815
|
+
help: FIREBASE_HELP,
|
|
7816
|
+
minSdkMajor: 48
|
|
7817
|
+
},
|
|
7818
|
+
{
|
|
7819
|
+
packageName: "expo-firebase-recaptcha",
|
|
7820
|
+
rule: "expo-no-redundant-dependency",
|
|
7821
|
+
message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
|
|
7822
|
+
help: FIREBASE_HELP,
|
|
7823
|
+
minSdkMajor: 48
|
|
7824
|
+
},
|
|
7825
|
+
{
|
|
7826
|
+
packageName: "expo-firebase-core",
|
|
7827
|
+
rule: "expo-no-redundant-dependency",
|
|
7828
|
+
message: "\"expo-firebase-core\" was removed in SDK 48",
|
|
7829
|
+
help: FIREBASE_HELP,
|
|
7830
|
+
minSdkMajor: 48
|
|
7831
|
+
}
|
|
7832
|
+
];
|
|
7833
|
+
const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
|
|
7834
|
+
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
|
|
7835
|
+
if (flaggedDependency.minSdkMajor === void 0) return true;
|
|
7836
|
+
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
|
|
7837
|
+
}).map((flaggedDependency) => buildExpoDiagnostic({
|
|
7838
|
+
rule: flaggedDependency.rule,
|
|
7839
|
+
message: flaggedDependency.message,
|
|
7840
|
+
help: flaggedDependency.help
|
|
7841
|
+
}));
|
|
7842
|
+
const findLocalModuleNativeFiles = (rootDirectory) => {
|
|
7843
|
+
const modulesDirectory = path.join(rootDirectory, "modules");
|
|
7844
|
+
if (!isDirectory(modulesDirectory)) return [];
|
|
7845
|
+
const nativeFilePaths = [];
|
|
7846
|
+
for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
|
|
7847
|
+
if (!moduleEntry.isDirectory()) continue;
|
|
7848
|
+
const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
|
|
7849
|
+
const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
|
|
7850
|
+
if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
|
|
7851
|
+
const iosDirectory = path.join(moduleDirectory, "ios");
|
|
7852
|
+
if (isDirectory(iosDirectory)) {
|
|
7853
|
+
for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
|
|
7854
|
+
}
|
|
7855
|
+
}
|
|
7856
|
+
return nativeFilePaths;
|
|
7857
|
+
};
|
|
7858
|
+
const checkExpoGitignore = (context) => {
|
|
7859
|
+
const { rootDirectory } = context;
|
|
7860
|
+
const diagnostics = [];
|
|
7861
|
+
const expoStateDirectory = path.join(rootDirectory, ".expo");
|
|
7862
|
+
if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
|
|
7863
|
+
rule: "expo-gitignore",
|
|
7864
|
+
message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
|
|
7865
|
+
help: "Add `.expo/` to your .gitignore"
|
|
7866
|
+
}));
|
|
7867
|
+
if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
|
|
7868
|
+
rule: "expo-gitignore",
|
|
7869
|
+
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",
|
|
7870
|
+
help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
|
|
7871
|
+
}));
|
|
7872
|
+
return diagnostics;
|
|
7873
|
+
};
|
|
7874
|
+
const LOCKFILE_NAMES = [
|
|
7875
|
+
"pnpm-lock.yaml",
|
|
7876
|
+
"yarn.lock",
|
|
7877
|
+
"package-lock.json",
|
|
7878
|
+
"bun.lockb",
|
|
7879
|
+
"bun.lock"
|
|
7880
|
+
];
|
|
7881
|
+
const checkExpoLockfile = (context) => {
|
|
7882
|
+
const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
|
|
7883
|
+
const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
|
|
7884
|
+
if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
|
|
7885
|
+
rule: "expo-lockfile",
|
|
7886
|
+
message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
|
|
7887
|
+
help: "Install dependencies with your package manager to generate a lock file, then commit it"
|
|
7888
|
+
})];
|
|
7889
|
+
if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
|
|
7890
|
+
rule: "expo-lockfile",
|
|
7891
|
+
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`,
|
|
7892
|
+
help: "Delete the lock files for the package managers you are not using and keep only one"
|
|
7893
|
+
})];
|
|
7894
|
+
return [];
|
|
7895
|
+
};
|
|
7896
|
+
const METRO_CONFIG_FILE_NAMES = [
|
|
7897
|
+
"metro.config.js",
|
|
7898
|
+
"metro.config.cjs",
|
|
7899
|
+
"metro.config.mjs",
|
|
7900
|
+
"metro.config.ts"
|
|
7901
|
+
];
|
|
7902
|
+
const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
|
|
7903
|
+
"expo/metro-config",
|
|
7904
|
+
"@sentry/react-native/metro",
|
|
7905
|
+
"getSentryExpoConfig"
|
|
7906
|
+
];
|
|
7907
|
+
const checkExpoMetroConfig = (context) => {
|
|
7908
|
+
const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
|
|
7909
|
+
if (metroConfigPath === void 0) return [];
|
|
7910
|
+
let contents;
|
|
7911
|
+
try {
|
|
7912
|
+
contents = fs.readFileSync(metroConfigPath, "utf-8");
|
|
7913
|
+
} catch {
|
|
7914
|
+
return [];
|
|
7915
|
+
}
|
|
7916
|
+
if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
|
|
7917
|
+
return [buildExpoDiagnostic({
|
|
7918
|
+
rule: "expo-metro-config",
|
|
7919
|
+
filePath: path.basename(metroConfigPath),
|
|
7920
|
+
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",
|
|
7921
|
+
help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
|
|
7922
|
+
})];
|
|
7923
|
+
};
|
|
7924
|
+
const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
|
|
7925
|
+
const checkExpoPackageJsonConflicts = (context) => {
|
|
7926
|
+
const { packageJson } = context;
|
|
7927
|
+
const diagnostics = [];
|
|
7928
|
+
const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
|
|
7929
|
+
if (conflictingScriptNames.length > 0) {
|
|
7930
|
+
const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
|
|
7931
|
+
const shadowsExpoCli = conflictingScriptNames.includes("expo");
|
|
7932
|
+
diagnostics.push(buildExpoDiagnostic({
|
|
7933
|
+
rule: "expo-package-json-conflict",
|
|
7934
|
+
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" : ""}`,
|
|
7935
|
+
help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
|
|
7936
|
+
}));
|
|
7937
|
+
}
|
|
7938
|
+
const packageName = packageJson.name;
|
|
7939
|
+
if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
|
|
7940
|
+
rule: "expo-package-json-conflict",
|
|
7941
|
+
message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
|
|
7942
|
+
help: "Rename your package so it no longer matches one of its dependencies"
|
|
7943
|
+
}));
|
|
7944
|
+
return diagnostics;
|
|
7945
|
+
};
|
|
7946
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
7947
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
7948
|
+
const checkExpoRouterReactNavigation = (context) => {
|
|
7949
|
+
const { expoSdkMajor } = context;
|
|
7950
|
+
if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
|
|
7951
|
+
if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
|
|
7952
|
+
if (!context.directDependencyNames.has("expo-router")) return [];
|
|
7953
|
+
const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
|
|
7954
|
+
if (reactNavigationNames.length === 0) return [];
|
|
7955
|
+
return [buildExpoDiagnostic({
|
|
7956
|
+
rule: "expo-router-no-react-navigation",
|
|
7957
|
+
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"}`,
|
|
7958
|
+
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/"
|
|
7959
|
+
})];
|
|
7960
|
+
};
|
|
7961
|
+
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
7962
|
+
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
7963
|
+
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
7964
|
+
const checkExpoVectorIcons = (context) => {
|
|
7965
|
+
if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
|
|
7966
|
+
const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
|
|
7967
|
+
const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
|
|
7968
|
+
if (!hasScopedPackage || !hasConflictingPackage) return [];
|
|
7969
|
+
return [buildExpoDiagnostic({
|
|
7970
|
+
rule: "expo-vector-icons-conflict",
|
|
7971
|
+
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",
|
|
7972
|
+
help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
|
|
7973
|
+
})];
|
|
7974
|
+
};
|
|
7975
|
+
const checkExpoProject = (rootDirectory, project) => {
|
|
7976
|
+
if (project.expoVersion === null) return [];
|
|
7977
|
+
const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
|
|
7978
|
+
return [
|
|
7979
|
+
...checkExpoFlaggedDependencies(context),
|
|
7980
|
+
...checkExpoDependencyOverrides(context),
|
|
7981
|
+
...checkExpoRouterReactNavigation(context),
|
|
7982
|
+
...checkExpoVectorIcons(context),
|
|
7983
|
+
...checkExpoPackageJsonConflicts(context),
|
|
7984
|
+
...checkExpoLockfile(context),
|
|
7985
|
+
...checkExpoGitignore(context),
|
|
7986
|
+
...checkExpoEnvLocalFiles(context),
|
|
7987
|
+
...checkExpoMetroConfig(context)
|
|
7988
|
+
];
|
|
7989
|
+
};
|
|
7484
7990
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
7485
7991
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
7486
7992
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -8758,6 +9264,7 @@ const buildCapabilities = (project) => {
|
|
|
8758
9264
|
const capabilities = /* @__PURE__ */ new Set();
|
|
8759
9265
|
capabilities.add(project.framework);
|
|
8760
9266
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
9267
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
8761
9268
|
const reactMajor = project.reactMajorVersion;
|
|
8762
9269
|
if (reactMajor !== null) {
|
|
8763
9270
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -8929,10 +9436,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
8929
9436
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
8930
9437
|
return fs.realpathSync(rootDirectory);
|
|
8931
9438
|
};
|
|
9439
|
+
const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
|
|
9440
|
+
if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
|
|
9441
|
+
return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
9442
|
+
};
|
|
8932
9443
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
8933
9444
|
const enabledRules = {};
|
|
8934
9445
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
8935
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
9446
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
8936
9447
|
if (severity === "off") continue;
|
|
8937
9448
|
enabledRules[ruleKey] = severity;
|
|
8938
9449
|
}
|
|
@@ -8974,7 +9485,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
8974
9485
|
category: rule.category
|
|
8975
9486
|
}, severityControls);
|
|
8976
9487
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
8977
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
9488
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
8978
9489
|
if (severity === "off") continue;
|
|
8979
9490
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
8980
9491
|
}
|
|
@@ -9031,6 +9542,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
9031
9542
|
}
|
|
9032
9543
|
return uniqueDiagnostics;
|
|
9033
9544
|
};
|
|
9545
|
+
/**
|
|
9546
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
9547
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
9548
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
9549
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
9550
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
9551
|
+
* batches do).
|
|
9552
|
+
*
|
|
9553
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
9554
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
9555
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
9556
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
9557
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
9558
|
+
* top of a still-running first one.
|
|
9559
|
+
*/
|
|
9560
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
9561
|
+
const results = new Array(items.length);
|
|
9562
|
+
if (items.length === 0) return results;
|
|
9563
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
9564
|
+
let nextIndex = 0;
|
|
9565
|
+
const errors = [];
|
|
9566
|
+
const runWorker = async () => {
|
|
9567
|
+
while (errors.length === 0) {
|
|
9568
|
+
const index = nextIndex;
|
|
9569
|
+
nextIndex += 1;
|
|
9570
|
+
if (index >= items.length) return;
|
|
9571
|
+
try {
|
|
9572
|
+
results[index] = await task(items[index], index);
|
|
9573
|
+
} catch (error) {
|
|
9574
|
+
errors.push(error);
|
|
9575
|
+
return;
|
|
9576
|
+
}
|
|
9577
|
+
}
|
|
9578
|
+
};
|
|
9579
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
9580
|
+
if (errors.length > 0) throw errors[0];
|
|
9581
|
+
return results;
|
|
9582
|
+
};
|
|
9034
9583
|
const getPublicEnvPrefix = (framework) => {
|
|
9035
9584
|
switch (framework) {
|
|
9036
9585
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -9713,6 +10262,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
9713
10262
|
*/
|
|
9714
10263
|
const spawnLintBatches = async (input) => {
|
|
9715
10264
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
10265
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
9716
10266
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
9717
10267
|
const allDiagnostics = [];
|
|
9718
10268
|
const droppedFiles = [];
|
|
@@ -9732,23 +10282,31 @@ const spawnLintBatches = async (input) => {
|
|
|
9732
10282
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
9733
10283
|
}
|
|
9734
10284
|
};
|
|
10285
|
+
let startedFileCount = 0;
|
|
9735
10286
|
let scannedFileCount = 0;
|
|
9736
|
-
|
|
9737
|
-
|
|
9738
|
-
const
|
|
9739
|
-
|
|
9740
|
-
|
|
9741
|
-
|
|
9742
|
-
|
|
9743
|
-
|
|
9744
|
-
|
|
10287
|
+
let displayedFileCount = 0;
|
|
10288
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
10289
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
10290
|
+
if (displayedFileCount < ceiling) {
|
|
10291
|
+
displayedFileCount += 1;
|
|
10292
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10293
|
+
}
|
|
10294
|
+
}, 50) : null;
|
|
10295
|
+
progressTimer?.unref?.();
|
|
10296
|
+
try {
|
|
10297
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
10298
|
+
startedFileCount += batch.length;
|
|
9745
10299
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
9746
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
9747
10300
|
scannedFileCount += batch.length;
|
|
9748
|
-
onFileProgress
|
|
9749
|
-
|
|
9750
|
-
|
|
9751
|
-
|
|
10301
|
+
if (onFileProgress) {
|
|
10302
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
10303
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10304
|
+
}
|
|
10305
|
+
return batchDiagnostics;
|
|
10306
|
+
});
|
|
10307
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
10308
|
+
} finally {
|
|
10309
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
9752
10310
|
}
|
|
9753
10311
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
9754
10312
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -9875,7 +10433,8 @@ const runOxlint = async (options) => {
|
|
|
9875
10433
|
onPartialFailure,
|
|
9876
10434
|
onFileProgress: options.onFileProgress,
|
|
9877
10435
|
spawnTimeoutMs,
|
|
9878
|
-
outputMaxBytes
|
|
10436
|
+
outputMaxBytes,
|
|
10437
|
+
concurrency: options.concurrency
|
|
9879
10438
|
});
|
|
9880
10439
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
9881
10440
|
try {
|
|
@@ -9943,6 +10502,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9943
10502
|
const partialFailures = yield* LintPartialFailures;
|
|
9944
10503
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
9945
10504
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
10505
|
+
const concurrency = yield* OxlintConcurrency;
|
|
9946
10506
|
const collectedFailures = [];
|
|
9947
10507
|
const diagnostics = yield* Effect.tryPromise({
|
|
9948
10508
|
try: () => runOxlint({
|
|
@@ -9961,7 +10521,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9961
10521
|
},
|
|
9962
10522
|
onFileProgress: input.onFileProgress,
|
|
9963
10523
|
spawnTimeoutMs,
|
|
9964
|
-
outputMaxBytes
|
|
10524
|
+
outputMaxBytes,
|
|
10525
|
+
concurrency
|
|
9965
10526
|
}),
|
|
9966
10527
|
catch: ensureReactDoctorError
|
|
9967
10528
|
});
|
|
@@ -10294,7 +10855,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10294
10855
|
showWarnings
|
|
10295
10856
|
});
|
|
10296
10857
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
10297
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10858
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10859
|
+
...checkReducedMotion(scanDirectory),
|
|
10860
|
+
...checkPnpmHardening(scanDirectory),
|
|
10861
|
+
...checkExpoProject(scanDirectory, project)
|
|
10862
|
+
];
|
|
10298
10863
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
10299
10864
|
const lintFailure = yield* Ref.make({
|
|
10300
10865
|
didFail: false,
|
|
@@ -10306,6 +10871,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10306
10871
|
didFail: false,
|
|
10307
10872
|
reason: null
|
|
10308
10873
|
});
|
|
10874
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
10875
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
10309
10876
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
10310
10877
|
const scanStartTime = Date.now();
|
|
10311
10878
|
let lastReportedTotalFileCount = 0;
|
|
@@ -10322,7 +10889,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10322
10889
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
10323
10890
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
10324
10891
|
lastReportedTotalFileCount = totalFileCount;
|
|
10325
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
10892
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
10326
10893
|
}
|
|
10327
10894
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
10328
10895
|
yield* Ref.set(lintFailure, {
|
|
@@ -10354,7 +10921,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10354
10921
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
10355
10922
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
10356
10923
|
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
10357
|
-
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
|
|
10924
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
10358
10925
|
yield* reporterService.finalize;
|
|
10359
10926
|
const finalDiagnostics = [
|
|
10360
10927
|
...envCollected,
|
|
@@ -10406,7 +10973,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10406
10973
|
"inspect.isCi": input.isCi,
|
|
10407
10974
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
10408
10975
|
} }));
|
|
10409
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
10410
10976
|
const parseNodeVersion = (versionString) => {
|
|
10411
10977
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
10412
10978
|
return {
|
|
@@ -10729,6 +11295,26 @@ const buildJsonReport = (input) => {
|
|
|
10729
11295
|
};
|
|
10730
11296
|
};
|
|
10731
11297
|
/**
|
|
11298
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
11299
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
11300
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
11301
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
11302
|
+
* branch logic lives here once.
|
|
11303
|
+
*/
|
|
11304
|
+
const buildSkippedChecks = (input) => {
|
|
11305
|
+
const skippedChecks = [];
|
|
11306
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
11307
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
11308
|
+
const skippedCheckReasons = {};
|
|
11309
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
11310
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
11311
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
11312
|
+
return {
|
|
11313
|
+
skippedChecks,
|
|
11314
|
+
skippedCheckReasons
|
|
11315
|
+
};
|
|
11316
|
+
};
|
|
11317
|
+
/**
|
|
10732
11318
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
10733
11319
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
10734
11320
|
* spawn, not `spawnSync`).
|
|
@@ -10864,6 +11450,46 @@ const groupBy = (items, keyFn) => {
|
|
|
10864
11450
|
*/
|
|
10865
11451
|
const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
|
|
10866
11452
|
//#endregion
|
|
11453
|
+
//#region src/cli/utils/constants.ts
|
|
11454
|
+
const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
|
|
11455
|
+
const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
|
|
11456
|
+
const SENTRY_DSN = "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920";
|
|
11457
|
+
//#endregion
|
|
11458
|
+
//#region src/cli/utils/version.ts
|
|
11459
|
+
const VERSION = "0.2.14-dev.24425b1";
|
|
11460
|
+
//#endregion
|
|
11461
|
+
//#region src/instrument.ts
|
|
11462
|
+
let isInitialized = false;
|
|
11463
|
+
const shouldEnableSentry = () => {
|
|
11464
|
+
if (process.argv.includes("--no-score")) return false;
|
|
11465
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
|
|
11466
|
+
return true;
|
|
11467
|
+
};
|
|
11468
|
+
/**
|
|
11469
|
+
* Initializes the Sentry Node SDK for CLI crash reporting. Invoked as
|
|
11470
|
+
* the first statement of the CLI entry (`cli/index.ts`) so the SDK's
|
|
11471
|
+
* global `uncaughtException` / `unhandledRejection` handlers are armed
|
|
11472
|
+
* before any command runs.
|
|
11473
|
+
*
|
|
11474
|
+
* Exported as a function rather than a bare side-effecting import
|
|
11475
|
+
* because the package declares `"sideEffects": false`, which lets the
|
|
11476
|
+
* bundler tree-shake side-effect-only modules. An explicit call keeps
|
|
11477
|
+
* the initialization in the published `dist/cli.js`.
|
|
11478
|
+
*
|
|
11479
|
+
* Scoped to the CLI application only — the programmatic
|
|
11480
|
+
* `@react-doctor/api` library never initializes Sentry, so importing
|
|
11481
|
+
* `diagnose()` into a consumer app can't hijack their telemetry.
|
|
11482
|
+
*/
|
|
11483
|
+
const initializeSentry = () => {
|
|
11484
|
+
if (isInitialized || !shouldEnableSentry()) return;
|
|
11485
|
+
isInitialized = true;
|
|
11486
|
+
Sentry.init({
|
|
11487
|
+
dsn: SENTRY_DSN,
|
|
11488
|
+
sendDefaultPii: true,
|
|
11489
|
+
release: VERSION
|
|
11490
|
+
});
|
|
11491
|
+
};
|
|
11492
|
+
//#endregion
|
|
10867
11493
|
//#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
10868
11494
|
const ANSI_BACKGROUND_OFFSET = 10;
|
|
10869
11495
|
const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
|
|
@@ -13817,23 +14443,60 @@ const CI_ENVIRONMENT_VARIABLES = [
|
|
|
13817
14443
|
"GITLAB_CI",
|
|
13818
14444
|
"CIRCLECI"
|
|
13819
14445
|
];
|
|
13820
|
-
const
|
|
13821
|
-
"
|
|
13822
|
-
"
|
|
13823
|
-
"
|
|
13824
|
-
"
|
|
13825
|
-
"
|
|
13826
|
-
"
|
|
13827
|
-
"
|
|
13828
|
-
"
|
|
13829
|
-
"
|
|
13830
|
-
"
|
|
13831
|
-
"
|
|
14446
|
+
const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
|
|
14447
|
+
["GITHUB_ACTIONS", "github-actions"],
|
|
14448
|
+
["GITLAB_CI", "gitlab-ci"],
|
|
14449
|
+
["CIRCLECI", "circleci"],
|
|
14450
|
+
["BUILDKITE", "buildkite"],
|
|
14451
|
+
["JENKINS_URL", "jenkins"],
|
|
14452
|
+
["TF_BUILD", "azure-pipelines"],
|
|
14453
|
+
["CODEBUILD_BUILD_ID", "aws-codebuild"],
|
|
14454
|
+
["TEAMCITY_VERSION", "teamcity"],
|
|
14455
|
+
["BITBUCKET_BUILD_NUMBER", "bitbucket"],
|
|
14456
|
+
["TRAVIS", "travis"],
|
|
14457
|
+
["DRONE", "drone"]
|
|
13832
14458
|
];
|
|
14459
|
+
const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
|
|
14460
|
+
["CLAUDECODE", "claude-code"],
|
|
14461
|
+
["CLAUDE_CODE", "claude-code"],
|
|
14462
|
+
["CURSOR_AGENT", "cursor"],
|
|
14463
|
+
["CODEX_CI", "codex"],
|
|
14464
|
+
["CODEX_SANDBOX", "codex"],
|
|
14465
|
+
["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
|
|
14466
|
+
["OPENCODE", "opencode"],
|
|
14467
|
+
["GOOSE_TERMINAL", "goose"],
|
|
14468
|
+
["AMP_THREAD_ID", "amp"]
|
|
14469
|
+
];
|
|
14470
|
+
const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
|
|
13833
14471
|
const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
|
|
13834
14472
|
const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
|
|
13835
|
-
|
|
13836
|
-
const
|
|
14473
|
+
[...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
|
|
14474
|
+
const FALSY_CI_FLAG_VALUES = new Set([
|
|
14475
|
+
"",
|
|
14476
|
+
"0",
|
|
14477
|
+
"false"
|
|
14478
|
+
]);
|
|
14479
|
+
const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
|
|
14480
|
+
const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
|
|
14481
|
+
const detectCiProvider = () => {
|
|
14482
|
+
for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
|
|
14483
|
+
return isCiFlagSet(process.env.CI) ? "unknown" : null;
|
|
14484
|
+
};
|
|
14485
|
+
const detectCodingAgentFromValue = () => {
|
|
14486
|
+
for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
|
|
14487
|
+
const value = process.env[environmentVariable]?.toLowerCase();
|
|
14488
|
+
if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
|
|
14489
|
+
}
|
|
14490
|
+
return null;
|
|
14491
|
+
};
|
|
14492
|
+
const detectCodingAgent = () => {
|
|
14493
|
+
for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
|
|
14494
|
+
const agentFromValue = detectCodingAgentFromValue();
|
|
14495
|
+
if (agentFromValue) return agentFromValue;
|
|
14496
|
+
if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
|
|
14497
|
+
return null;
|
|
14498
|
+
};
|
|
14499
|
+
const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
|
|
13837
14500
|
const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
|
|
13838
14501
|
//#endregion
|
|
13839
14502
|
//#region src/cli/utils/is-non-interactive-environment.ts
|
|
@@ -13935,9 +14598,8 @@ const buildSpinnerProgressHandle = (text) => {
|
|
|
13935
14598
|
* construction and post-scan rendering — layer wiring is its own
|
|
13936
14599
|
* concern with its own contract.
|
|
13937
14600
|
*
|
|
13938
|
-
* Same shape as
|
|
13939
|
-
*
|
|
13940
|
-
* differences specific to the CLI path:
|
|
14601
|
+
* Same service shape as `@react-doctor/api → diagnose()`'s
|
|
14602
|
+
* `buildDiagnoseLayer`, with the differences specific to the CLI path:
|
|
13941
14603
|
*
|
|
13942
14604
|
* - **Config**: when the caller passes `configOverride`, the
|
|
13943
14605
|
* already-loaded config is provided via `Config.layerOf` instead
|
|
@@ -13963,7 +14625,8 @@ const buildRuntimeLayers = (input) => {
|
|
|
13963
14625
|
resolvedDirectory: input.directory,
|
|
13964
14626
|
configSourceDirectory: input.configSourceDirectory
|
|
13965
14627
|
}) : Config.layerNode;
|
|
13966
|
-
|
|
14628
|
+
const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
|
|
14629
|
+
return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
|
|
13967
14630
|
};
|
|
13968
14631
|
//#endregion
|
|
13969
14632
|
//#region src/cli/utils/noop-console.ts
|
|
@@ -14366,10 +15029,6 @@ const colorizeByScore = (text, score) => {
|
|
|
14366
15029
|
return highlighter.error(text);
|
|
14367
15030
|
};
|
|
14368
15031
|
//#endregion
|
|
14369
|
-
//#region src/cli/utils/constants.ts
|
|
14370
|
-
const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
|
|
14371
|
-
const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
|
|
14372
|
-
//#endregion
|
|
14373
15032
|
//#region src/cli/utils/render-score-header.ts
|
|
14374
15033
|
const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
|
|
14375
15034
|
const RAINBOW_GRADIENT_WIDTH = 80;
|
|
@@ -14754,9 +15413,6 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
|
|
|
14754
15413
|
});
|
|
14755
15414
|
const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
|
|
14756
15415
|
//#endregion
|
|
14757
|
-
//#region src/cli/utils/version.ts
|
|
14758
|
-
const VERSION = "0.2.14-dev.09fe1ff";
|
|
14759
|
-
//#endregion
|
|
14760
15416
|
//#region src/inspect.ts
|
|
14761
15417
|
const silentConsole = makeNoopConsole();
|
|
14762
15418
|
const runConsole = (effect) => {
|
|
@@ -14785,7 +15441,8 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
14785
15441
|
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
14786
15442
|
ignoredTags: buildIgnoredTags(userConfig),
|
|
14787
15443
|
outputSurface: inputOptions.outputSurface ?? "cli",
|
|
14788
|
-
suppressRendering: inputOptions.suppressRendering ?? false
|
|
15444
|
+
suppressRendering: inputOptions.suppressRendering ?? false,
|
|
15445
|
+
concurrency: inputOptions.concurrency
|
|
14789
15446
|
});
|
|
14790
15447
|
const inspect = async (directory, inputOptions = {}) => {
|
|
14791
15448
|
const startTime = performance.now();
|
|
@@ -14825,7 +15482,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14825
15482
|
shouldSkipLint: !options.lint || lintBindingMissing,
|
|
14826
15483
|
shouldRunDeadCode: options.deadCode,
|
|
14827
15484
|
shouldComputeScore: !options.noScore,
|
|
14828
|
-
shouldShowProgressSpinners
|
|
15485
|
+
shouldShowProgressSpinners,
|
|
15486
|
+
oxlintConcurrency: options.concurrency
|
|
14829
15487
|
});
|
|
14830
15488
|
const program = runInspect({
|
|
14831
15489
|
directory,
|
|
@@ -14881,15 +15539,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14881
15539
|
};
|
|
14882
15540
|
const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
14883
15541
|
const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
|
|
14884
|
-
const skippedChecks =
|
|
14885
|
-
|
|
14886
|
-
|
|
15542
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
|
|
15543
|
+
didLintFail,
|
|
15544
|
+
lintFailureReason,
|
|
15545
|
+
lintPartialFailures,
|
|
15546
|
+
didDeadCodeFail,
|
|
15547
|
+
deadCodeFailureReason
|
|
15548
|
+
});
|
|
14887
15549
|
const hasSkippedChecks = skippedChecks.length > 0;
|
|
14888
15550
|
const noScoreMessage = buildNoScoreMessage(options.noScore);
|
|
14889
|
-
const skippedCheckReasons = {};
|
|
14890
|
-
if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
|
|
14891
|
-
else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
|
|
14892
|
-
if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
|
|
14893
15551
|
const buildResult = () => ({
|
|
14894
15552
|
diagnostics: [...diagnostics],
|
|
14895
15553
|
score,
|
|
@@ -15043,6 +15701,7 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
|
|
|
15043
15701
|
yield* Console.error("");
|
|
15044
15702
|
yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
|
|
15045
15703
|
yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
|
|
15704
|
+
yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
|
|
15046
15705
|
yield* Console.error("");
|
|
15047
15706
|
yield* Console.error(highlighter.error(formatErrorForReport(error)));
|
|
15048
15707
|
yield* Console.error("");
|
|
@@ -16492,6 +17151,78 @@ const printBrandedHeader = Effect.gen(function* () {
|
|
|
16492
17151
|
yield* Console.log("");
|
|
16493
17152
|
});
|
|
16494
17153
|
//#endregion
|
|
17154
|
+
//#region src/cli/utils/build-run-context.ts
|
|
17155
|
+
const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
|
|
17156
|
+
const detectOrigin = () => {
|
|
17157
|
+
if (process.env.GIT_DIR) return "git-hook";
|
|
17158
|
+
if (isCodingAgentEnvironment()) return "agent";
|
|
17159
|
+
if (isCiEnvironment()) return "ci";
|
|
17160
|
+
return "cli";
|
|
17161
|
+
};
|
|
17162
|
+
const detectCommand = (userArguments) => {
|
|
17163
|
+
for (const argument of userArguments) {
|
|
17164
|
+
if (argument === "--") break;
|
|
17165
|
+
if (argument.startsWith("-")) continue;
|
|
17166
|
+
return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
|
|
17167
|
+
}
|
|
17168
|
+
return "inspect";
|
|
17169
|
+
};
|
|
17170
|
+
/**
|
|
17171
|
+
* Snapshot of the current invocation, attached to Sentry events as the
|
|
17172
|
+
* `run` context to make crashes triage-able (which version, platform,
|
|
17173
|
+
* CI/agent, how it was invoked). Every field is cheap, synchronous, and
|
|
17174
|
+
* safe to read at any point — cwd reads fall back, env reads are
|
|
17175
|
+
* booleans — so it's rebuilt lazily at capture time when runtime-only
|
|
17176
|
+
* signals like `jsonMode` are finally known.
|
|
17177
|
+
*/
|
|
17178
|
+
const buildRunContext = () => {
|
|
17179
|
+
const userArguments = process.argv.slice(2);
|
|
17180
|
+
return {
|
|
17181
|
+
version: VERSION,
|
|
17182
|
+
origin: detectOrigin(),
|
|
17183
|
+
command: detectCommand(userArguments),
|
|
17184
|
+
argv: userArguments.join(" "),
|
|
17185
|
+
cwd: process.cwd(),
|
|
17186
|
+
node: process.version,
|
|
17187
|
+
platform: process.platform,
|
|
17188
|
+
arch: process.arch,
|
|
17189
|
+
ci: isCiEnvironment(),
|
|
17190
|
+
ciProvider: detectCiProvider(),
|
|
17191
|
+
codingAgent: detectCodingAgent(),
|
|
17192
|
+
interactive: !isNonInteractiveEnvironment(),
|
|
17193
|
+
jsonMode: isJsonModeActive()
|
|
17194
|
+
};
|
|
17195
|
+
};
|
|
17196
|
+
//#endregion
|
|
17197
|
+
//#region src/cli/utils/report-error.ts
|
|
17198
|
+
/**
|
|
17199
|
+
* Sends an error to Sentry, enriched with a snapshot of the current run
|
|
17200
|
+
* (version, platform, CI/agent, invocation), and waits for delivery
|
|
17201
|
+
* before the caller exits. The CLI tears down the process synchronously
|
|
17202
|
+
* after rendering an error, so the awaited `flush` is what actually gets
|
|
17203
|
+
* the event off the machine (see the Sentry CLI/serverless flush
|
|
17204
|
+
* contract).
|
|
17205
|
+
*
|
|
17206
|
+
* Returns early when Sentry was never initialized (`--no-score`, tests,
|
|
17207
|
+
* or a missing DSN), and swallows any transport failure so telemetry can
|
|
17208
|
+
* never mask the user's original error.
|
|
17209
|
+
*/
|
|
17210
|
+
const reportErrorToSentry = async (error) => {
|
|
17211
|
+
if (!Sentry.isInitialized()) return;
|
|
17212
|
+
try {
|
|
17213
|
+
const runContext = buildRunContext();
|
|
17214
|
+
Sentry.setContext("run", { ...runContext });
|
|
17215
|
+
Sentry.setTags({
|
|
17216
|
+
origin: runContext.origin,
|
|
17217
|
+
command: runContext.command,
|
|
17218
|
+
ciProvider: runContext.ciProvider,
|
|
17219
|
+
codingAgent: runContext.codingAgent
|
|
17220
|
+
});
|
|
17221
|
+
Sentry.captureException(error);
|
|
17222
|
+
await Sentry.flush(2e3);
|
|
17223
|
+
} catch {}
|
|
17224
|
+
};
|
|
17225
|
+
//#endregion
|
|
16495
17226
|
//#region src/cli/utils/path-format.ts
|
|
16496
17227
|
const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
|
|
16497
17228
|
//#endregion
|
|
@@ -16636,6 +17367,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
|
|
|
16636
17367
|
for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
|
|
16637
17368
|
};
|
|
16638
17369
|
//#endregion
|
|
17370
|
+
//#region src/cli/utils/resolve-parallel-flag.ts
|
|
17371
|
+
/**
|
|
17372
|
+
* Translates the `--experimental-parallel [workers]` flag into a concrete
|
|
17373
|
+
* worker count for `InspectOptions.concurrency`:
|
|
17374
|
+
*
|
|
17375
|
+
* - flag absent (`undefined`) → `undefined` (defer to the ambient
|
|
17376
|
+
* default: serial unless `REACT_DOCTOR_PARALLEL` is set)
|
|
17377
|
+
* - bare flag / `auto` → auto-detect CPU cores
|
|
17378
|
+
* - `--experimental-parallel <n>` → `n` workers (clamped)
|
|
17379
|
+
* - `false` / `off` / `0` → serial (an explicit opt-out, so
|
|
17380
|
+
* it overrides an env-enabled default rather than deferring to it)
|
|
17381
|
+
* - an unparseable value → auto-detect cores
|
|
17382
|
+
*
|
|
17383
|
+
* Commander yields `true` for a bare flag, the raw string for an explicit
|
|
17384
|
+
* value, and `undefined` when the flag is omitted.
|
|
17385
|
+
*/
|
|
17386
|
+
const resolveParallelFlag = (parallel) => {
|
|
17387
|
+
if (parallel === void 0) return void 0;
|
|
17388
|
+
if (parallel === true) return resolveScanConcurrency("auto");
|
|
17389
|
+
if (parallel === false) return 1;
|
|
17390
|
+
const normalized = parallel.trim().toLowerCase();
|
|
17391
|
+
if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
|
|
17392
|
+
if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
|
|
17393
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
17394
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
|
|
17395
|
+
return resolveScanConcurrency(parsed);
|
|
17396
|
+
};
|
|
17397
|
+
//#endregion
|
|
16639
17398
|
//#region src/cli/utils/resolve-cli-inspect-options.ts
|
|
16640
17399
|
/**
|
|
16641
17400
|
* Translates CLI flags into the `InspectOptions` contract `inspect()`
|
|
@@ -16661,7 +17420,8 @@ const resolveCliInspectOptions = (flags, userConfig) => {
|
|
|
16661
17420
|
noScore: flags.score === false || (userConfig?.noScore ?? false),
|
|
16662
17421
|
isCi: isCiEnvironment(),
|
|
16663
17422
|
silent: Boolean(flags.json),
|
|
16664
|
-
outputSurface: flags.prComment ? "prComment" : "cli"
|
|
17423
|
+
outputSurface: flags.prComment ? "prComment" : "cli",
|
|
17424
|
+
concurrency: resolveParallelFlag(flags.experimentalParallel)
|
|
16665
17425
|
};
|
|
16666
17426
|
};
|
|
16667
17427
|
//#endregion
|
|
@@ -17145,6 +17905,7 @@ const inspectAction = async (directory, flags) => {
|
|
|
17145
17905
|
})) printAgentInstallHint();
|
|
17146
17906
|
}
|
|
17147
17907
|
} catch (error) {
|
|
17908
|
+
await reportErrorToSentry(error);
|
|
17148
17909
|
if (isJsonMode) {
|
|
17149
17910
|
writeJsonErrorReport(error);
|
|
17150
17911
|
process.exitCode = 1;
|
|
@@ -17166,6 +17927,7 @@ const installAction = async (options, command) => {
|
|
|
17166
17927
|
projectRoot: options.cwd ?? process.cwd()
|
|
17167
17928
|
});
|
|
17168
17929
|
} catch (error) {
|
|
17930
|
+
await reportErrorToSentry(error);
|
|
17169
17931
|
handleError(error);
|
|
17170
17932
|
}
|
|
17171
17933
|
};
|
|
@@ -17211,7 +17973,7 @@ const ROOT_FLAG_SPEC = {
|
|
|
17211
17973
|
"--project",
|
|
17212
17974
|
"--why"
|
|
17213
17975
|
]),
|
|
17214
|
-
longOptionsWithOptionalValues: new Set(["--diff"]),
|
|
17976
|
+
longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
|
|
17215
17977
|
shortOptionsWithoutValues: new Set([
|
|
17216
17978
|
"-h",
|
|
17217
17979
|
"-v",
|
|
@@ -17300,10 +18062,11 @@ const stripUnknownCliFlags = (argv) => {
|
|
|
17300
18062
|
};
|
|
17301
18063
|
//#endregion
|
|
17302
18064
|
//#region src/cli/index.ts
|
|
18065
|
+
initializeSentry();
|
|
17303
18066
|
process.on("SIGINT", exitGracefully);
|
|
17304
18067
|
process.on("SIGTERM", exitGracefully);
|
|
17305
18068
|
unrefStdin();
|
|
17306
|
-
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("--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 and the share URL").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 (errors always show)").option("--no-warnings", "hide warning-severity diagnostics (default)").addHelpText("after", `
|
|
18069
|
+
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 and the share URL").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 (errors always show)").option("--no-warnings", "hide warning-severity diagnostics (default)").addHelpText("after", `
|
|
17307
18070
|
${highlighter.dim("Configuration:")}
|
|
17308
18071
|
Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
|
|
17309
18072
|
CLI flags always override config values. See the README for the full schema.
|
|
@@ -17316,7 +18079,8 @@ program.command("install").alias("setup").description("Install the react-doctor
|
|
|
17316
18079
|
process.stdout.on("error", (error) => {
|
|
17317
18080
|
if (error.code === "EPIPE") process.exit(0);
|
|
17318
18081
|
});
|
|
17319
|
-
program.parseAsync(stripUnknownCliFlags(process.argv)).catch((error) => {
|
|
18082
|
+
program.parseAsync(stripUnknownCliFlags(process.argv)).catch(async (error) => {
|
|
18083
|
+
await reportErrorToSentry(error);
|
|
17320
18084
|
if (isJsonModeActive()) {
|
|
17321
18085
|
writeJsonErrorReport(error);
|
|
17322
18086
|
process.exit(1);
|