react-doctor 0.2.14-dev.8b313ba → 0.2.14-dev.9777f1a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/dist/cli.js +1913 -190
- package/dist/index.d.ts +69 -9
- package/dist/index.js +726 -104
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +8 -4
package/dist/index.js
CHANGED
|
@@ -14,7 +14,10 @@ import * as Redacted from "effect/Redacted";
|
|
|
14
14
|
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
15
15
|
import * as Otlp from "effect/unstable/observability/Otlp";
|
|
16
16
|
import * as Context from "effect/Context";
|
|
17
|
+
import os from "node:os";
|
|
17
18
|
import * as Console from "effect/Console";
|
|
19
|
+
import { parseJSON5 } from "confbox";
|
|
20
|
+
import { createJiti } from "jiti";
|
|
18
21
|
import * as Fiber from "effect/Fiber";
|
|
19
22
|
import * as Filter from "effect/Filter";
|
|
20
23
|
import * as Option from "effect/Option";
|
|
@@ -26,7 +29,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
|
|
|
26
29
|
import * as NodePath from "@effect/platform-node-shared/NodePath";
|
|
27
30
|
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
28
31
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
29
|
-
import os from "node:os";
|
|
30
32
|
import * as ts from "typescript";
|
|
31
33
|
import { gzipSync } from "node:zlib";
|
|
32
34
|
//#region \0rolldown/runtime.js
|
|
@@ -2874,29 +2876,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
2874
2876
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
2875
2877
|
};
|
|
2876
2878
|
};
|
|
2877
|
-
const
|
|
2878
|
-
|
|
2879
|
+
const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
|
|
2880
|
+
const rootValue = select(rootPackageJson);
|
|
2881
|
+
if (rootValue !== null) return rootValue;
|
|
2879
2882
|
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
2880
|
-
if (patterns.length === 0) return
|
|
2883
|
+
if (patterns.length === 0) return null;
|
|
2881
2884
|
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
2882
2885
|
for (const pattern of patterns) {
|
|
2883
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
2886
|
+
const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
|
|
2884
2887
|
for (const workspaceDirectory of directories) {
|
|
2885
2888
|
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
2886
2889
|
visitedDirectories.add(workspaceDirectory);
|
|
2887
|
-
|
|
2890
|
+
const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
|
|
2891
|
+
if (value !== null) return value;
|
|
2888
2892
|
}
|
|
2889
2893
|
}
|
|
2890
|
-
return
|
|
2894
|
+
return null;
|
|
2891
2895
|
};
|
|
2896
|
+
const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
|
|
2892
2897
|
const NAMES = new Set([
|
|
2893
2898
|
"react-native",
|
|
2894
2899
|
"react-native-tvos",
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
+
...new Set([
|
|
2901
|
+
"expo",
|
|
2902
|
+
"expo-router",
|
|
2903
|
+
"@expo/cli",
|
|
2904
|
+
"@expo/metro-config",
|
|
2905
|
+
"@expo/metro-runtime"
|
|
2906
|
+
]),
|
|
2900
2907
|
"react-native-windows",
|
|
2901
2908
|
"react-native-macos"
|
|
2902
2909
|
]);
|
|
@@ -2920,6 +2927,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
|
|
|
2920
2927
|
return false;
|
|
2921
2928
|
};
|
|
2922
2929
|
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
|
|
2930
|
+
const getExpoDependencySpec = (packageJson) => {
|
|
2931
|
+
const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
|
|
2932
|
+
return typeof spec === "string" ? spec : null;
|
|
2933
|
+
};
|
|
2934
|
+
const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
|
|
2923
2935
|
const getPreactVersion = (packageJson) => {
|
|
2924
2936
|
return {
|
|
2925
2937
|
...packageJson.peerDependencies,
|
|
@@ -3159,6 +3171,19 @@ const discoverProject = (directory) => {
|
|
|
3159
3171
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
3160
3172
|
const sourceFileCount = countSourceFiles(directory);
|
|
3161
3173
|
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
3174
|
+
let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
|
|
3175
|
+
if (expoVersion !== null && isCatalogReference(expoVersion)) {
|
|
3176
|
+
const catalogName = extractCatalogName(expoVersion);
|
|
3177
|
+
let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
|
|
3178
|
+
if (!resolvedExpoVersion) {
|
|
3179
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
3180
|
+
if (monorepoRoot) {
|
|
3181
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
3182
|
+
if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
expoVersion = resolvedExpoVersion ?? expoVersion;
|
|
3186
|
+
}
|
|
3162
3187
|
const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
|
|
3163
3188
|
const preactVersion = getPreactVersion(packageJson);
|
|
3164
3189
|
const projectInfo = {
|
|
@@ -3176,6 +3201,7 @@ const discoverProject = (directory) => {
|
|
|
3176
3201
|
preactVersion,
|
|
3177
3202
|
preactMajorVersion: parseReactMajor(preactVersion),
|
|
3178
3203
|
hasReactNativeWorkspace,
|
|
3204
|
+
expoVersion,
|
|
3179
3205
|
hasReanimated,
|
|
3180
3206
|
sourceFileCount
|
|
3181
3207
|
};
|
|
@@ -3265,7 +3291,14 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
3265
3291
|
"tsconfig.json",
|
|
3266
3292
|
"tsconfig.base.json",
|
|
3267
3293
|
"package.json",
|
|
3268
|
-
"
|
|
3294
|
+
"doctor.config.ts",
|
|
3295
|
+
"doctor.config.mts",
|
|
3296
|
+
"doctor.config.cts",
|
|
3297
|
+
"doctor.config.js",
|
|
3298
|
+
"doctor.config.mjs",
|
|
3299
|
+
"doctor.config.cjs",
|
|
3300
|
+
"doctor.config.json",
|
|
3301
|
+
"doctor.config.jsonc",
|
|
3269
3302
|
"oxlint.json",
|
|
3270
3303
|
".oxlintrc.json"
|
|
3271
3304
|
];
|
|
@@ -3280,6 +3313,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
|
3280
3313
|
"Accessibility",
|
|
3281
3314
|
"Maintainability"
|
|
3282
3315
|
];
|
|
3316
|
+
const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
|
|
3317
|
+
const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
|
|
3318
|
+
const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
|
|
3283
3319
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
3284
3320
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
3285
3321
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -3399,10 +3435,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
3399
3435
|
*/
|
|
3400
3436
|
const buildRuleSeverityControls = (config) => {
|
|
3401
3437
|
if (!config) return void 0;
|
|
3402
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
3438
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
3403
3439
|
return {
|
|
3404
3440
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
3405
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
3441
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
3442
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
3406
3443
|
};
|
|
3407
3444
|
};
|
|
3408
3445
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -3766,6 +3803,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
3766
3803
|
}
|
|
3767
3804
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
3768
3805
|
};
|
|
3806
|
+
const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
|
|
3807
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
3808
|
+
const findNearestPackageDirectory = (filename) => {
|
|
3809
|
+
if (!filename) return null;
|
|
3810
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
3811
|
+
if (fromCache !== void 0) return fromCache;
|
|
3812
|
+
let currentDirectory = path.dirname(filename);
|
|
3813
|
+
while (true) {
|
|
3814
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
3815
|
+
let hasPackageJson = false;
|
|
3816
|
+
try {
|
|
3817
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
3818
|
+
} catch {
|
|
3819
|
+
hasPackageJson = false;
|
|
3820
|
+
}
|
|
3821
|
+
if (hasPackageJson) {
|
|
3822
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
3823
|
+
return currentDirectory;
|
|
3824
|
+
}
|
|
3825
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
3826
|
+
if (parentDirectory === currentDirectory) {
|
|
3827
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
3828
|
+
return null;
|
|
3829
|
+
}
|
|
3830
|
+
currentDirectory = parentDirectory;
|
|
3831
|
+
}
|
|
3832
|
+
};
|
|
3833
|
+
const readManifest = (packageJsonPath) => {
|
|
3834
|
+
try {
|
|
3835
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
3836
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
3837
|
+
return null;
|
|
3838
|
+
} catch {
|
|
3839
|
+
return null;
|
|
3840
|
+
}
|
|
3841
|
+
};
|
|
3842
|
+
const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
|
|
3843
|
+
const classifyByDirectoryCohort = (packageDirectory) => {
|
|
3844
|
+
let current = packageDirectory;
|
|
3845
|
+
while (true) {
|
|
3846
|
+
if (path.basename(current) === "apps") return "app";
|
|
3847
|
+
const parent = path.dirname(current);
|
|
3848
|
+
if (parent === current) return null;
|
|
3849
|
+
current = parent;
|
|
3850
|
+
}
|
|
3851
|
+
};
|
|
3852
|
+
const clearPackageRoleCache = () => {
|
|
3853
|
+
cachedRoleByPackageDirectory.clear();
|
|
3854
|
+
cachedPackageDirectoryByFilename.clear();
|
|
3855
|
+
};
|
|
3856
|
+
const classifyPackageRole = (filename) => {
|
|
3857
|
+
if (!filename) return "unknown";
|
|
3858
|
+
const packageDirectory = findNearestPackageDirectory(filename);
|
|
3859
|
+
if (!packageDirectory) return "unknown";
|
|
3860
|
+
const cached = cachedRoleByPackageDirectory.get(packageDirectory);
|
|
3861
|
+
if (cached !== void 0) return cached;
|
|
3862
|
+
const manifest = readManifest(path.join(packageDirectory, "package.json"));
|
|
3863
|
+
let result;
|
|
3864
|
+
if (manifest && hasPublishContract(manifest)) result = "library";
|
|
3865
|
+
else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
|
|
3866
|
+
cachedRoleByPackageDirectory.set(packageDirectory, result);
|
|
3867
|
+
return result;
|
|
3868
|
+
};
|
|
3769
3869
|
/**
|
|
3770
3870
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
3771
3871
|
* accounting for the various shapes oxlint emits:
|
|
@@ -3928,6 +4028,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3928
4028
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
3929
4029
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
3930
4030
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
4031
|
+
const libraryFileCache = /* @__PURE__ */ new Map();
|
|
4032
|
+
const isLibraryFile = (filePath) => {
|
|
4033
|
+
let cached = libraryFileCache.get(filePath);
|
|
4034
|
+
if (cached === void 0) {
|
|
4035
|
+
cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
|
|
4036
|
+
libraryFileCache.set(filePath, cached);
|
|
4037
|
+
}
|
|
4038
|
+
return cached;
|
|
4039
|
+
};
|
|
3931
4040
|
const getFileLines = (filePath) => {
|
|
3932
4041
|
const cached = fileLinesCache.get(filePath);
|
|
3933
4042
|
if (cached !== void 0) return cached;
|
|
@@ -3954,6 +4063,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3954
4063
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
3955
4064
|
return false;
|
|
3956
4065
|
};
|
|
4066
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
4067
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
4068
|
+
return false;
|
|
4069
|
+
};
|
|
3957
4070
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
3958
4071
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
3959
4072
|
if (diagnostic.line <= 0) return false;
|
|
@@ -3968,8 +4081,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3968
4081
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
3969
4082
|
let current = diagnostic;
|
|
3970
4083
|
let explicitSeverityOverride;
|
|
4084
|
+
let explicitRuleOverride;
|
|
3971
4085
|
if (severityControls) {
|
|
3972
4086
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
4087
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
3973
4088
|
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
3974
4089
|
ruleKey,
|
|
3975
4090
|
category
|
|
@@ -3977,6 +4092,9 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3977
4092
|
if (explicitSeverityOverride === "off") return null;
|
|
3978
4093
|
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
3979
4094
|
}
|
|
4095
|
+
if (explicitRuleOverride === void 0) {
|
|
4096
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
4097
|
+
}
|
|
3980
4098
|
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
3981
4099
|
if (userConfig) {
|
|
3982
4100
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
@@ -4162,6 +4280,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
4162
4280
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
4163
4281
|
}).pipe(Effect.orDie));
|
|
4164
4282
|
/**
|
|
4283
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
4284
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
4285
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
4286
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
4287
|
+
*/
|
|
4288
|
+
const resolveScanConcurrency = (requested) => {
|
|
4289
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
4290
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
4291
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
4292
|
+
};
|
|
4293
|
+
/**
|
|
4165
4294
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
4166
4295
|
* startup so the eval harness can raise the budget under sandbox
|
|
4167
4296
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -4181,6 +4310,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
4181
4310
|
* tests that exercise the cap behavior.
|
|
4182
4311
|
*/
|
|
4183
4312
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
4313
|
+
/**
|
|
4314
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
4315
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
4316
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
4317
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
4318
|
+
* CI callers that never touch the flag:
|
|
4319
|
+
*
|
|
4320
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
4321
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
4322
|
+
* - a positive integer → that many workers (clamped)
|
|
4323
|
+
*
|
|
4324
|
+
* The resolved value is always within
|
|
4325
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
4326
|
+
*/
|
|
4327
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
4328
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
4329
|
+
if (raw === void 0) return 1;
|
|
4330
|
+
const normalized = raw.trim().toLowerCase();
|
|
4331
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
4332
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
4333
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
4334
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
4335
|
+
return resolveScanConcurrency(parsed);
|
|
4336
|
+
} }) {};
|
|
4184
4337
|
const DIAGNOSTIC_SURFACES = [
|
|
4185
4338
|
"cli",
|
|
4186
4339
|
"prComment",
|
|
@@ -4337,69 +4490,109 @@ const validateConfigTypes = (config) => {
|
|
|
4337
4490
|
const warn = (message) => {
|
|
4338
4491
|
Effect.runSync(Console.warn(message));
|
|
4339
4492
|
};
|
|
4340
|
-
const
|
|
4493
|
+
const CONFIG_BASENAME = "doctor.config";
|
|
4494
|
+
const CONFIG_EXTENSIONS = [
|
|
4495
|
+
"ts",
|
|
4496
|
+
"mts",
|
|
4497
|
+
"cts",
|
|
4498
|
+
"js",
|
|
4499
|
+
"mjs",
|
|
4500
|
+
"cjs",
|
|
4501
|
+
"json",
|
|
4502
|
+
"jsonc"
|
|
4503
|
+
];
|
|
4504
|
+
const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
|
|
4505
|
+
const PACKAGE_JSON_FILENAME = "package.json";
|
|
4341
4506
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
4342
|
-
const
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
const packageJsonPath = path.join(directory, "package.json");
|
|
4356
|
-
if (isFile(packageJsonPath)) try {
|
|
4357
|
-
const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
|
|
4358
|
-
const packageJson = JSON.parse(fileContent);
|
|
4507
|
+
const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
|
|
4508
|
+
const jiti = createJiti(import.meta.url);
|
|
4509
|
+
const formatError = (error) => error instanceof Error ? error.message : String(error);
|
|
4510
|
+
const loadModuleConfig = async (filePath) => {
|
|
4511
|
+
const imported = await jiti.import(filePath);
|
|
4512
|
+
return imported?.default ?? imported;
|
|
4513
|
+
};
|
|
4514
|
+
const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
|
|
4515
|
+
const readEmbeddedPackageJsonConfig = (directory) => {
|
|
4516
|
+
const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
|
|
4517
|
+
if (!isFile(packageJsonPath)) return null;
|
|
4518
|
+
try {
|
|
4519
|
+
const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
4359
4520
|
if (isPlainObject(packageJson)) {
|
|
4360
4521
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
4361
|
-
if (isPlainObject(embeddedConfig)) return
|
|
4362
|
-
|
|
4363
|
-
|
|
4522
|
+
if (isPlainObject(embeddedConfig)) return embeddedConfig;
|
|
4523
|
+
}
|
|
4524
|
+
} catch {}
|
|
4525
|
+
return null;
|
|
4526
|
+
};
|
|
4527
|
+
const loadPackageJsonConfig = (directory) => {
|
|
4528
|
+
const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
|
|
4529
|
+
if (!embeddedConfig) return null;
|
|
4530
|
+
return {
|
|
4531
|
+
config: validateConfigTypes(embeddedConfig),
|
|
4532
|
+
sourceDirectory: directory,
|
|
4533
|
+
configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
|
|
4534
|
+
format: "package-json"
|
|
4535
|
+
};
|
|
4536
|
+
};
|
|
4537
|
+
const loadConfigFromDirectory = async (directory) => {
|
|
4538
|
+
let sawBrokenConfigFile = false;
|
|
4539
|
+
for (const extension of CONFIG_EXTENSIONS) {
|
|
4540
|
+
const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
|
|
4541
|
+
if (!isFile(filePath)) continue;
|
|
4542
|
+
const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
|
|
4543
|
+
try {
|
|
4544
|
+
const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
|
|
4545
|
+
if (isPlainObject(parsed)) return {
|
|
4546
|
+
status: "found",
|
|
4547
|
+
loaded: {
|
|
4548
|
+
config: validateConfigTypes(parsed),
|
|
4549
|
+
sourceDirectory: directory,
|
|
4550
|
+
configFilePath: filePath,
|
|
4551
|
+
format: isDataFile ? "json" : "module"
|
|
4552
|
+
}
|
|
4364
4553
|
};
|
|
4554
|
+
warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
|
|
4555
|
+
sawBrokenConfigFile = true;
|
|
4556
|
+
} catch (error) {
|
|
4557
|
+
warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
|
|
4558
|
+
sawBrokenConfigFile = true;
|
|
4365
4559
|
}
|
|
4366
|
-
} catch {
|
|
4367
|
-
return null;
|
|
4368
4560
|
}
|
|
4369
|
-
|
|
4561
|
+
const packageJsonConfig = loadPackageJsonConfig(directory);
|
|
4562
|
+
if (packageJsonConfig) return {
|
|
4563
|
+
status: "found",
|
|
4564
|
+
loaded: packageJsonConfig
|
|
4565
|
+
};
|
|
4566
|
+
if (isFile(path.join(directory, LEGACY_CONFIG_FILENAME))) warn(`${LEGACY_CONFIG_FILENAME} is no longer read — rename it to ${CONFIG_BASENAME}.json (or author a ${CONFIG_BASENAME}.ts).`);
|
|
4567
|
+
return {
|
|
4568
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
4569
|
+
loaded: null
|
|
4570
|
+
};
|
|
4370
4571
|
};
|
|
4371
4572
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
4372
4573
|
const clearConfigCache = () => {
|
|
4373
4574
|
cachedConfigs.clear();
|
|
4374
4575
|
};
|
|
4375
|
-
const
|
|
4376
|
-
const
|
|
4377
|
-
if (
|
|
4378
|
-
|
|
4379
|
-
if (localConfig) {
|
|
4380
|
-
cachedConfigs.set(rootDirectory, localConfig);
|
|
4381
|
-
return localConfig;
|
|
4382
|
-
}
|
|
4383
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
4384
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4385
|
-
return null;
|
|
4386
|
-
}
|
|
4576
|
+
const loadConfigWalkingUp = async (rootDirectory) => {
|
|
4577
|
+
const localResult = await loadConfigFromDirectory(rootDirectory);
|
|
4578
|
+
if (localResult.status === "found") return localResult.loaded;
|
|
4579
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
|
|
4387
4580
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
4388
4581
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
4389
|
-
const
|
|
4390
|
-
if (
|
|
4391
|
-
|
|
4392
|
-
return ancestorConfig;
|
|
4393
|
-
}
|
|
4394
|
-
if (isProjectBoundary(ancestorDirectory)) {
|
|
4395
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4396
|
-
return null;
|
|
4397
|
-
}
|
|
4582
|
+
const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
|
|
4583
|
+
if (ancestorResult.status === "found") return ancestorResult.loaded;
|
|
4584
|
+
if (isProjectBoundary(ancestorDirectory)) return null;
|
|
4398
4585
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
4399
4586
|
}
|
|
4400
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4401
4587
|
return null;
|
|
4402
4588
|
};
|
|
4589
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
4590
|
+
const cached = cachedConfigs.get(rootDirectory);
|
|
4591
|
+
if (cached !== void 0) return cached;
|
|
4592
|
+
const loadPromise = loadConfigWalkingUp(rootDirectory);
|
|
4593
|
+
cachedConfigs.set(rootDirectory, loadPromise);
|
|
4594
|
+
return loadPromise;
|
|
4595
|
+
};
|
|
4403
4596
|
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
4404
4597
|
if (!config || !configSourceDirectory) return null;
|
|
4405
4598
|
const rawRootDir = config.rootDir;
|
|
@@ -4414,11 +4607,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
|
4414
4607
|
}
|
|
4415
4608
|
return resolvedRootDir;
|
|
4416
4609
|
};
|
|
4417
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
4610
|
+
const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
4418
4611
|
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
4419
4612
|
const reactSubprojects = discoverReactSubprojects(directory);
|
|
4420
4613
|
if (reactSubprojects.length === 0) return null;
|
|
4421
4614
|
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
4615
|
+
if (options.allowAmbiguous === true) return null;
|
|
4422
4616
|
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
4423
4617
|
};
|
|
4424
4618
|
/**
|
|
@@ -4426,13 +4620,13 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4426
4620
|
* (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
|
|
4427
4621
|
*
|
|
4428
4622
|
* 1. Resolve the requested directory to absolute.
|
|
4429
|
-
* 2. Load `
|
|
4430
|
-
* if present.
|
|
4623
|
+
* 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
|
|
4431
4624
|
* 3. Honor `config.rootDir` to redirect the scan to a nested
|
|
4432
4625
|
* project root, if configured.
|
|
4433
4626
|
* 4. Walk into a nested React subproject when the requested
|
|
4434
4627
|
* directory has no `package.json` of its own (raises
|
|
4435
|
-
* `AmbiguousProjectError` when multiple candidates exist
|
|
4628
|
+
* `AmbiguousProjectError` when multiple candidates exist unless
|
|
4629
|
+
* the caller opts into keeping the wrapper directory).
|
|
4436
4630
|
*
|
|
4437
4631
|
* Throws `ProjectNotFoundError` when neither the requested directory
|
|
4438
4632
|
* nor any discoverable nested project has a `package.json`.
|
|
@@ -4444,14 +4638,14 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4444
4638
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
4445
4639
|
* shell in agreement on what "the scan directory" means.
|
|
4446
4640
|
*/
|
|
4447
|
-
const resolveScanTarget = (requestedDirectory) => {
|
|
4641
|
+
const resolveScanTarget = async (requestedDirectory, options = {}) => {
|
|
4448
4642
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
4449
|
-
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
4643
|
+
const loadedConfig = await loadConfigWithSource(absoluteRequested);
|
|
4450
4644
|
const userConfig = loadedConfig?.config ?? null;
|
|
4451
4645
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
4452
4646
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
4453
4647
|
const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
|
|
4454
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
|
|
4648
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
|
|
4455
4649
|
if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
|
|
4456
4650
|
return {
|
|
4457
4651
|
resolvedDirectory,
|
|
@@ -4461,6 +4655,359 @@ const resolveScanTarget = (requestedDirectory) => {
|
|
|
4461
4655
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
4462
4656
|
};
|
|
4463
4657
|
};
|
|
4658
|
+
const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
|
|
4659
|
+
const buildExpoCheckContext = (rootDirectory, expoVersion) => {
|
|
4660
|
+
const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
|
|
4661
|
+
return {
|
|
4662
|
+
rootDirectory,
|
|
4663
|
+
packageJson,
|
|
4664
|
+
directDependencyNames: getDirectDependencyNames(packageJson),
|
|
4665
|
+
expoSdkMajor: getLowestDependencyMajor(expoVersion)
|
|
4666
|
+
};
|
|
4667
|
+
};
|
|
4668
|
+
const buildExpoDiagnostic = (input) => ({
|
|
4669
|
+
filePath: input.filePath ?? "package.json",
|
|
4670
|
+
plugin: "react-doctor",
|
|
4671
|
+
rule: input.rule,
|
|
4672
|
+
severity: input.severity ?? "warning",
|
|
4673
|
+
message: input.message,
|
|
4674
|
+
help: input.help,
|
|
4675
|
+
line: input.line ?? 0,
|
|
4676
|
+
column: input.column ?? 0,
|
|
4677
|
+
category: input.category ?? "Correctness"
|
|
4678
|
+
});
|
|
4679
|
+
const CRITICAL_OVERRIDE_NAMES = new Set([
|
|
4680
|
+
"@expo/cli",
|
|
4681
|
+
"@expo/config",
|
|
4682
|
+
"@expo/metro-config",
|
|
4683
|
+
"@expo/metro-runtime",
|
|
4684
|
+
"@expo/metro",
|
|
4685
|
+
"metro"
|
|
4686
|
+
]);
|
|
4687
|
+
const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
|
|
4688
|
+
const collectOverrideNames = (packageJson) => new Set([
|
|
4689
|
+
...Object.keys(packageJson.overrides ?? {}),
|
|
4690
|
+
...Object.keys(packageJson.resolutions ?? {}),
|
|
4691
|
+
...Object.keys(packageJson.pnpm?.overrides ?? {})
|
|
4692
|
+
]);
|
|
4693
|
+
const checkExpoDependencyOverrides = (context) => {
|
|
4694
|
+
const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
|
|
4695
|
+
if (overriddenCriticalNames.length === 0) return [];
|
|
4696
|
+
const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
|
|
4697
|
+
return [buildExpoDiagnostic({
|
|
4698
|
+
rule: "expo-no-conflicting-dependency-override",
|
|
4699
|
+
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`,
|
|
4700
|
+
help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
|
|
4701
|
+
})];
|
|
4702
|
+
};
|
|
4703
|
+
const isPathGitIgnored = (rootDirectory, absolutePath) => {
|
|
4704
|
+
const result = spawnSync("git", [
|
|
4705
|
+
"check-ignore",
|
|
4706
|
+
"-q",
|
|
4707
|
+
absolutePath
|
|
4708
|
+
], {
|
|
4709
|
+
cwd: rootDirectory,
|
|
4710
|
+
stdio: [
|
|
4711
|
+
"ignore",
|
|
4712
|
+
"ignore",
|
|
4713
|
+
"ignore"
|
|
4714
|
+
]
|
|
4715
|
+
});
|
|
4716
|
+
if (result.error) return null;
|
|
4717
|
+
if (result.status === 0) return true;
|
|
4718
|
+
if (result.status === 1) return false;
|
|
4719
|
+
return null;
|
|
4720
|
+
};
|
|
4721
|
+
const LOCAL_ENV_FILE_NAMES = [
|
|
4722
|
+
".env.local",
|
|
4723
|
+
".env.development.local",
|
|
4724
|
+
".env.production.local",
|
|
4725
|
+
".env.test.local"
|
|
4726
|
+
];
|
|
4727
|
+
const checkExpoEnvLocalFiles = (context) => {
|
|
4728
|
+
const { rootDirectory } = context;
|
|
4729
|
+
const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
|
|
4730
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
4731
|
+
if (!isFile(filePath)) return false;
|
|
4732
|
+
return isPathGitIgnored(rootDirectory, filePath) === false;
|
|
4733
|
+
});
|
|
4734
|
+
if (committedEnvFiles.length === 0) return [];
|
|
4735
|
+
return [buildExpoDiagnostic({
|
|
4736
|
+
rule: "expo-env-local-not-gitignored",
|
|
4737
|
+
category: "Security",
|
|
4738
|
+
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`,
|
|
4739
|
+
help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
|
|
4740
|
+
})];
|
|
4741
|
+
};
|
|
4742
|
+
const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
|
|
4743
|
+
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";
|
|
4744
|
+
const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
|
|
4745
|
+
const unimodulesEntry = (packageName) => ({
|
|
4746
|
+
packageName,
|
|
4747
|
+
rule: "expo-no-unimodules-packages",
|
|
4748
|
+
message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
|
|
4749
|
+
help: UNIMODULES_HELP
|
|
4750
|
+
});
|
|
4751
|
+
const FLAGGED_DEPENDENCIES = [
|
|
4752
|
+
unimodulesEntry("@unimodules/core"),
|
|
4753
|
+
unimodulesEntry("@unimodules/react-native-adapter"),
|
|
4754
|
+
unimodulesEntry("react-native-unimodules"),
|
|
4755
|
+
{
|
|
4756
|
+
packageName: "expo-cli",
|
|
4757
|
+
rule: "expo-no-cli-dependencies",
|
|
4758
|
+
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`",
|
|
4759
|
+
help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
|
|
4760
|
+
},
|
|
4761
|
+
{
|
|
4762
|
+
packageName: "eas-cli",
|
|
4763
|
+
rule: "expo-no-cli-dependencies",
|
|
4764
|
+
message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
|
|
4765
|
+
help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
|
|
4766
|
+
},
|
|
4767
|
+
{
|
|
4768
|
+
packageName: "expo-modules-autolinking",
|
|
4769
|
+
rule: "expo-no-redundant-dependency",
|
|
4770
|
+
message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
|
|
4771
|
+
help: "Remove `expo-modules-autolinking` from your package.json"
|
|
4772
|
+
},
|
|
4773
|
+
{
|
|
4774
|
+
packageName: "expo-dev-launcher",
|
|
4775
|
+
rule: "expo-no-redundant-dependency",
|
|
4776
|
+
message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
4777
|
+
help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
|
|
4778
|
+
},
|
|
4779
|
+
{
|
|
4780
|
+
packageName: "expo-dev-menu",
|
|
4781
|
+
rule: "expo-no-redundant-dependency",
|
|
4782
|
+
message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
4783
|
+
help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
|
|
4784
|
+
},
|
|
4785
|
+
{
|
|
4786
|
+
packageName: "expo-modules-core",
|
|
4787
|
+
rule: "expo-no-redundant-dependency",
|
|
4788
|
+
message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
|
|
4789
|
+
help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
|
|
4790
|
+
},
|
|
4791
|
+
{
|
|
4792
|
+
packageName: "@expo/metro-config",
|
|
4793
|
+
rule: "expo-no-redundant-dependency",
|
|
4794
|
+
message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
|
|
4795
|
+
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
|
|
4796
|
+
},
|
|
4797
|
+
{
|
|
4798
|
+
packageName: "@types/react-native",
|
|
4799
|
+
rule: "expo-no-redundant-dependency",
|
|
4800
|
+
message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
|
|
4801
|
+
help: "Remove `@types/react-native` from your package.json",
|
|
4802
|
+
minSdkMajor: 48
|
|
4803
|
+
},
|
|
4804
|
+
{
|
|
4805
|
+
packageName: "@expo/config-plugins",
|
|
4806
|
+
rule: "expo-no-redundant-dependency",
|
|
4807
|
+
message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
|
|
4808
|
+
help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
|
|
4809
|
+
minSdkMajor: 48
|
|
4810
|
+
},
|
|
4811
|
+
{
|
|
4812
|
+
packageName: "@expo/prebuild-config",
|
|
4813
|
+
rule: "expo-no-redundant-dependency",
|
|
4814
|
+
message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
|
|
4815
|
+
help: "Remove `@expo/prebuild-config` from your package.json",
|
|
4816
|
+
minSdkMajor: 53
|
|
4817
|
+
},
|
|
4818
|
+
{
|
|
4819
|
+
packageName: "expo-permissions",
|
|
4820
|
+
rule: "expo-no-redundant-dependency",
|
|
4821
|
+
message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
|
|
4822
|
+
help: "Remove `expo-permissions` and request permissions from the relevant module instead",
|
|
4823
|
+
minSdkMajor: 50
|
|
4824
|
+
},
|
|
4825
|
+
{
|
|
4826
|
+
packageName: "expo-app-loading",
|
|
4827
|
+
rule: "expo-no-redundant-dependency",
|
|
4828
|
+
message: "\"expo-app-loading\" was removed in SDK 49",
|
|
4829
|
+
help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
|
|
4830
|
+
minSdkMajor: 49
|
|
4831
|
+
},
|
|
4832
|
+
{
|
|
4833
|
+
packageName: "expo-firebase-analytics",
|
|
4834
|
+
rule: "expo-no-redundant-dependency",
|
|
4835
|
+
message: "\"expo-firebase-analytics\" was removed in SDK 48",
|
|
4836
|
+
help: FIREBASE_HELP,
|
|
4837
|
+
minSdkMajor: 48
|
|
4838
|
+
},
|
|
4839
|
+
{
|
|
4840
|
+
packageName: "expo-firebase-recaptcha",
|
|
4841
|
+
rule: "expo-no-redundant-dependency",
|
|
4842
|
+
message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
|
|
4843
|
+
help: FIREBASE_HELP,
|
|
4844
|
+
minSdkMajor: 48
|
|
4845
|
+
},
|
|
4846
|
+
{
|
|
4847
|
+
packageName: "expo-firebase-core",
|
|
4848
|
+
rule: "expo-no-redundant-dependency",
|
|
4849
|
+
message: "\"expo-firebase-core\" was removed in SDK 48",
|
|
4850
|
+
help: FIREBASE_HELP,
|
|
4851
|
+
minSdkMajor: 48
|
|
4852
|
+
}
|
|
4853
|
+
];
|
|
4854
|
+
const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
|
|
4855
|
+
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
|
|
4856
|
+
if (flaggedDependency.minSdkMajor === void 0) return true;
|
|
4857
|
+
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
|
|
4858
|
+
}).map((flaggedDependency) => buildExpoDiagnostic({
|
|
4859
|
+
rule: flaggedDependency.rule,
|
|
4860
|
+
message: flaggedDependency.message,
|
|
4861
|
+
help: flaggedDependency.help
|
|
4862
|
+
}));
|
|
4863
|
+
const findLocalModuleNativeFiles = (rootDirectory) => {
|
|
4864
|
+
const modulesDirectory = path.join(rootDirectory, "modules");
|
|
4865
|
+
if (!isDirectory(modulesDirectory)) return [];
|
|
4866
|
+
const nativeFilePaths = [];
|
|
4867
|
+
for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
|
|
4868
|
+
if (!moduleEntry.isDirectory()) continue;
|
|
4869
|
+
const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
|
|
4870
|
+
const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
|
|
4871
|
+
if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
|
|
4872
|
+
const iosDirectory = path.join(moduleDirectory, "ios");
|
|
4873
|
+
if (isDirectory(iosDirectory)) {
|
|
4874
|
+
for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
return nativeFilePaths;
|
|
4878
|
+
};
|
|
4879
|
+
const checkExpoGitignore = (context) => {
|
|
4880
|
+
const { rootDirectory } = context;
|
|
4881
|
+
const diagnostics = [];
|
|
4882
|
+
const expoStateDirectory = path.join(rootDirectory, ".expo");
|
|
4883
|
+
if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
|
|
4884
|
+
rule: "expo-gitignore",
|
|
4885
|
+
message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
|
|
4886
|
+
help: "Add `.expo/` to your .gitignore"
|
|
4887
|
+
}));
|
|
4888
|
+
if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
|
|
4889
|
+
rule: "expo-gitignore",
|
|
4890
|
+
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",
|
|
4891
|
+
help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
|
|
4892
|
+
}));
|
|
4893
|
+
return diagnostics;
|
|
4894
|
+
};
|
|
4895
|
+
const LOCKFILE_NAMES = [
|
|
4896
|
+
"pnpm-lock.yaml",
|
|
4897
|
+
"yarn.lock",
|
|
4898
|
+
"package-lock.json",
|
|
4899
|
+
"bun.lockb",
|
|
4900
|
+
"bun.lock"
|
|
4901
|
+
];
|
|
4902
|
+
const checkExpoLockfile = (context) => {
|
|
4903
|
+
const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
|
|
4904
|
+
const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
|
|
4905
|
+
if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
|
|
4906
|
+
rule: "expo-lockfile",
|
|
4907
|
+
message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
|
|
4908
|
+
help: "Install dependencies with your package manager to generate a lock file, then commit it"
|
|
4909
|
+
})];
|
|
4910
|
+
if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
|
|
4911
|
+
rule: "expo-lockfile",
|
|
4912
|
+
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`,
|
|
4913
|
+
help: "Delete the lock files for the package managers you are not using and keep only one"
|
|
4914
|
+
})];
|
|
4915
|
+
return [];
|
|
4916
|
+
};
|
|
4917
|
+
const METRO_CONFIG_FILE_NAMES = [
|
|
4918
|
+
"metro.config.js",
|
|
4919
|
+
"metro.config.cjs",
|
|
4920
|
+
"metro.config.mjs",
|
|
4921
|
+
"metro.config.ts"
|
|
4922
|
+
];
|
|
4923
|
+
const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
|
|
4924
|
+
"expo/metro-config",
|
|
4925
|
+
"@sentry/react-native/metro",
|
|
4926
|
+
"getSentryExpoConfig"
|
|
4927
|
+
];
|
|
4928
|
+
const checkExpoMetroConfig = (context) => {
|
|
4929
|
+
const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
|
|
4930
|
+
if (metroConfigPath === void 0) return [];
|
|
4931
|
+
let contents;
|
|
4932
|
+
try {
|
|
4933
|
+
contents = fs.readFileSync(metroConfigPath, "utf-8");
|
|
4934
|
+
} catch {
|
|
4935
|
+
return [];
|
|
4936
|
+
}
|
|
4937
|
+
if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
|
|
4938
|
+
return [buildExpoDiagnostic({
|
|
4939
|
+
rule: "expo-metro-config",
|
|
4940
|
+
filePath: path.basename(metroConfigPath),
|
|
4941
|
+
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",
|
|
4942
|
+
help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
|
|
4943
|
+
})];
|
|
4944
|
+
};
|
|
4945
|
+
const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
|
|
4946
|
+
const checkExpoPackageJsonConflicts = (context) => {
|
|
4947
|
+
const { packageJson } = context;
|
|
4948
|
+
const diagnostics = [];
|
|
4949
|
+
const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
|
|
4950
|
+
if (conflictingScriptNames.length > 0) {
|
|
4951
|
+
const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
|
|
4952
|
+
const shadowsExpoCli = conflictingScriptNames.includes("expo");
|
|
4953
|
+
diagnostics.push(buildExpoDiagnostic({
|
|
4954
|
+
rule: "expo-package-json-conflict",
|
|
4955
|
+
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" : ""}`,
|
|
4956
|
+
help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
|
|
4957
|
+
}));
|
|
4958
|
+
}
|
|
4959
|
+
const packageName = packageJson.name;
|
|
4960
|
+
if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
|
|
4961
|
+
rule: "expo-package-json-conflict",
|
|
4962
|
+
message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
|
|
4963
|
+
help: "Rename your package so it no longer matches one of its dependencies"
|
|
4964
|
+
}));
|
|
4965
|
+
return diagnostics;
|
|
4966
|
+
};
|
|
4967
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
4968
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
4969
|
+
const checkExpoRouterReactNavigation = (context) => {
|
|
4970
|
+
const { expoSdkMajor } = context;
|
|
4971
|
+
if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
|
|
4972
|
+
if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
|
|
4973
|
+
if (!context.directDependencyNames.has("expo-router")) return [];
|
|
4974
|
+
const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
|
|
4975
|
+
if (reactNavigationNames.length === 0) return [];
|
|
4976
|
+
return [buildExpoDiagnostic({
|
|
4977
|
+
rule: "expo-router-no-react-navigation",
|
|
4978
|
+
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"}`,
|
|
4979
|
+
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/"
|
|
4980
|
+
})];
|
|
4981
|
+
};
|
|
4982
|
+
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
4983
|
+
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
4984
|
+
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
4985
|
+
const checkExpoVectorIcons = (context) => {
|
|
4986
|
+
if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
|
|
4987
|
+
const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
|
|
4988
|
+
const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
|
|
4989
|
+
if (!hasScopedPackage || !hasConflictingPackage) return [];
|
|
4990
|
+
return [buildExpoDiagnostic({
|
|
4991
|
+
rule: "expo-vector-icons-conflict",
|
|
4992
|
+
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",
|
|
4993
|
+
help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
|
|
4994
|
+
})];
|
|
4995
|
+
};
|
|
4996
|
+
const checkExpoProject = (rootDirectory, project) => {
|
|
4997
|
+
if (project.expoVersion === null) return [];
|
|
4998
|
+
const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
|
|
4999
|
+
return [
|
|
5000
|
+
...checkExpoFlaggedDependencies(context),
|
|
5001
|
+
...checkExpoDependencyOverrides(context),
|
|
5002
|
+
...checkExpoRouterReactNavigation(context),
|
|
5003
|
+
...checkExpoVectorIcons(context),
|
|
5004
|
+
...checkExpoPackageJsonConflicts(context),
|
|
5005
|
+
...checkExpoLockfile(context),
|
|
5006
|
+
...checkExpoGitignore(context),
|
|
5007
|
+
...checkExpoEnvLocalFiles(context),
|
|
5008
|
+
...checkExpoMetroConfig(context)
|
|
5009
|
+
];
|
|
5010
|
+
};
|
|
4464
5011
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
4465
5012
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
4466
5013
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -5143,8 +5690,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
|
5143
5690
|
const cache = yield* Cache.make({
|
|
5144
5691
|
capacity: 16,
|
|
5145
5692
|
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
5146
|
-
lookup: (directory) => Effect.
|
|
5147
|
-
const loaded = loadConfigWithSource(directory);
|
|
5693
|
+
lookup: (directory) => Effect.promise(async () => {
|
|
5694
|
+
const loaded = await loadConfigWithSource(directory);
|
|
5148
5695
|
const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
|
|
5149
5696
|
return {
|
|
5150
5697
|
config: loaded?.config ?? null,
|
|
@@ -5741,6 +6288,7 @@ const buildCapabilities = (project) => {
|
|
|
5741
6288
|
const capabilities = /* @__PURE__ */ new Set();
|
|
5742
6289
|
capabilities.add(project.framework);
|
|
5743
6290
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
6291
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
5744
6292
|
const reactMajor = project.reactMajorVersion;
|
|
5745
6293
|
if (reactMajor !== null) {
|
|
5746
6294
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -5912,10 +6460,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
5912
6460
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
5913
6461
|
return fs.realpathSync(rootDirectory);
|
|
5914
6462
|
};
|
|
6463
|
+
const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
|
|
6464
|
+
if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
|
|
6465
|
+
return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
6466
|
+
};
|
|
5915
6467
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
5916
6468
|
const enabledRules = {};
|
|
5917
6469
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
5918
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
6470
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
5919
6471
|
if (severity === "off") continue;
|
|
5920
6472
|
enabledRules[ruleKey] = severity;
|
|
5921
6473
|
}
|
|
@@ -5957,7 +6509,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
5957
6509
|
category: rule.category
|
|
5958
6510
|
}, severityControls);
|
|
5959
6511
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
5960
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
6512
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
5961
6513
|
if (severity === "off") continue;
|
|
5962
6514
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
5963
6515
|
}
|
|
@@ -6014,6 +6566,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
6014
6566
|
}
|
|
6015
6567
|
return uniqueDiagnostics;
|
|
6016
6568
|
};
|
|
6569
|
+
/**
|
|
6570
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
6571
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
6572
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
6573
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
6574
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
6575
|
+
* batches do).
|
|
6576
|
+
*
|
|
6577
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
6578
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
6579
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
6580
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
6581
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
6582
|
+
* top of a still-running first one.
|
|
6583
|
+
*/
|
|
6584
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
6585
|
+
const results = new Array(items.length);
|
|
6586
|
+
if (items.length === 0) return results;
|
|
6587
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
6588
|
+
let nextIndex = 0;
|
|
6589
|
+
const errors = [];
|
|
6590
|
+
const runWorker = async () => {
|
|
6591
|
+
while (errors.length === 0) {
|
|
6592
|
+
const index = nextIndex;
|
|
6593
|
+
nextIndex += 1;
|
|
6594
|
+
if (index >= items.length) return;
|
|
6595
|
+
try {
|
|
6596
|
+
results[index] = await task(items[index], index);
|
|
6597
|
+
} catch (error) {
|
|
6598
|
+
errors.push(error);
|
|
6599
|
+
return;
|
|
6600
|
+
}
|
|
6601
|
+
}
|
|
6602
|
+
};
|
|
6603
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
6604
|
+
if (errors.length > 0) throw errors[0];
|
|
6605
|
+
return results;
|
|
6606
|
+
};
|
|
6017
6607
|
const getPublicEnvPrefix = (framework) => {
|
|
6018
6608
|
switch (framework) {
|
|
6019
6609
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -6696,6 +7286,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
6696
7286
|
*/
|
|
6697
7287
|
const spawnLintBatches = async (input) => {
|
|
6698
7288
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
7289
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
6699
7290
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
6700
7291
|
const allDiagnostics = [];
|
|
6701
7292
|
const droppedFiles = [];
|
|
@@ -6715,23 +7306,31 @@ const spawnLintBatches = async (input) => {
|
|
|
6715
7306
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
6716
7307
|
}
|
|
6717
7308
|
};
|
|
7309
|
+
let startedFileCount = 0;
|
|
6718
7310
|
let scannedFileCount = 0;
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
const
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
7311
|
+
let displayedFileCount = 0;
|
|
7312
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
7313
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
7314
|
+
if (displayedFileCount < ceiling) {
|
|
7315
|
+
displayedFileCount += 1;
|
|
7316
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
7317
|
+
}
|
|
7318
|
+
}, 50) : null;
|
|
7319
|
+
progressTimer?.unref?.();
|
|
7320
|
+
try {
|
|
7321
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
7322
|
+
startedFileCount += batch.length;
|
|
6728
7323
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
6729
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
6730
7324
|
scannedFileCount += batch.length;
|
|
6731
|
-
onFileProgress
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
7325
|
+
if (onFileProgress) {
|
|
7326
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
7327
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
7328
|
+
}
|
|
7329
|
+
return batchDiagnostics;
|
|
7330
|
+
});
|
|
7331
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
7332
|
+
} finally {
|
|
7333
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
6735
7334
|
}
|
|
6736
7335
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
6737
7336
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -6858,7 +7457,8 @@ const runOxlint = async (options) => {
|
|
|
6858
7457
|
onPartialFailure,
|
|
6859
7458
|
onFileProgress: options.onFileProgress,
|
|
6860
7459
|
spawnTimeoutMs,
|
|
6861
|
-
outputMaxBytes
|
|
7460
|
+
outputMaxBytes,
|
|
7461
|
+
concurrency: options.concurrency
|
|
6862
7462
|
});
|
|
6863
7463
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
6864
7464
|
try {
|
|
@@ -6926,6 +7526,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6926
7526
|
const partialFailures = yield* LintPartialFailures;
|
|
6927
7527
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
6928
7528
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
7529
|
+
const concurrency = yield* OxlintConcurrency;
|
|
6929
7530
|
const collectedFailures = [];
|
|
6930
7531
|
const diagnostics = yield* Effect.tryPromise({
|
|
6931
7532
|
try: () => runOxlint({
|
|
@@ -6944,7 +7545,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6944
7545
|
},
|
|
6945
7546
|
onFileProgress: input.onFileProgress,
|
|
6946
7547
|
spawnTimeoutMs,
|
|
6947
|
-
outputMaxBytes
|
|
7548
|
+
outputMaxBytes,
|
|
7549
|
+
concurrency
|
|
6948
7550
|
}),
|
|
6949
7551
|
catch: ensureReactDoctorError
|
|
6950
7552
|
});
|
|
@@ -7268,7 +7870,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7268
7870
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
7269
7871
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
7270
7872
|
const isDiffMode = input.includePaths.length > 0;
|
|
7271
|
-
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ??
|
|
7873
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
7272
7874
|
const transform = buildDiagnosticPipeline({
|
|
7273
7875
|
rootDirectory: scanDirectory,
|
|
7274
7876
|
userConfig: resolvedConfig.config,
|
|
@@ -7277,7 +7879,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7277
7879
|
showWarnings
|
|
7278
7880
|
});
|
|
7279
7881
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
7280
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7882
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7883
|
+
...checkReducedMotion(scanDirectory),
|
|
7884
|
+
...checkPnpmHardening(scanDirectory),
|
|
7885
|
+
...checkExpoProject(scanDirectory, project)
|
|
7886
|
+
];
|
|
7281
7887
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
7282
7888
|
const lintFailure = yield* Ref.make({
|
|
7283
7889
|
didFail: false,
|
|
@@ -7289,6 +7895,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7289
7895
|
didFail: false,
|
|
7290
7896
|
reason: null
|
|
7291
7897
|
});
|
|
7898
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
7899
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
7292
7900
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
7293
7901
|
const scanStartTime = Date.now();
|
|
7294
7902
|
let lastReportedTotalFileCount = 0;
|
|
@@ -7305,7 +7913,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7305
7913
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
7306
7914
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
7307
7915
|
lastReportedTotalFileCount = totalFileCount;
|
|
7308
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
7916
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
7309
7917
|
}
|
|
7310
7918
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
7311
7919
|
yield* Ref.set(lintFailure, {
|
|
@@ -7337,7 +7945,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7337
7945
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
7338
7946
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
7339
7947
|
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
7340
|
-
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
|
|
7948
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
7341
7949
|
yield* reporterService.finalize;
|
|
7342
7950
|
const finalDiagnostics = [
|
|
7343
7951
|
...envCollected,
|
|
@@ -7389,7 +7997,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7389
7997
|
"inspect.isCi": input.isCi,
|
|
7390
7998
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
7391
7999
|
} }));
|
|
7392
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
7393
8000
|
const parseNodeVersion = (versionString) => {
|
|
7394
8001
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
7395
8002
|
return {
|
|
@@ -7688,6 +8295,26 @@ const buildJsonReport = (input) => {
|
|
|
7688
8295
|
};
|
|
7689
8296
|
};
|
|
7690
8297
|
/**
|
|
8298
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
8299
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
8300
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
8301
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
8302
|
+
* branch logic lives here once.
|
|
8303
|
+
*/
|
|
8304
|
+
const buildSkippedChecks = (input) => {
|
|
8305
|
+
const skippedChecks = [];
|
|
8306
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
8307
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
8308
|
+
const skippedCheckReasons = {};
|
|
8309
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
8310
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
8311
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
8312
|
+
return {
|
|
8313
|
+
skippedChecks,
|
|
8314
|
+
skippedCheckReasons
|
|
8315
|
+
};
|
|
8316
|
+
};
|
|
8317
|
+
/**
|
|
7691
8318
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
7692
8319
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
7693
8320
|
* spawn, not `spawnSync`).
|
|
@@ -7793,7 +8420,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
|
|
|
7793
8420
|
const clearAutoSuppressionCaches = () => {};
|
|
7794
8421
|
//#endregion
|
|
7795
8422
|
//#region ../api/dist/index.js
|
|
7796
|
-
const
|
|
8423
|
+
const buildDiagnoseLayer = (configLayer = Config.layerNode) => Layer.mergeAll(Project.layerNode, configLayer, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
7797
8424
|
const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
7798
8425
|
const effectiveConfig = configOverride ?? scanTarget.userConfig;
|
|
7799
8426
|
const includePaths = options.includePaths ?? [];
|
|
@@ -7802,7 +8429,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7802
8429
|
includePaths,
|
|
7803
8430
|
customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
|
|
7804
8431
|
respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
|
|
7805
|
-
warnings: options.warnings ?? effectiveConfig?.warnings ??
|
|
8432
|
+
warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
|
|
7806
8433
|
adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
|
|
7807
8434
|
ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
|
|
7808
8435
|
runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
|
|
@@ -7812,13 +8439,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7812
8439
|
};
|
|
7813
8440
|
const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
7814
8441
|
if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
|
|
7815
|
-
const skippedChecks =
|
|
7816
|
-
if (output.didLintFail) skippedChecks.push("lint");
|
|
7817
|
-
if (output.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
7818
|
-
const skippedCheckReasons = {};
|
|
7819
|
-
if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
|
|
7820
|
-
else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
|
|
7821
|
-
if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
|
|
8442
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
|
|
7822
8443
|
return {
|
|
7823
8444
|
diagnostics: [...output.diagnostics],
|
|
7824
8445
|
score: output.score,
|
|
@@ -7830,8 +8451,8 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
|
7830
8451
|
};
|
|
7831
8452
|
const diagnose = async (directory, options = {}) => {
|
|
7832
8453
|
const startTime = globalThis.performance.now();
|
|
7833
|
-
const program = buildInspectProgram(resolveScanTarget(directory), options);
|
|
7834
|
-
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(
|
|
8454
|
+
const program = buildInspectProgram(await resolveScanTarget(directory), options);
|
|
8455
|
+
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
|
|
7835
8456
|
};
|
|
7836
8457
|
//#endregion
|
|
7837
8458
|
//#region src/index.ts
|
|
@@ -7840,6 +8461,7 @@ const clearCaches = () => {
|
|
|
7840
8461
|
clearConfigCache();
|
|
7841
8462
|
clearPackageJsonCache();
|
|
7842
8463
|
clearIgnorePatternsCache();
|
|
8464
|
+
clearPackageRoleCache();
|
|
7843
8465
|
clearAutoSuppressionCaches();
|
|
7844
8466
|
};
|
|
7845
8467
|
const toJsonReport = (result, options) => buildJsonReport({
|