react-doctor 0.2.14-dev.9fc6f62 → 0.2.14-dev.b3c3aa9
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 +753 -92
- package/dist/index.d.ts +39 -1
- package/dist/index.js +683 -75
- package/package.json +4 -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,7 +28,6 @@ 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
33
|
import { performance } from "node:perf_hooks";
|
|
@@ -5892,29 +5892,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
5892
5892
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
5893
5893
|
};
|
|
5894
5894
|
};
|
|
5895
|
-
const
|
|
5896
|
-
|
|
5895
|
+
const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
|
|
5896
|
+
const rootValue = select(rootPackageJson);
|
|
5897
|
+
if (rootValue !== null) return rootValue;
|
|
5897
5898
|
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
5898
|
-
if (patterns.length === 0) return
|
|
5899
|
+
if (patterns.length === 0) return null;
|
|
5899
5900
|
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
5900
5901
|
for (const pattern of patterns) {
|
|
5901
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
5902
|
+
const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
|
|
5902
5903
|
for (const workspaceDirectory of directories) {
|
|
5903
5904
|
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
5904
5905
|
visitedDirectories.add(workspaceDirectory);
|
|
5905
|
-
|
|
5906
|
+
const value = select(readPackageJson$1(path.join(workspaceDirectory, "package.json")));
|
|
5907
|
+
if (value !== null) return value;
|
|
5906
5908
|
}
|
|
5907
5909
|
}
|
|
5908
|
-
return
|
|
5910
|
+
return null;
|
|
5909
5911
|
};
|
|
5912
|
+
const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
|
|
5910
5913
|
const NAMES = new Set([
|
|
5911
5914
|
"react-native",
|
|
5912
5915
|
"react-native-tvos",
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5916
|
+
...new Set([
|
|
5917
|
+
"expo",
|
|
5918
|
+
"expo-router",
|
|
5919
|
+
"@expo/cli",
|
|
5920
|
+
"@expo/metro-config",
|
|
5921
|
+
"@expo/metro-runtime"
|
|
5922
|
+
]),
|
|
5918
5923
|
"react-native-windows",
|
|
5919
5924
|
"react-native-macos"
|
|
5920
5925
|
]);
|
|
@@ -5938,6 +5943,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
|
|
|
5938
5943
|
return false;
|
|
5939
5944
|
};
|
|
5940
5945
|
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
|
|
5946
|
+
const getExpoDependencySpec = (packageJson) => {
|
|
5947
|
+
const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
|
|
5948
|
+
return typeof spec === "string" ? spec : null;
|
|
5949
|
+
};
|
|
5950
|
+
const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
|
|
5941
5951
|
const getPreactVersion = (packageJson) => {
|
|
5942
5952
|
return {
|
|
5943
5953
|
...packageJson.peerDependencies,
|
|
@@ -6174,6 +6184,19 @@ const discoverProject = (directory) => {
|
|
|
6174
6184
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
6175
6185
|
const sourceFileCount = countSourceFiles(directory);
|
|
6176
6186
|
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
6187
|
+
let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
|
|
6188
|
+
if (expoVersion !== null && isCatalogReference(expoVersion)) {
|
|
6189
|
+
const catalogName = extractCatalogName(expoVersion);
|
|
6190
|
+
let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
|
|
6191
|
+
if (!resolvedExpoVersion) {
|
|
6192
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
6193
|
+
if (monorepoRoot) {
|
|
6194
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
6195
|
+
if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson$1(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
|
|
6196
|
+
}
|
|
6197
|
+
}
|
|
6198
|
+
expoVersion = resolvedExpoVersion ?? expoVersion;
|
|
6199
|
+
}
|
|
6177
6200
|
const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
|
|
6178
6201
|
const preactVersion = getPreactVersion(packageJson);
|
|
6179
6202
|
const projectInfo = {
|
|
@@ -6191,6 +6214,7 @@ const discoverProject = (directory) => {
|
|
|
6191
6214
|
preactVersion,
|
|
6192
6215
|
preactMajorVersion: parseReactMajor(preactVersion),
|
|
6193
6216
|
hasReactNativeWorkspace,
|
|
6217
|
+
expoVersion,
|
|
6194
6218
|
hasReanimated,
|
|
6195
6219
|
sourceFileCount
|
|
6196
6220
|
};
|
|
@@ -6301,6 +6325,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
|
6301
6325
|
"Accessibility",
|
|
6302
6326
|
"Maintainability"
|
|
6303
6327
|
];
|
|
6328
|
+
const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
|
|
6329
|
+
const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
|
|
6330
|
+
const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
|
|
6304
6331
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
6305
6332
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
6306
6333
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -6420,10 +6447,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
6420
6447
|
*/
|
|
6421
6448
|
const buildRuleSeverityControls = (config) => {
|
|
6422
6449
|
if (!config) return void 0;
|
|
6423
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
6450
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
6424
6451
|
return {
|
|
6425
6452
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
6426
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
6453
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
6454
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
6427
6455
|
};
|
|
6428
6456
|
};
|
|
6429
6457
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -6787,6 +6815,65 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
6787
6815
|
}
|
|
6788
6816
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
6789
6817
|
};
|
|
6818
|
+
const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
|
|
6819
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
6820
|
+
const findNearestPackageDirectory$1 = (filename) => {
|
|
6821
|
+
if (!filename) return null;
|
|
6822
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
6823
|
+
if (fromCache !== void 0) return fromCache;
|
|
6824
|
+
let currentDirectory = path.dirname(filename);
|
|
6825
|
+
while (true) {
|
|
6826
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
6827
|
+
let hasPackageJson = false;
|
|
6828
|
+
try {
|
|
6829
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
6830
|
+
} catch {
|
|
6831
|
+
hasPackageJson = false;
|
|
6832
|
+
}
|
|
6833
|
+
if (hasPackageJson) {
|
|
6834
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
6835
|
+
return currentDirectory;
|
|
6836
|
+
}
|
|
6837
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
6838
|
+
if (parentDirectory === currentDirectory) {
|
|
6839
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
6840
|
+
return null;
|
|
6841
|
+
}
|
|
6842
|
+
currentDirectory = parentDirectory;
|
|
6843
|
+
}
|
|
6844
|
+
};
|
|
6845
|
+
const readManifest = (packageJsonPath) => {
|
|
6846
|
+
try {
|
|
6847
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
6848
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
6849
|
+
return null;
|
|
6850
|
+
} catch {
|
|
6851
|
+
return null;
|
|
6852
|
+
}
|
|
6853
|
+
};
|
|
6854
|
+
const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
|
|
6855
|
+
const classifyByDirectoryCohort = (packageDirectory) => {
|
|
6856
|
+
let current = packageDirectory;
|
|
6857
|
+
while (true) {
|
|
6858
|
+
if (path.basename(current) === "apps") return "app";
|
|
6859
|
+
const parent = path.dirname(current);
|
|
6860
|
+
if (parent === current) return null;
|
|
6861
|
+
current = parent;
|
|
6862
|
+
}
|
|
6863
|
+
};
|
|
6864
|
+
const classifyPackageRole = (filename) => {
|
|
6865
|
+
if (!filename) return "unknown";
|
|
6866
|
+
const packageDirectory = findNearestPackageDirectory$1(filename);
|
|
6867
|
+
if (!packageDirectory) return "unknown";
|
|
6868
|
+
const cached = cachedRoleByPackageDirectory.get(packageDirectory);
|
|
6869
|
+
if (cached !== void 0) return cached;
|
|
6870
|
+
const manifest = readManifest(path.join(packageDirectory, "package.json"));
|
|
6871
|
+
let result;
|
|
6872
|
+
if (manifest && hasPublishContract(manifest)) result = "library";
|
|
6873
|
+
else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
|
|
6874
|
+
cachedRoleByPackageDirectory.set(packageDirectory, result);
|
|
6875
|
+
return result;
|
|
6876
|
+
};
|
|
6790
6877
|
/**
|
|
6791
6878
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
6792
6879
|
* accounting for the various shapes oxlint emits:
|
|
@@ -6949,6 +7036,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6949
7036
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
6950
7037
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
6951
7038
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
7039
|
+
const libraryFileCache = /* @__PURE__ */ new Map();
|
|
7040
|
+
const isLibraryFile = (filePath) => {
|
|
7041
|
+
let cached = libraryFileCache.get(filePath);
|
|
7042
|
+
if (cached === void 0) {
|
|
7043
|
+
cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
|
|
7044
|
+
libraryFileCache.set(filePath, cached);
|
|
7045
|
+
}
|
|
7046
|
+
return cached;
|
|
7047
|
+
};
|
|
6952
7048
|
const getFileLines = (filePath) => {
|
|
6953
7049
|
const cached = fileLinesCache.get(filePath);
|
|
6954
7050
|
if (cached !== void 0) return cached;
|
|
@@ -6975,6 +7071,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6975
7071
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
6976
7072
|
return false;
|
|
6977
7073
|
};
|
|
7074
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
7075
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
7076
|
+
return false;
|
|
7077
|
+
};
|
|
6978
7078
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
6979
7079
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
6980
7080
|
if (diagnostic.line <= 0) return false;
|
|
@@ -6989,8 +7089,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6989
7089
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
6990
7090
|
let current = diagnostic;
|
|
6991
7091
|
let explicitSeverityOverride;
|
|
7092
|
+
let explicitRuleOverride;
|
|
6992
7093
|
if (severityControls) {
|
|
6993
7094
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
7095
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
6994
7096
|
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
6995
7097
|
ruleKey,
|
|
6996
7098
|
category
|
|
@@ -6998,6 +7100,9 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
6998
7100
|
if (explicitSeverityOverride === "off") return null;
|
|
6999
7101
|
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
7000
7102
|
}
|
|
7103
|
+
if (explicitRuleOverride === void 0) {
|
|
7104
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
7105
|
+
}
|
|
7001
7106
|
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
7002
7107
|
if (userConfig) {
|
|
7003
7108
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
@@ -7183,6 +7288,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
7183
7288
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
7184
7289
|
}).pipe(Effect.orDie));
|
|
7185
7290
|
/**
|
|
7291
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
7292
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
7293
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
7294
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
7295
|
+
*/
|
|
7296
|
+
const resolveScanConcurrency = (requested) => {
|
|
7297
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
7298
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
7299
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
7300
|
+
};
|
|
7301
|
+
/**
|
|
7186
7302
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
7187
7303
|
* startup so the eval harness can raise the budget under sandbox
|
|
7188
7304
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -7202,6 +7318,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
7202
7318
|
* tests that exercise the cap behavior.
|
|
7203
7319
|
*/
|
|
7204
7320
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
7321
|
+
/**
|
|
7322
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
7323
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
7324
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
7325
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
7326
|
+
* CI callers that never touch the flag:
|
|
7327
|
+
*
|
|
7328
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
7329
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
7330
|
+
* - a positive integer → that many workers (clamped)
|
|
7331
|
+
*
|
|
7332
|
+
* The resolved value is always within
|
|
7333
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
7334
|
+
*/
|
|
7335
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
7336
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
7337
|
+
if (raw === void 0) return 1;
|
|
7338
|
+
const normalized = raw.trim().toLowerCase();
|
|
7339
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
7340
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
7341
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
7342
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
7343
|
+
return resolveScanConcurrency(parsed);
|
|
7344
|
+
} }) {};
|
|
7205
7345
|
const DIAGNOSTIC_SURFACES = [
|
|
7206
7346
|
"cli",
|
|
7207
7347
|
"prComment",
|
|
@@ -7362,16 +7502,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
|
|
|
7362
7502
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
7363
7503
|
const loadConfigFromDirectory = (directory) => {
|
|
7364
7504
|
const configFilePath = path.join(directory, CONFIG_FILENAME);
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7505
|
+
let sawBrokenConfigFile = false;
|
|
7506
|
+
if (isFile(configFilePath)) {
|
|
7507
|
+
try {
|
|
7508
|
+
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
7509
|
+
const parsed = JSON.parse(fileContent);
|
|
7510
|
+
if (isPlainObject(parsed)) return {
|
|
7511
|
+
status: "found",
|
|
7512
|
+
loaded: {
|
|
7513
|
+
config: validateConfigTypes(parsed),
|
|
7514
|
+
sourceDirectory: directory
|
|
7515
|
+
}
|
|
7516
|
+
};
|
|
7517
|
+
warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
7518
|
+
} catch (error) {
|
|
7519
|
+
warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7520
|
+
}
|
|
7521
|
+
sawBrokenConfigFile = true;
|
|
7375
7522
|
}
|
|
7376
7523
|
const packageJsonPath = path.join(directory, "package.json");
|
|
7377
7524
|
if (isFile(packageJsonPath)) try {
|
|
@@ -7380,34 +7527,38 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
7380
7527
|
if (isPlainObject(packageJson)) {
|
|
7381
7528
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
7382
7529
|
if (isPlainObject(embeddedConfig)) return {
|
|
7383
|
-
|
|
7384
|
-
|
|
7530
|
+
status: "found",
|
|
7531
|
+
loaded: {
|
|
7532
|
+
config: validateConfigTypes(embeddedConfig),
|
|
7533
|
+
sourceDirectory: directory
|
|
7534
|
+
}
|
|
7385
7535
|
};
|
|
7386
7536
|
}
|
|
7387
|
-
} catch {
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7537
|
+
} catch {}
|
|
7538
|
+
return {
|
|
7539
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
7540
|
+
loaded: null
|
|
7541
|
+
};
|
|
7391
7542
|
};
|
|
7392
7543
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
7393
7544
|
const loadConfigWithSource = (rootDirectory) => {
|
|
7394
7545
|
const cached = cachedConfigs.get(rootDirectory);
|
|
7395
7546
|
if (cached !== void 0) return cached;
|
|
7396
|
-
const
|
|
7397
|
-
if (
|
|
7398
|
-
cachedConfigs.set(rootDirectory,
|
|
7399
|
-
return
|
|
7547
|
+
const localResult = loadConfigFromDirectory(rootDirectory);
|
|
7548
|
+
if (localResult.status === "found") {
|
|
7549
|
+
cachedConfigs.set(rootDirectory, localResult.loaded);
|
|
7550
|
+
return localResult.loaded;
|
|
7400
7551
|
}
|
|
7401
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
7552
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
|
|
7402
7553
|
cachedConfigs.set(rootDirectory, null);
|
|
7403
7554
|
return null;
|
|
7404
7555
|
}
|
|
7405
7556
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
7406
7557
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
7407
|
-
const
|
|
7408
|
-
if (
|
|
7409
|
-
cachedConfigs.set(rootDirectory,
|
|
7410
|
-
return
|
|
7558
|
+
const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
|
|
7559
|
+
if (ancestorResult.status === "found") {
|
|
7560
|
+
cachedConfigs.set(rootDirectory, ancestorResult.loaded);
|
|
7561
|
+
return ancestorResult.loaded;
|
|
7411
7562
|
}
|
|
7412
7563
|
if (isProjectBoundary(ancestorDirectory)) {
|
|
7413
7564
|
cachedConfigs.set(rootDirectory, null);
|
|
@@ -7432,11 +7583,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
|
7432
7583
|
}
|
|
7433
7584
|
return resolvedRootDir;
|
|
7434
7585
|
};
|
|
7435
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
7586
|
+
const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
7436
7587
|
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
7437
7588
|
const reactSubprojects = discoverReactSubprojects(directory);
|
|
7438
7589
|
if (reactSubprojects.length === 0) return null;
|
|
7439
7590
|
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
7591
|
+
if (options.allowAmbiguous === true) return null;
|
|
7440
7592
|
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
7441
7593
|
};
|
|
7442
7594
|
/**
|
|
@@ -7450,7 +7602,8 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
7450
7602
|
* project root, if configured.
|
|
7451
7603
|
* 4. Walk into a nested React subproject when the requested
|
|
7452
7604
|
* directory has no `package.json` of its own (raises
|
|
7453
|
-
* `AmbiguousProjectError` when multiple candidates exist
|
|
7605
|
+
* `AmbiguousProjectError` when multiple candidates exist unless
|
|
7606
|
+
* the caller opts into keeping the wrapper directory).
|
|
7454
7607
|
*
|
|
7455
7608
|
* Throws `ProjectNotFoundError` when neither the requested directory
|
|
7456
7609
|
* nor any discoverable nested project has a `package.json`.
|
|
@@ -7462,14 +7615,14 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
7462
7615
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
7463
7616
|
* shell in agreement on what "the scan directory" means.
|
|
7464
7617
|
*/
|
|
7465
|
-
const resolveScanTarget = (requestedDirectory) => {
|
|
7618
|
+
const resolveScanTarget = (requestedDirectory, options = {}) => {
|
|
7466
7619
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
7467
7620
|
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
7468
7621
|
const userConfig = loadedConfig?.config ?? null;
|
|
7469
7622
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
7470
7623
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
7471
7624
|
const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
|
|
7472
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
|
|
7625
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
|
|
7473
7626
|
if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
|
|
7474
7627
|
return {
|
|
7475
7628
|
resolvedDirectory,
|
|
@@ -7479,6 +7632,359 @@ const resolveScanTarget = (requestedDirectory) => {
|
|
|
7479
7632
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
7480
7633
|
};
|
|
7481
7634
|
};
|
|
7635
|
+
const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
|
|
7636
|
+
const buildExpoCheckContext = (rootDirectory, expoVersion) => {
|
|
7637
|
+
const packageJson = readPackageJson$1(path.join(rootDirectory, "package.json"));
|
|
7638
|
+
return {
|
|
7639
|
+
rootDirectory,
|
|
7640
|
+
packageJson,
|
|
7641
|
+
directDependencyNames: getDirectDependencyNames(packageJson),
|
|
7642
|
+
expoSdkMajor: getLowestDependencyMajor(expoVersion)
|
|
7643
|
+
};
|
|
7644
|
+
};
|
|
7645
|
+
const buildExpoDiagnostic = (input) => ({
|
|
7646
|
+
filePath: input.filePath ?? "package.json",
|
|
7647
|
+
plugin: "react-doctor",
|
|
7648
|
+
rule: input.rule,
|
|
7649
|
+
severity: input.severity ?? "warning",
|
|
7650
|
+
message: input.message,
|
|
7651
|
+
help: input.help,
|
|
7652
|
+
line: input.line ?? 0,
|
|
7653
|
+
column: input.column ?? 0,
|
|
7654
|
+
category: input.category ?? "Correctness"
|
|
7655
|
+
});
|
|
7656
|
+
const CRITICAL_OVERRIDE_NAMES = new Set([
|
|
7657
|
+
"@expo/cli",
|
|
7658
|
+
"@expo/config",
|
|
7659
|
+
"@expo/metro-config",
|
|
7660
|
+
"@expo/metro-runtime",
|
|
7661
|
+
"@expo/metro",
|
|
7662
|
+
"metro"
|
|
7663
|
+
]);
|
|
7664
|
+
const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
|
|
7665
|
+
const collectOverrideNames = (packageJson) => new Set([
|
|
7666
|
+
...Object.keys(packageJson.overrides ?? {}),
|
|
7667
|
+
...Object.keys(packageJson.resolutions ?? {}),
|
|
7668
|
+
...Object.keys(packageJson.pnpm?.overrides ?? {})
|
|
7669
|
+
]);
|
|
7670
|
+
const checkExpoDependencyOverrides = (context) => {
|
|
7671
|
+
const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
|
|
7672
|
+
if (overriddenCriticalNames.length === 0) return [];
|
|
7673
|
+
const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
|
|
7674
|
+
return [buildExpoDiagnostic({
|
|
7675
|
+
rule: "expo-no-conflicting-dependency-override",
|
|
7676
|
+
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`,
|
|
7677
|
+
help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
|
|
7678
|
+
})];
|
|
7679
|
+
};
|
|
7680
|
+
const isPathGitIgnored = (rootDirectory, absolutePath) => {
|
|
7681
|
+
const result = spawnSync("git", [
|
|
7682
|
+
"check-ignore",
|
|
7683
|
+
"-q",
|
|
7684
|
+
absolutePath
|
|
7685
|
+
], {
|
|
7686
|
+
cwd: rootDirectory,
|
|
7687
|
+
stdio: [
|
|
7688
|
+
"ignore",
|
|
7689
|
+
"ignore",
|
|
7690
|
+
"ignore"
|
|
7691
|
+
]
|
|
7692
|
+
});
|
|
7693
|
+
if (result.error) return null;
|
|
7694
|
+
if (result.status === 0) return true;
|
|
7695
|
+
if (result.status === 1) return false;
|
|
7696
|
+
return null;
|
|
7697
|
+
};
|
|
7698
|
+
const LOCAL_ENV_FILE_NAMES = [
|
|
7699
|
+
".env.local",
|
|
7700
|
+
".env.development.local",
|
|
7701
|
+
".env.production.local",
|
|
7702
|
+
".env.test.local"
|
|
7703
|
+
];
|
|
7704
|
+
const checkExpoEnvLocalFiles = (context) => {
|
|
7705
|
+
const { rootDirectory } = context;
|
|
7706
|
+
const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
|
|
7707
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
7708
|
+
if (!isFile(filePath)) return false;
|
|
7709
|
+
return isPathGitIgnored(rootDirectory, filePath) === false;
|
|
7710
|
+
});
|
|
7711
|
+
if (committedEnvFiles.length === 0) return [];
|
|
7712
|
+
return [buildExpoDiagnostic({
|
|
7713
|
+
rule: "expo-env-local-not-gitignored",
|
|
7714
|
+
category: "Security",
|
|
7715
|
+
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`,
|
|
7716
|
+
help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
|
|
7717
|
+
})];
|
|
7718
|
+
};
|
|
7719
|
+
const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
|
|
7720
|
+
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";
|
|
7721
|
+
const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
|
|
7722
|
+
const unimodulesEntry = (packageName) => ({
|
|
7723
|
+
packageName,
|
|
7724
|
+
rule: "expo-no-unimodules-packages",
|
|
7725
|
+
message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
|
|
7726
|
+
help: UNIMODULES_HELP
|
|
7727
|
+
});
|
|
7728
|
+
const FLAGGED_DEPENDENCIES = [
|
|
7729
|
+
unimodulesEntry("@unimodules/core"),
|
|
7730
|
+
unimodulesEntry("@unimodules/react-native-adapter"),
|
|
7731
|
+
unimodulesEntry("react-native-unimodules"),
|
|
7732
|
+
{
|
|
7733
|
+
packageName: "expo-cli",
|
|
7734
|
+
rule: "expo-no-cli-dependencies",
|
|
7735
|
+
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`",
|
|
7736
|
+
help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
|
|
7737
|
+
},
|
|
7738
|
+
{
|
|
7739
|
+
packageName: "eas-cli",
|
|
7740
|
+
rule: "expo-no-cli-dependencies",
|
|
7741
|
+
message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
|
|
7742
|
+
help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
|
|
7743
|
+
},
|
|
7744
|
+
{
|
|
7745
|
+
packageName: "expo-modules-autolinking",
|
|
7746
|
+
rule: "expo-no-redundant-dependency",
|
|
7747
|
+
message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
|
|
7748
|
+
help: "Remove `expo-modules-autolinking` from your package.json"
|
|
7749
|
+
},
|
|
7750
|
+
{
|
|
7751
|
+
packageName: "expo-dev-launcher",
|
|
7752
|
+
rule: "expo-no-redundant-dependency",
|
|
7753
|
+
message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7754
|
+
help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
|
|
7755
|
+
},
|
|
7756
|
+
{
|
|
7757
|
+
packageName: "expo-dev-menu",
|
|
7758
|
+
rule: "expo-no-redundant-dependency",
|
|
7759
|
+
message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
7760
|
+
help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
|
|
7761
|
+
},
|
|
7762
|
+
{
|
|
7763
|
+
packageName: "expo-modules-core",
|
|
7764
|
+
rule: "expo-no-redundant-dependency",
|
|
7765
|
+
message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
|
|
7766
|
+
help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
|
|
7767
|
+
},
|
|
7768
|
+
{
|
|
7769
|
+
packageName: "@expo/metro-config",
|
|
7770
|
+
rule: "expo-no-redundant-dependency",
|
|
7771
|
+
message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
|
|
7772
|
+
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
|
|
7773
|
+
},
|
|
7774
|
+
{
|
|
7775
|
+
packageName: "@types/react-native",
|
|
7776
|
+
rule: "expo-no-redundant-dependency",
|
|
7777
|
+
message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
|
|
7778
|
+
help: "Remove `@types/react-native` from your package.json",
|
|
7779
|
+
minSdkMajor: 48
|
|
7780
|
+
},
|
|
7781
|
+
{
|
|
7782
|
+
packageName: "@expo/config-plugins",
|
|
7783
|
+
rule: "expo-no-redundant-dependency",
|
|
7784
|
+
message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
|
|
7785
|
+
help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
|
|
7786
|
+
minSdkMajor: 48
|
|
7787
|
+
},
|
|
7788
|
+
{
|
|
7789
|
+
packageName: "@expo/prebuild-config",
|
|
7790
|
+
rule: "expo-no-redundant-dependency",
|
|
7791
|
+
message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
|
|
7792
|
+
help: "Remove `@expo/prebuild-config` from your package.json",
|
|
7793
|
+
minSdkMajor: 53
|
|
7794
|
+
},
|
|
7795
|
+
{
|
|
7796
|
+
packageName: "expo-permissions",
|
|
7797
|
+
rule: "expo-no-redundant-dependency",
|
|
7798
|
+
message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
|
|
7799
|
+
help: "Remove `expo-permissions` and request permissions from the relevant module instead",
|
|
7800
|
+
minSdkMajor: 50
|
|
7801
|
+
},
|
|
7802
|
+
{
|
|
7803
|
+
packageName: "expo-app-loading",
|
|
7804
|
+
rule: "expo-no-redundant-dependency",
|
|
7805
|
+
message: "\"expo-app-loading\" was removed in SDK 49",
|
|
7806
|
+
help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
|
|
7807
|
+
minSdkMajor: 49
|
|
7808
|
+
},
|
|
7809
|
+
{
|
|
7810
|
+
packageName: "expo-firebase-analytics",
|
|
7811
|
+
rule: "expo-no-redundant-dependency",
|
|
7812
|
+
message: "\"expo-firebase-analytics\" was removed in SDK 48",
|
|
7813
|
+
help: FIREBASE_HELP,
|
|
7814
|
+
minSdkMajor: 48
|
|
7815
|
+
},
|
|
7816
|
+
{
|
|
7817
|
+
packageName: "expo-firebase-recaptcha",
|
|
7818
|
+
rule: "expo-no-redundant-dependency",
|
|
7819
|
+
message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
|
|
7820
|
+
help: FIREBASE_HELP,
|
|
7821
|
+
minSdkMajor: 48
|
|
7822
|
+
},
|
|
7823
|
+
{
|
|
7824
|
+
packageName: "expo-firebase-core",
|
|
7825
|
+
rule: "expo-no-redundant-dependency",
|
|
7826
|
+
message: "\"expo-firebase-core\" was removed in SDK 48",
|
|
7827
|
+
help: FIREBASE_HELP,
|
|
7828
|
+
minSdkMajor: 48
|
|
7829
|
+
}
|
|
7830
|
+
];
|
|
7831
|
+
const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
|
|
7832
|
+
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
|
|
7833
|
+
if (flaggedDependency.minSdkMajor === void 0) return true;
|
|
7834
|
+
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
|
|
7835
|
+
}).map((flaggedDependency) => buildExpoDiagnostic({
|
|
7836
|
+
rule: flaggedDependency.rule,
|
|
7837
|
+
message: flaggedDependency.message,
|
|
7838
|
+
help: flaggedDependency.help
|
|
7839
|
+
}));
|
|
7840
|
+
const findLocalModuleNativeFiles = (rootDirectory) => {
|
|
7841
|
+
const modulesDirectory = path.join(rootDirectory, "modules");
|
|
7842
|
+
if (!isDirectory(modulesDirectory)) return [];
|
|
7843
|
+
const nativeFilePaths = [];
|
|
7844
|
+
for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
|
|
7845
|
+
if (!moduleEntry.isDirectory()) continue;
|
|
7846
|
+
const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
|
|
7847
|
+
const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
|
|
7848
|
+
if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
|
|
7849
|
+
const iosDirectory = path.join(moduleDirectory, "ios");
|
|
7850
|
+
if (isDirectory(iosDirectory)) {
|
|
7851
|
+
for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
|
|
7852
|
+
}
|
|
7853
|
+
}
|
|
7854
|
+
return nativeFilePaths;
|
|
7855
|
+
};
|
|
7856
|
+
const checkExpoGitignore = (context) => {
|
|
7857
|
+
const { rootDirectory } = context;
|
|
7858
|
+
const diagnostics = [];
|
|
7859
|
+
const expoStateDirectory = path.join(rootDirectory, ".expo");
|
|
7860
|
+
if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
|
|
7861
|
+
rule: "expo-gitignore",
|
|
7862
|
+
message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
|
|
7863
|
+
help: "Add `.expo/` to your .gitignore"
|
|
7864
|
+
}));
|
|
7865
|
+
if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
|
|
7866
|
+
rule: "expo-gitignore",
|
|
7867
|
+
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",
|
|
7868
|
+
help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
|
|
7869
|
+
}));
|
|
7870
|
+
return diagnostics;
|
|
7871
|
+
};
|
|
7872
|
+
const LOCKFILE_NAMES = [
|
|
7873
|
+
"pnpm-lock.yaml",
|
|
7874
|
+
"yarn.lock",
|
|
7875
|
+
"package-lock.json",
|
|
7876
|
+
"bun.lockb",
|
|
7877
|
+
"bun.lock"
|
|
7878
|
+
];
|
|
7879
|
+
const checkExpoLockfile = (context) => {
|
|
7880
|
+
const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
|
|
7881
|
+
const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
|
|
7882
|
+
if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
|
|
7883
|
+
rule: "expo-lockfile",
|
|
7884
|
+
message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
|
|
7885
|
+
help: "Install dependencies with your package manager to generate a lock file, then commit it"
|
|
7886
|
+
})];
|
|
7887
|
+
if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
|
|
7888
|
+
rule: "expo-lockfile",
|
|
7889
|
+
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`,
|
|
7890
|
+
help: "Delete the lock files for the package managers you are not using and keep only one"
|
|
7891
|
+
})];
|
|
7892
|
+
return [];
|
|
7893
|
+
};
|
|
7894
|
+
const METRO_CONFIG_FILE_NAMES = [
|
|
7895
|
+
"metro.config.js",
|
|
7896
|
+
"metro.config.cjs",
|
|
7897
|
+
"metro.config.mjs",
|
|
7898
|
+
"metro.config.ts"
|
|
7899
|
+
];
|
|
7900
|
+
const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
|
|
7901
|
+
"expo/metro-config",
|
|
7902
|
+
"@sentry/react-native/metro",
|
|
7903
|
+
"getSentryExpoConfig"
|
|
7904
|
+
];
|
|
7905
|
+
const checkExpoMetroConfig = (context) => {
|
|
7906
|
+
const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
|
|
7907
|
+
if (metroConfigPath === void 0) return [];
|
|
7908
|
+
let contents;
|
|
7909
|
+
try {
|
|
7910
|
+
contents = fs.readFileSync(metroConfigPath, "utf-8");
|
|
7911
|
+
} catch {
|
|
7912
|
+
return [];
|
|
7913
|
+
}
|
|
7914
|
+
if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
|
|
7915
|
+
return [buildExpoDiagnostic({
|
|
7916
|
+
rule: "expo-metro-config",
|
|
7917
|
+
filePath: path.basename(metroConfigPath),
|
|
7918
|
+
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",
|
|
7919
|
+
help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
|
|
7920
|
+
})];
|
|
7921
|
+
};
|
|
7922
|
+
const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
|
|
7923
|
+
const checkExpoPackageJsonConflicts = (context) => {
|
|
7924
|
+
const { packageJson } = context;
|
|
7925
|
+
const diagnostics = [];
|
|
7926
|
+
const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
|
|
7927
|
+
if (conflictingScriptNames.length > 0) {
|
|
7928
|
+
const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
|
|
7929
|
+
const shadowsExpoCli = conflictingScriptNames.includes("expo");
|
|
7930
|
+
diagnostics.push(buildExpoDiagnostic({
|
|
7931
|
+
rule: "expo-package-json-conflict",
|
|
7932
|
+
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" : ""}`,
|
|
7933
|
+
help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
|
|
7934
|
+
}));
|
|
7935
|
+
}
|
|
7936
|
+
const packageName = packageJson.name;
|
|
7937
|
+
if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
|
|
7938
|
+
rule: "expo-package-json-conflict",
|
|
7939
|
+
message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
|
|
7940
|
+
help: "Rename your package so it no longer matches one of its dependencies"
|
|
7941
|
+
}));
|
|
7942
|
+
return diagnostics;
|
|
7943
|
+
};
|
|
7944
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
7945
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
7946
|
+
const checkExpoRouterReactNavigation = (context) => {
|
|
7947
|
+
const { expoSdkMajor } = context;
|
|
7948
|
+
if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
|
|
7949
|
+
if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
|
|
7950
|
+
if (!context.directDependencyNames.has("expo-router")) return [];
|
|
7951
|
+
const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
|
|
7952
|
+
if (reactNavigationNames.length === 0) return [];
|
|
7953
|
+
return [buildExpoDiagnostic({
|
|
7954
|
+
rule: "expo-router-no-react-navigation",
|
|
7955
|
+
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"}`,
|
|
7956
|
+
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/"
|
|
7957
|
+
})];
|
|
7958
|
+
};
|
|
7959
|
+
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
7960
|
+
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
7961
|
+
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
7962
|
+
const checkExpoVectorIcons = (context) => {
|
|
7963
|
+
if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
|
|
7964
|
+
const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
|
|
7965
|
+
const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
|
|
7966
|
+
if (!hasScopedPackage || !hasConflictingPackage) return [];
|
|
7967
|
+
return [buildExpoDiagnostic({
|
|
7968
|
+
rule: "expo-vector-icons-conflict",
|
|
7969
|
+
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",
|
|
7970
|
+
help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
|
|
7971
|
+
})];
|
|
7972
|
+
};
|
|
7973
|
+
const checkExpoProject = (rootDirectory, project) => {
|
|
7974
|
+
if (project.expoVersion === null) return [];
|
|
7975
|
+
const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
|
|
7976
|
+
return [
|
|
7977
|
+
...checkExpoFlaggedDependencies(context),
|
|
7978
|
+
...checkExpoDependencyOverrides(context),
|
|
7979
|
+
...checkExpoRouterReactNavigation(context),
|
|
7980
|
+
...checkExpoVectorIcons(context),
|
|
7981
|
+
...checkExpoPackageJsonConflicts(context),
|
|
7982
|
+
...checkExpoLockfile(context),
|
|
7983
|
+
...checkExpoGitignore(context),
|
|
7984
|
+
...checkExpoEnvLocalFiles(context),
|
|
7985
|
+
...checkExpoMetroConfig(context)
|
|
7986
|
+
];
|
|
7987
|
+
};
|
|
7482
7988
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
7483
7989
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
7484
7990
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -7726,6 +8232,28 @@ const collectIgnorePatterns = (rootDirectory) => {
|
|
|
7726
8232
|
cachedPatternsByRoot.set(rootDirectory, patterns);
|
|
7727
8233
|
return patterns;
|
|
7728
8234
|
};
|
|
8235
|
+
/**
|
|
8236
|
+
* Resolves a path to its canonical, symlink-free form, falling back to
|
|
8237
|
+
* the input when it cannot be realpath'd (broken symlink, permission
|
|
8238
|
+
* error) so a best-effort normalization never throws.
|
|
8239
|
+
*
|
|
8240
|
+
* deslop's dead-code module graph is collected with `fast-glob` (which
|
|
8241
|
+
* keeps the scan root's symlinks intact) while imports are resolved
|
|
8242
|
+
* through `oxc-resolver` (which returns realpath'd targets). When the
|
|
8243
|
+
* project root sits behind a symlink — e.g. macOS iCloud-synced
|
|
8244
|
+
* `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
|
|
8245
|
+
* spaces diverge: every resolved import misses the graph and the files
|
|
8246
|
+
* they point at (commonly every `@/…` alias target) are mis-reported as
|
|
8247
|
+
* unreachable. Canonicalizing the root before the scan keeps both path
|
|
8248
|
+
* spaces in agreement.
|
|
8249
|
+
*/
|
|
8250
|
+
const toCanonicalPath = (filePath) => {
|
|
8251
|
+
try {
|
|
8252
|
+
return fs.realpathSync(filePath);
|
|
8253
|
+
} catch {
|
|
8254
|
+
return filePath;
|
|
8255
|
+
}
|
|
8256
|
+
};
|
|
7729
8257
|
const DEAD_CODE_PLUGIN = "deslop";
|
|
7730
8258
|
const DEAD_CODE_CATEGORY = "Maintainability";
|
|
7731
8259
|
const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
|
|
@@ -7982,7 +8510,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
|
|
|
7982
8510
|
});
|
|
7983
8511
|
});
|
|
7984
8512
|
const checkDeadCode = async (options) => {
|
|
7985
|
-
const {
|
|
8513
|
+
const { userConfig } = options;
|
|
8514
|
+
const rootDirectory = toCanonicalPath(options.rootDirectory);
|
|
7986
8515
|
if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
|
|
7987
8516
|
const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
|
|
7988
8517
|
const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
|
|
@@ -8733,6 +9262,7 @@ const buildCapabilities = (project) => {
|
|
|
8733
9262
|
const capabilities = /* @__PURE__ */ new Set();
|
|
8734
9263
|
capabilities.add(project.framework);
|
|
8735
9264
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
9265
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
8736
9266
|
const reactMajor = project.reactMajorVersion;
|
|
8737
9267
|
if (reactMajor !== null) {
|
|
8738
9268
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -8904,10 +9434,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
8904
9434
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
8905
9435
|
return fs.realpathSync(rootDirectory);
|
|
8906
9436
|
};
|
|
9437
|
+
const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
|
|
9438
|
+
if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
|
|
9439
|
+
return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
9440
|
+
};
|
|
8907
9441
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
8908
9442
|
const enabledRules = {};
|
|
8909
9443
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
8910
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
9444
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
8911
9445
|
if (severity === "off") continue;
|
|
8912
9446
|
enabledRules[ruleKey] = severity;
|
|
8913
9447
|
}
|
|
@@ -8949,7 +9483,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
8949
9483
|
category: rule.category
|
|
8950
9484
|
}, severityControls);
|
|
8951
9485
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
8952
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
9486
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
8953
9487
|
if (severity === "off") continue;
|
|
8954
9488
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
8955
9489
|
}
|
|
@@ -9006,6 +9540,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
9006
9540
|
}
|
|
9007
9541
|
return uniqueDiagnostics;
|
|
9008
9542
|
};
|
|
9543
|
+
/**
|
|
9544
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
9545
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
9546
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
9547
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
9548
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
9549
|
+
* batches do).
|
|
9550
|
+
*
|
|
9551
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
9552
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
9553
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
9554
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
9555
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
9556
|
+
* top of a still-running first one.
|
|
9557
|
+
*/
|
|
9558
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
9559
|
+
const results = new Array(items.length);
|
|
9560
|
+
if (items.length === 0) return results;
|
|
9561
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
9562
|
+
let nextIndex = 0;
|
|
9563
|
+
const errors = [];
|
|
9564
|
+
const runWorker = async () => {
|
|
9565
|
+
while (errors.length === 0) {
|
|
9566
|
+
const index = nextIndex;
|
|
9567
|
+
nextIndex += 1;
|
|
9568
|
+
if (index >= items.length) return;
|
|
9569
|
+
try {
|
|
9570
|
+
results[index] = await task(items[index], index);
|
|
9571
|
+
} catch (error) {
|
|
9572
|
+
errors.push(error);
|
|
9573
|
+
return;
|
|
9574
|
+
}
|
|
9575
|
+
}
|
|
9576
|
+
};
|
|
9577
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
9578
|
+
if (errors.length > 0) throw errors[0];
|
|
9579
|
+
return results;
|
|
9580
|
+
};
|
|
9009
9581
|
const getPublicEnvPrefix = (framework) => {
|
|
9010
9582
|
switch (framework) {
|
|
9011
9583
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -9688,6 +10260,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
9688
10260
|
*/
|
|
9689
10261
|
const spawnLintBatches = async (input) => {
|
|
9690
10262
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
10263
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
9691
10264
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
9692
10265
|
const allDiagnostics = [];
|
|
9693
10266
|
const droppedFiles = [];
|
|
@@ -9707,23 +10280,31 @@ const spawnLintBatches = async (input) => {
|
|
|
9707
10280
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
9708
10281
|
}
|
|
9709
10282
|
};
|
|
10283
|
+
let startedFileCount = 0;
|
|
9710
10284
|
let scannedFileCount = 0;
|
|
9711
|
-
|
|
9712
|
-
|
|
9713
|
-
const
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
|
|
9718
|
-
|
|
9719
|
-
|
|
10285
|
+
let displayedFileCount = 0;
|
|
10286
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
10287
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
10288
|
+
if (displayedFileCount < ceiling) {
|
|
10289
|
+
displayedFileCount += 1;
|
|
10290
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10291
|
+
}
|
|
10292
|
+
}, 50) : null;
|
|
10293
|
+
progressTimer?.unref?.();
|
|
10294
|
+
try {
|
|
10295
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
10296
|
+
startedFileCount += batch.length;
|
|
9720
10297
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
9721
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
9722
10298
|
scannedFileCount += batch.length;
|
|
9723
|
-
onFileProgress
|
|
9724
|
-
|
|
9725
|
-
|
|
9726
|
-
|
|
10299
|
+
if (onFileProgress) {
|
|
10300
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
10301
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
10302
|
+
}
|
|
10303
|
+
return batchDiagnostics;
|
|
10304
|
+
});
|
|
10305
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
10306
|
+
} finally {
|
|
10307
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
9727
10308
|
}
|
|
9728
10309
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
9729
10310
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -9850,7 +10431,8 @@ const runOxlint = async (options) => {
|
|
|
9850
10431
|
onPartialFailure,
|
|
9851
10432
|
onFileProgress: options.onFileProgress,
|
|
9852
10433
|
spawnTimeoutMs,
|
|
9853
|
-
outputMaxBytes
|
|
10434
|
+
outputMaxBytes,
|
|
10435
|
+
concurrency: options.concurrency
|
|
9854
10436
|
});
|
|
9855
10437
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
9856
10438
|
try {
|
|
@@ -9918,6 +10500,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9918
10500
|
const partialFailures = yield* LintPartialFailures;
|
|
9919
10501
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
9920
10502
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
10503
|
+
const concurrency = yield* OxlintConcurrency;
|
|
9921
10504
|
const collectedFailures = [];
|
|
9922
10505
|
const diagnostics = yield* Effect.tryPromise({
|
|
9923
10506
|
try: () => runOxlint({
|
|
@@ -9936,7 +10519,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
9936
10519
|
},
|
|
9937
10520
|
onFileProgress: input.onFileProgress,
|
|
9938
10521
|
spawnTimeoutMs,
|
|
9939
|
-
outputMaxBytes
|
|
10522
|
+
outputMaxBytes,
|
|
10523
|
+
concurrency
|
|
9940
10524
|
}),
|
|
9941
10525
|
catch: ensureReactDoctorError
|
|
9942
10526
|
});
|
|
@@ -10269,7 +10853,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10269
10853
|
showWarnings
|
|
10270
10854
|
});
|
|
10271
10855
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
10272
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10856
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
10857
|
+
...checkReducedMotion(scanDirectory),
|
|
10858
|
+
...checkPnpmHardening(scanDirectory),
|
|
10859
|
+
...checkExpoProject(scanDirectory, project)
|
|
10860
|
+
];
|
|
10273
10861
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
10274
10862
|
const lintFailure = yield* Ref.make({
|
|
10275
10863
|
didFail: false,
|
|
@@ -10281,6 +10869,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10281
10869
|
didFail: false,
|
|
10282
10870
|
reason: null
|
|
10283
10871
|
});
|
|
10872
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
10873
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
10284
10874
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
10285
10875
|
const scanStartTime = Date.now();
|
|
10286
10876
|
let lastReportedTotalFileCount = 0;
|
|
@@ -10297,7 +10887,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10297
10887
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
10298
10888
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
10299
10889
|
lastReportedTotalFileCount = totalFileCount;
|
|
10300
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
10890
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
10301
10891
|
}
|
|
10302
10892
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
10303
10893
|
yield* Ref.set(lintFailure, {
|
|
@@ -10329,7 +10919,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10329
10919
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
10330
10920
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
10331
10921
|
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
10332
|
-
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
|
|
10922
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
10333
10923
|
yield* reporterService.finalize;
|
|
10334
10924
|
const finalDiagnostics = [
|
|
10335
10925
|
...envCollected,
|
|
@@ -10381,7 +10971,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
10381
10971
|
"inspect.isCi": input.isCi,
|
|
10382
10972
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
10383
10973
|
} }));
|
|
10384
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
10385
10974
|
const parseNodeVersion = (versionString) => {
|
|
10386
10975
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
10387
10976
|
return {
|
|
@@ -10704,6 +11293,26 @@ const buildJsonReport = (input) => {
|
|
|
10704
11293
|
};
|
|
10705
11294
|
};
|
|
10706
11295
|
/**
|
|
11296
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
11297
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
11298
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
11299
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
11300
|
+
* branch logic lives here once.
|
|
11301
|
+
*/
|
|
11302
|
+
const buildSkippedChecks = (input) => {
|
|
11303
|
+
const skippedChecks = [];
|
|
11304
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
11305
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
11306
|
+
const skippedCheckReasons = {};
|
|
11307
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
11308
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
11309
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
11310
|
+
return {
|
|
11311
|
+
skippedChecks,
|
|
11312
|
+
skippedCheckReasons
|
|
11313
|
+
};
|
|
11314
|
+
};
|
|
11315
|
+
/**
|
|
10707
11316
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
10708
11317
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
10709
11318
|
* spawn, not `spawnSync`).
|
|
@@ -10824,6 +11433,20 @@ const groupBy = (items, keyFn) => {
|
|
|
10824
11433
|
}
|
|
10825
11434
|
return groups;
|
|
10826
11435
|
};
|
|
11436
|
+
/**
|
|
11437
|
+
* Whether a diagnostic's rule has a published per-rule fix recipe at
|
|
11438
|
+
* `${PROMPTS_RULES_BASE_URL}/react-doctor/<rule>.md`
|
|
11439
|
+
* (see `buildRulePromptUrl`).
|
|
11440
|
+
*
|
|
11441
|
+
* Recipes are generated from react-doctor's own engine rules, so only
|
|
11442
|
+
* those resolve. Dead-code (`deslop`), the synthetic environment and
|
|
11443
|
+
* supply-chain checks (`require-reduced-motion`, `require-pnpm-hardening`
|
|
11444
|
+
* — `react-doctor`-namespaced but not engine rules), and adopted
|
|
11445
|
+
* third-party plugins (`eslint`, `unicorn`, `react-hooks-js`, …) have no
|
|
11446
|
+
* recipe, so advertising "fetch the fix recipe" for them sends agents to
|
|
11447
|
+
* a 404. Gate the directive on this predicate.
|
|
11448
|
+
*/
|
|
11449
|
+
const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
|
|
10827
11450
|
//#endregion
|
|
10828
11451
|
//#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
10829
11452
|
const ANSI_BACKGROUND_OFFSET = 10;
|
|
@@ -13793,7 +14416,13 @@ const CODING_AGENT_ENVIRONMENT_VARIABLES = [
|
|
|
13793
14416
|
];
|
|
13794
14417
|
const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
|
|
13795
14418
|
const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
|
|
13796
|
-
const
|
|
14419
|
+
const FALSY_CI_FLAG_VALUES = new Set([
|
|
14420
|
+
"",
|
|
14421
|
+
"0",
|
|
14422
|
+
"false"
|
|
14423
|
+
]);
|
|
14424
|
+
const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
|
|
14425
|
+
const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCiFlagSet(process.env.CI);
|
|
13797
14426
|
const isCodingAgentEnvironment = () => CODING_AGENT_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES.some((envVariable) => CODING_AGENT_ENVIRONMENT_VALUES[envVariable].some((value) => process.env[envVariable]?.toLowerCase() === value));
|
|
13798
14427
|
const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
|
|
13799
14428
|
//#endregion
|
|
@@ -13896,9 +14525,8 @@ const buildSpinnerProgressHandle = (text) => {
|
|
|
13896
14525
|
* construction and post-scan rendering — layer wiring is its own
|
|
13897
14526
|
* concern with its own contract.
|
|
13898
14527
|
*
|
|
13899
|
-
* Same shape as
|
|
13900
|
-
*
|
|
13901
|
-
* differences specific to the CLI path:
|
|
14528
|
+
* Same service shape as `@react-doctor/api → diagnose()`'s
|
|
14529
|
+
* `buildDiagnoseLayer`, with the differences specific to the CLI path:
|
|
13902
14530
|
*
|
|
13903
14531
|
* - **Config**: when the caller passes `configOverride`, the
|
|
13904
14532
|
* already-loaded config is provided via `Config.layerOf` instead
|
|
@@ -13924,7 +14552,8 @@ const buildRuntimeLayers = (input) => {
|
|
|
13924
14552
|
resolvedDirectory: input.directory,
|
|
13925
14553
|
configSourceDirectory: input.configSourceDirectory
|
|
13926
14554
|
}) : Config.layerNode;
|
|
13927
|
-
|
|
14555
|
+
const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
|
|
14556
|
+
return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
|
|
13928
14557
|
};
|
|
13929
14558
|
//#endregion
|
|
13930
14559
|
//#region src/cli/utils/noop-console.ts
|
|
@@ -14012,7 +14641,7 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
|
|
|
14012
14641
|
};
|
|
14013
14642
|
const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
|
|
14014
14643
|
const FETCH_FIX_RECIPE_LABEL = "Fetch & follow the canonical fix recipe before fixing";
|
|
14015
|
-
const formatFixRecipeLine = (diagnostic) => `${FETCH_FIX_RECIPE_LABEL}: ${buildRulePromptUrl(diagnostic.plugin, diagnostic.rule)}
|
|
14644
|
+
const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FETCH_FIX_RECIPE_LABEL}: ${buildRulePromptUrl(diagnostic.plugin, diagnostic.rule)}` : null;
|
|
14016
14645
|
//#endregion
|
|
14017
14646
|
//#region src/cli/utils/box-text.ts
|
|
14018
14647
|
const ESCAPE = String.fromCharCode(27);
|
|
@@ -14296,7 +14925,8 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
|
|
|
14296
14925
|
];
|
|
14297
14926
|
if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
|
|
14298
14927
|
if (firstDiagnostic.url) sections.push("", `Docs: ${firstDiagnostic.url}`);
|
|
14299
|
-
|
|
14928
|
+
const fixRecipeLine = formatFixRecipeLine(firstDiagnostic);
|
|
14929
|
+
if (fixRecipeLine) sections.push("", fixRecipeLine);
|
|
14300
14930
|
sections.push("", "Files:");
|
|
14301
14931
|
const fileSites = buildVerboseSiteMap(ruleDiagnostics);
|
|
14302
14932
|
for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
|
|
@@ -14715,7 +15345,7 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
|
|
|
14715
15345
|
const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
|
|
14716
15346
|
//#endregion
|
|
14717
15347
|
//#region src/cli/utils/version.ts
|
|
14718
|
-
const VERSION = "0.2.14-dev.
|
|
15348
|
+
const VERSION = "0.2.14-dev.b3c3aa9";
|
|
14719
15349
|
//#endregion
|
|
14720
15350
|
//#region src/inspect.ts
|
|
14721
15351
|
const silentConsole = makeNoopConsole();
|
|
@@ -14745,7 +15375,8 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
14745
15375
|
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
14746
15376
|
ignoredTags: buildIgnoredTags(userConfig),
|
|
14747
15377
|
outputSurface: inputOptions.outputSurface ?? "cli",
|
|
14748
|
-
suppressRendering: inputOptions.suppressRendering ?? false
|
|
15378
|
+
suppressRendering: inputOptions.suppressRendering ?? false,
|
|
15379
|
+
concurrency: inputOptions.concurrency
|
|
14749
15380
|
});
|
|
14750
15381
|
const inspect = async (directory, inputOptions = {}) => {
|
|
14751
15382
|
const startTime = performance.now();
|
|
@@ -14785,7 +15416,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14785
15416
|
shouldSkipLint: !options.lint || lintBindingMissing,
|
|
14786
15417
|
shouldRunDeadCode: options.deadCode,
|
|
14787
15418
|
shouldComputeScore: !options.noScore,
|
|
14788
|
-
shouldShowProgressSpinners
|
|
15419
|
+
shouldShowProgressSpinners,
|
|
15420
|
+
oxlintConcurrency: options.concurrency
|
|
14789
15421
|
});
|
|
14790
15422
|
const program = runInspect({
|
|
14791
15423
|
directory,
|
|
@@ -14841,15 +15473,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
14841
15473
|
};
|
|
14842
15474
|
const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
14843
15475
|
const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
|
|
14844
|
-
const skippedChecks =
|
|
14845
|
-
|
|
14846
|
-
|
|
15476
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
|
|
15477
|
+
didLintFail,
|
|
15478
|
+
lintFailureReason,
|
|
15479
|
+
lintPartialFailures,
|
|
15480
|
+
didDeadCodeFail,
|
|
15481
|
+
deadCodeFailureReason
|
|
15482
|
+
});
|
|
14847
15483
|
const hasSkippedChecks = skippedChecks.length > 0;
|
|
14848
15484
|
const noScoreMessage = buildNoScoreMessage(options.noScore);
|
|
14849
|
-
const skippedCheckReasons = {};
|
|
14850
|
-
if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
|
|
14851
|
-
else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
|
|
14852
|
-
if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
|
|
14853
15485
|
const buildResult = () => ({
|
|
14854
15486
|
diagnostics: [...diagnostics],
|
|
14855
15487
|
score,
|
|
@@ -15029,7 +15661,9 @@ const buildHandoffPayload = (input) => {
|
|
|
15029
15661
|
topGroups.forEach(([ruleKey, ruleDiagnostics], index) => {
|
|
15030
15662
|
const representative = ruleDiagnostics[0];
|
|
15031
15663
|
const severityLabel = representative.severity === "error" ? "ERROR" : "WARN";
|
|
15032
|
-
lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (×${ruleDiagnostics.length})`, ` ${representative.message}
|
|
15664
|
+
lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (×${ruleDiagnostics.length})`, ` ${representative.message}`);
|
|
15665
|
+
const fixRecipeLine = formatFixRecipeLine(representative);
|
|
15666
|
+
if (fixRecipeLine) lines.push(` ${fixRecipeLine}`);
|
|
15033
15667
|
const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
|
|
15034
15668
|
for (const filePath of uniqueFiles.slice(0, 3)) {
|
|
15035
15669
|
const firstSite = ruleDiagnostics.find((diagnostic) => diagnostic.filePath === filePath && diagnostic.line > 0);
|
|
@@ -16594,6 +17228,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
|
|
|
16594
17228
|
for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
|
|
16595
17229
|
};
|
|
16596
17230
|
//#endregion
|
|
17231
|
+
//#region src/cli/utils/resolve-parallel-flag.ts
|
|
17232
|
+
/**
|
|
17233
|
+
* Translates the `--experimental-parallel [workers]` flag into a concrete
|
|
17234
|
+
* worker count for `InspectOptions.concurrency`:
|
|
17235
|
+
*
|
|
17236
|
+
* - flag absent (`undefined`) → `undefined` (defer to the ambient
|
|
17237
|
+
* default: serial unless `REACT_DOCTOR_PARALLEL` is set)
|
|
17238
|
+
* - bare flag / `auto` → auto-detect CPU cores
|
|
17239
|
+
* - `--experimental-parallel <n>` → `n` workers (clamped)
|
|
17240
|
+
* - `false` / `off` / `0` → serial (an explicit opt-out, so
|
|
17241
|
+
* it overrides an env-enabled default rather than deferring to it)
|
|
17242
|
+
* - an unparseable value → auto-detect cores
|
|
17243
|
+
*
|
|
17244
|
+
* Commander yields `true` for a bare flag, the raw string for an explicit
|
|
17245
|
+
* value, and `undefined` when the flag is omitted.
|
|
17246
|
+
*/
|
|
17247
|
+
const resolveParallelFlag = (parallel) => {
|
|
17248
|
+
if (parallel === void 0) return void 0;
|
|
17249
|
+
if (parallel === true) return resolveScanConcurrency("auto");
|
|
17250
|
+
if (parallel === false) return 1;
|
|
17251
|
+
const normalized = parallel.trim().toLowerCase();
|
|
17252
|
+
if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
|
|
17253
|
+
if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
|
|
17254
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
17255
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
|
|
17256
|
+
return resolveScanConcurrency(parsed);
|
|
17257
|
+
};
|
|
17258
|
+
//#endregion
|
|
16597
17259
|
//#region src/cli/utils/resolve-cli-inspect-options.ts
|
|
16598
17260
|
/**
|
|
16599
17261
|
* Translates CLI flags into the `InspectOptions` contract `inspect()`
|
|
@@ -16619,7 +17281,8 @@ const resolveCliInspectOptions = (flags, userConfig) => {
|
|
|
16619
17281
|
noScore: flags.score === false || (userConfig?.noScore ?? false),
|
|
16620
17282
|
isCi: isCiEnvironment(),
|
|
16621
17283
|
silent: Boolean(flags.json),
|
|
16622
|
-
outputSurface: flags.prComment ? "prComment" : "cli"
|
|
17284
|
+
outputSurface: flags.prComment ? "prComment" : "cli",
|
|
17285
|
+
concurrency: resolveParallelFlag(flags.experimentalParallel)
|
|
16623
17286
|
};
|
|
16624
17287
|
};
|
|
16625
17288
|
//#endregion
|
|
@@ -16778,11 +17441,9 @@ const parseFileLineArgument = (rawArgument) => {
|
|
|
16778
17441
|
//#endregion
|
|
16779
17442
|
//#region src/cli/utils/select-projects.ts
|
|
16780
17443
|
const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
17444
|
+
const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json"));
|
|
16781
17445
|
let packages = listWorkspacePackages(rootDirectory);
|
|
16782
|
-
if (packages.length === 0)
|
|
16783
|
-
if (!isMonorepoRoot(rootDirectory)) return [rootDirectory];
|
|
16784
|
-
packages = discoverReactSubprojects(rootDirectory);
|
|
16785
|
-
}
|
|
17446
|
+
if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) packages = discoverReactSubprojects(rootDirectory);
|
|
16786
17447
|
if (packages.length === 0) return [rootDirectory];
|
|
16787
17448
|
if (packages.length === 1) {
|
|
16788
17449
|
cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages[0].name}`);
|
|
@@ -16931,7 +17592,7 @@ const inspectAction = async (directory, flags) => {
|
|
|
16931
17592
|
});
|
|
16932
17593
|
try {
|
|
16933
17594
|
validateModeFlags(flags);
|
|
16934
|
-
const scanTarget = resolveScanTarget(requestedDirectory);
|
|
17595
|
+
const scanTarget = resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
|
|
16935
17596
|
const userConfig = scanTarget.userConfig;
|
|
16936
17597
|
const resolvedDirectory = scanTarget.resolvedDirectory;
|
|
16937
17598
|
setJsonReportDirectory(resolvedDirectory);
|
|
@@ -17171,7 +17832,7 @@ const ROOT_FLAG_SPEC = {
|
|
|
17171
17832
|
"--project",
|
|
17172
17833
|
"--why"
|
|
17173
17834
|
]),
|
|
17174
|
-
longOptionsWithOptionalValues: new Set(["--diff"]),
|
|
17835
|
+
longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
|
|
17175
17836
|
shortOptionsWithoutValues: new Set([
|
|
17176
17837
|
"-h",
|
|
17177
17838
|
"-v",
|
|
@@ -17263,7 +17924,7 @@ const stripUnknownCliFlags = (argv) => {
|
|
|
17263
17924
|
process.on("SIGINT", exitGracefully);
|
|
17264
17925
|
process.on("SIGTERM", exitGracefully);
|
|
17265
17926
|
unrefStdin();
|
|
17266
|
-
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", `
|
|
17927
|
+
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", `
|
|
17267
17928
|
${highlighter.dim("Configuration:")}
|
|
17268
17929
|
Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
|
|
17269
17930
|
CLI flags always override config values. See the README for the full schema.
|