react-doctor 0.2.14-dev.6e59f10 → 0.2.14-dev.75c1f99
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 +48 -2
- package/dist/cli.js +2627 -388
- package/dist/index.d.ts +69 -9
- package/dist/index.js +754 -106
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="fce73b02-d297-5132-af08-817f37e1467c")}catch(e){}}();
|
|
1
3
|
import { createRequire } from "node:module";
|
|
2
4
|
import * as Schema from "effect/Schema";
|
|
3
5
|
import * as fs$1 from "node:fs";
|
|
@@ -14,7 +16,10 @@ import * as Redacted from "effect/Redacted";
|
|
|
14
16
|
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
15
17
|
import * as Otlp from "effect/unstable/observability/Otlp";
|
|
16
18
|
import * as Context from "effect/Context";
|
|
19
|
+
import os from "node:os";
|
|
17
20
|
import * as Console from "effect/Console";
|
|
21
|
+
import { parseJSON5 } from "confbox";
|
|
22
|
+
import { createJiti } from "jiti";
|
|
18
23
|
import * as Fiber from "effect/Fiber";
|
|
19
24
|
import * as Filter from "effect/Filter";
|
|
20
25
|
import * as Option from "effect/Option";
|
|
@@ -26,7 +31,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
|
|
|
26
31
|
import * as NodePath from "@effect/platform-node-shared/NodePath";
|
|
27
32
|
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
28
33
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
29
|
-
import os from "node:os";
|
|
30
34
|
import * as ts from "typescript";
|
|
31
35
|
import { gzipSync } from "node:zlib";
|
|
32
36
|
//#region \0rolldown/runtime.js
|
|
@@ -2874,29 +2878,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
2874
2878
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
2875
2879
|
};
|
|
2876
2880
|
};
|
|
2877
|
-
const
|
|
2878
|
-
|
|
2881
|
+
const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
|
|
2882
|
+
const rootValue = select(rootPackageJson);
|
|
2883
|
+
if (rootValue !== null) return rootValue;
|
|
2879
2884
|
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
2880
|
-
if (patterns.length === 0) return
|
|
2885
|
+
if (patterns.length === 0) return null;
|
|
2881
2886
|
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
2882
2887
|
for (const pattern of patterns) {
|
|
2883
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
2888
|
+
const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
|
|
2884
2889
|
for (const workspaceDirectory of directories) {
|
|
2885
2890
|
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
2886
2891
|
visitedDirectories.add(workspaceDirectory);
|
|
2887
|
-
|
|
2892
|
+
const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
|
|
2893
|
+
if (value !== null) return value;
|
|
2888
2894
|
}
|
|
2889
2895
|
}
|
|
2890
|
-
return
|
|
2896
|
+
return null;
|
|
2891
2897
|
};
|
|
2898
|
+
const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
|
|
2892
2899
|
const NAMES = new Set([
|
|
2893
2900
|
"react-native",
|
|
2894
2901
|
"react-native-tvos",
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2902
|
+
...new Set([
|
|
2903
|
+
"expo",
|
|
2904
|
+
"expo-router",
|
|
2905
|
+
"@expo/cli",
|
|
2906
|
+
"@expo/metro-config",
|
|
2907
|
+
"@expo/metro-runtime"
|
|
2908
|
+
]),
|
|
2900
2909
|
"react-native-windows",
|
|
2901
2910
|
"react-native-macos"
|
|
2902
2911
|
]);
|
|
@@ -2920,6 +2929,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
|
|
|
2920
2929
|
return false;
|
|
2921
2930
|
};
|
|
2922
2931
|
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
|
|
2932
|
+
const getExpoDependencySpec = (packageJson) => {
|
|
2933
|
+
const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
|
|
2934
|
+
return typeof spec === "string" ? spec : null;
|
|
2935
|
+
};
|
|
2936
|
+
const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
|
|
2923
2937
|
const getPreactVersion = (packageJson) => {
|
|
2924
2938
|
return {
|
|
2925
2939
|
...packageJson.peerDependencies,
|
|
@@ -3159,6 +3173,19 @@ const discoverProject = (directory) => {
|
|
|
3159
3173
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
3160
3174
|
const sourceFileCount = countSourceFiles(directory);
|
|
3161
3175
|
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
3176
|
+
let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
|
|
3177
|
+
if (expoVersion !== null && isCatalogReference(expoVersion)) {
|
|
3178
|
+
const catalogName = extractCatalogName(expoVersion);
|
|
3179
|
+
let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
|
|
3180
|
+
if (!resolvedExpoVersion) {
|
|
3181
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
3182
|
+
if (monorepoRoot) {
|
|
3183
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
3184
|
+
if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
expoVersion = resolvedExpoVersion ?? expoVersion;
|
|
3188
|
+
}
|
|
3162
3189
|
const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
|
|
3163
3190
|
const preactVersion = getPreactVersion(packageJson);
|
|
3164
3191
|
const projectInfo = {
|
|
@@ -3176,6 +3203,7 @@ const discoverProject = (directory) => {
|
|
|
3176
3203
|
preactVersion,
|
|
3177
3204
|
preactMajorVersion: parseReactMajor(preactVersion),
|
|
3178
3205
|
hasReactNativeWorkspace,
|
|
3206
|
+
expoVersion,
|
|
3179
3207
|
hasReanimated,
|
|
3180
3208
|
sourceFileCount
|
|
3181
3209
|
};
|
|
@@ -3265,7 +3293,14 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
3265
3293
|
"tsconfig.json",
|
|
3266
3294
|
"tsconfig.base.json",
|
|
3267
3295
|
"package.json",
|
|
3268
|
-
"
|
|
3296
|
+
"doctor.config.ts",
|
|
3297
|
+
"doctor.config.mts",
|
|
3298
|
+
"doctor.config.cts",
|
|
3299
|
+
"doctor.config.js",
|
|
3300
|
+
"doctor.config.mjs",
|
|
3301
|
+
"doctor.config.cjs",
|
|
3302
|
+
"doctor.config.json",
|
|
3303
|
+
"doctor.config.jsonc",
|
|
3269
3304
|
"oxlint.json",
|
|
3270
3305
|
".oxlintrc.json"
|
|
3271
3306
|
];
|
|
@@ -3280,6 +3315,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
|
3280
3315
|
"Accessibility",
|
|
3281
3316
|
"Maintainability"
|
|
3282
3317
|
];
|
|
3318
|
+
const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
|
|
3319
|
+
const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
|
|
3320
|
+
const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
|
|
3283
3321
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
3284
3322
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
3285
3323
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -3399,10 +3437,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
3399
3437
|
*/
|
|
3400
3438
|
const buildRuleSeverityControls = (config) => {
|
|
3401
3439
|
if (!config) return void 0;
|
|
3402
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
3440
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
3403
3441
|
return {
|
|
3404
3442
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
3405
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
3443
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
3444
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
3406
3445
|
};
|
|
3407
3446
|
};
|
|
3408
3447
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -3766,6 +3805,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
3766
3805
|
}
|
|
3767
3806
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
3768
3807
|
};
|
|
3808
|
+
const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
|
|
3809
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
3810
|
+
const findNearestPackageDirectory = (filename) => {
|
|
3811
|
+
if (!filename) return null;
|
|
3812
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
3813
|
+
if (fromCache !== void 0) return fromCache;
|
|
3814
|
+
let currentDirectory = path.dirname(filename);
|
|
3815
|
+
while (true) {
|
|
3816
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
3817
|
+
let hasPackageJson = false;
|
|
3818
|
+
try {
|
|
3819
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
3820
|
+
} catch {
|
|
3821
|
+
hasPackageJson = false;
|
|
3822
|
+
}
|
|
3823
|
+
if (hasPackageJson) {
|
|
3824
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
3825
|
+
return currentDirectory;
|
|
3826
|
+
}
|
|
3827
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
3828
|
+
if (parentDirectory === currentDirectory) {
|
|
3829
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
3830
|
+
return null;
|
|
3831
|
+
}
|
|
3832
|
+
currentDirectory = parentDirectory;
|
|
3833
|
+
}
|
|
3834
|
+
};
|
|
3835
|
+
const readManifest = (packageJsonPath) => {
|
|
3836
|
+
try {
|
|
3837
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
3838
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
3839
|
+
return null;
|
|
3840
|
+
} catch {
|
|
3841
|
+
return null;
|
|
3842
|
+
}
|
|
3843
|
+
};
|
|
3844
|
+
const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
|
|
3845
|
+
const classifyByDirectoryCohort = (packageDirectory) => {
|
|
3846
|
+
let current = packageDirectory;
|
|
3847
|
+
while (true) {
|
|
3848
|
+
if (path.basename(current) === "apps") return "app";
|
|
3849
|
+
const parent = path.dirname(current);
|
|
3850
|
+
if (parent === current) return null;
|
|
3851
|
+
current = parent;
|
|
3852
|
+
}
|
|
3853
|
+
};
|
|
3854
|
+
const clearPackageRoleCache = () => {
|
|
3855
|
+
cachedRoleByPackageDirectory.clear();
|
|
3856
|
+
cachedPackageDirectoryByFilename.clear();
|
|
3857
|
+
};
|
|
3858
|
+
const classifyPackageRole = (filename) => {
|
|
3859
|
+
if (!filename) return "unknown";
|
|
3860
|
+
const packageDirectory = findNearestPackageDirectory(filename);
|
|
3861
|
+
if (!packageDirectory) return "unknown";
|
|
3862
|
+
const cached = cachedRoleByPackageDirectory.get(packageDirectory);
|
|
3863
|
+
if (cached !== void 0) return cached;
|
|
3864
|
+
const manifest = readManifest(path.join(packageDirectory, "package.json"));
|
|
3865
|
+
let result;
|
|
3866
|
+
if (manifest && hasPublishContract(manifest)) result = "library";
|
|
3867
|
+
else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
|
|
3868
|
+
cachedRoleByPackageDirectory.set(packageDirectory, result);
|
|
3869
|
+
return result;
|
|
3870
|
+
};
|
|
3769
3871
|
/**
|
|
3770
3872
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
3771
3873
|
* accounting for the various shapes oxlint emits:
|
|
@@ -3928,6 +4030,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3928
4030
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
3929
4031
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
3930
4032
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
4033
|
+
const libraryFileCache = /* @__PURE__ */ new Map();
|
|
4034
|
+
const isLibraryFile = (filePath) => {
|
|
4035
|
+
let cached = libraryFileCache.get(filePath);
|
|
4036
|
+
if (cached === void 0) {
|
|
4037
|
+
cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
|
|
4038
|
+
libraryFileCache.set(filePath, cached);
|
|
4039
|
+
}
|
|
4040
|
+
return cached;
|
|
4041
|
+
};
|
|
3931
4042
|
const getFileLines = (filePath) => {
|
|
3932
4043
|
const cached = fileLinesCache.get(filePath);
|
|
3933
4044
|
if (cached !== void 0) return cached;
|
|
@@ -3954,6 +4065,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3954
4065
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
3955
4066
|
return false;
|
|
3956
4067
|
};
|
|
4068
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
4069
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
4070
|
+
return false;
|
|
4071
|
+
};
|
|
3957
4072
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
3958
4073
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
3959
4074
|
if (diagnostic.line <= 0) return false;
|
|
@@ -3968,8 +4083,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3968
4083
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
3969
4084
|
let current = diagnostic;
|
|
3970
4085
|
let explicitSeverityOverride;
|
|
4086
|
+
let explicitRuleOverride;
|
|
3971
4087
|
if (severityControls) {
|
|
3972
4088
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
4089
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
3973
4090
|
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
3974
4091
|
ruleKey,
|
|
3975
4092
|
category
|
|
@@ -3977,6 +4094,9 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3977
4094
|
if (explicitSeverityOverride === "off") return null;
|
|
3978
4095
|
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
3979
4096
|
}
|
|
4097
|
+
if (explicitRuleOverride === void 0) {
|
|
4098
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
4099
|
+
}
|
|
3980
4100
|
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
3981
4101
|
if (userConfig) {
|
|
3982
4102
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
@@ -4162,6 +4282,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
4162
4282
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
4163
4283
|
}).pipe(Effect.orDie));
|
|
4164
4284
|
/**
|
|
4285
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
4286
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
4287
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
4288
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
4289
|
+
*/
|
|
4290
|
+
const resolveScanConcurrency = (requested) => {
|
|
4291
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
4292
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
4293
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
4294
|
+
};
|
|
4295
|
+
/**
|
|
4165
4296
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
4166
4297
|
* startup so the eval harness can raise the budget under sandbox
|
|
4167
4298
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -4181,6 +4312,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
4181
4312
|
* tests that exercise the cap behavior.
|
|
4182
4313
|
*/
|
|
4183
4314
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
4315
|
+
/**
|
|
4316
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
4317
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
4318
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
4319
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
4320
|
+
* CI callers that never touch the flag:
|
|
4321
|
+
*
|
|
4322
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
4323
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
4324
|
+
* - a positive integer → that many workers (clamped)
|
|
4325
|
+
*
|
|
4326
|
+
* The resolved value is always within
|
|
4327
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
4328
|
+
*/
|
|
4329
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
4330
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
4331
|
+
if (raw === void 0) return 1;
|
|
4332
|
+
const normalized = raw.trim().toLowerCase();
|
|
4333
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
4334
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
4335
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
4336
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
4337
|
+
return resolveScanConcurrency(parsed);
|
|
4338
|
+
} }) {};
|
|
4184
4339
|
const DIAGNOSTIC_SURFACES = [
|
|
4185
4340
|
"cli",
|
|
4186
4341
|
"prComment",
|
|
@@ -4337,69 +4492,109 @@ const validateConfigTypes = (config) => {
|
|
|
4337
4492
|
const warn = (message) => {
|
|
4338
4493
|
Effect.runSync(Console.warn(message));
|
|
4339
4494
|
};
|
|
4340
|
-
const
|
|
4495
|
+
const CONFIG_BASENAME = "doctor.config";
|
|
4496
|
+
const CONFIG_EXTENSIONS = [
|
|
4497
|
+
"ts",
|
|
4498
|
+
"mts",
|
|
4499
|
+
"cts",
|
|
4500
|
+
"js",
|
|
4501
|
+
"mjs",
|
|
4502
|
+
"cjs",
|
|
4503
|
+
"json",
|
|
4504
|
+
"jsonc"
|
|
4505
|
+
];
|
|
4506
|
+
const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
|
|
4507
|
+
const PACKAGE_JSON_FILENAME = "package.json";
|
|
4341
4508
|
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);
|
|
4509
|
+
const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
|
|
4510
|
+
const jiti = createJiti(import.meta.url);
|
|
4511
|
+
const formatError = (error) => error instanceof Error ? error.message : String(error);
|
|
4512
|
+
const loadModuleConfig = async (filePath) => {
|
|
4513
|
+
const imported = await jiti.import(filePath);
|
|
4514
|
+
return imported?.default ?? imported;
|
|
4515
|
+
};
|
|
4516
|
+
const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
|
|
4517
|
+
const readEmbeddedPackageJsonConfig = (directory) => {
|
|
4518
|
+
const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
|
|
4519
|
+
if (!isFile(packageJsonPath)) return null;
|
|
4520
|
+
try {
|
|
4521
|
+
const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
4359
4522
|
if (isPlainObject(packageJson)) {
|
|
4360
4523
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
4361
|
-
if (isPlainObject(embeddedConfig)) return
|
|
4362
|
-
|
|
4363
|
-
|
|
4524
|
+
if (isPlainObject(embeddedConfig)) return embeddedConfig;
|
|
4525
|
+
}
|
|
4526
|
+
} catch {}
|
|
4527
|
+
return null;
|
|
4528
|
+
};
|
|
4529
|
+
const loadPackageJsonConfig = (directory) => {
|
|
4530
|
+
const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
|
|
4531
|
+
if (!embeddedConfig) return null;
|
|
4532
|
+
return {
|
|
4533
|
+
config: validateConfigTypes(embeddedConfig),
|
|
4534
|
+
sourceDirectory: directory,
|
|
4535
|
+
configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
|
|
4536
|
+
format: "package-json"
|
|
4537
|
+
};
|
|
4538
|
+
};
|
|
4539
|
+
const loadConfigFromDirectory = async (directory) => {
|
|
4540
|
+
let sawBrokenConfigFile = false;
|
|
4541
|
+
for (const extension of CONFIG_EXTENSIONS) {
|
|
4542
|
+
const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
|
|
4543
|
+
if (!isFile(filePath)) continue;
|
|
4544
|
+
const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
|
|
4545
|
+
try {
|
|
4546
|
+
const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
|
|
4547
|
+
if (isPlainObject(parsed)) return {
|
|
4548
|
+
status: "found",
|
|
4549
|
+
loaded: {
|
|
4550
|
+
config: validateConfigTypes(parsed),
|
|
4551
|
+
sourceDirectory: directory,
|
|
4552
|
+
configFilePath: filePath,
|
|
4553
|
+
format: isDataFile ? "json" : "module"
|
|
4554
|
+
}
|
|
4364
4555
|
};
|
|
4556
|
+
warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
|
|
4557
|
+
sawBrokenConfigFile = true;
|
|
4558
|
+
} catch (error) {
|
|
4559
|
+
warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
|
|
4560
|
+
sawBrokenConfigFile = true;
|
|
4365
4561
|
}
|
|
4366
|
-
} catch {
|
|
4367
|
-
return null;
|
|
4368
4562
|
}
|
|
4369
|
-
|
|
4563
|
+
const packageJsonConfig = loadPackageJsonConfig(directory);
|
|
4564
|
+
if (packageJsonConfig) return {
|
|
4565
|
+
status: "found",
|
|
4566
|
+
loaded: packageJsonConfig
|
|
4567
|
+
};
|
|
4568
|
+
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).`);
|
|
4569
|
+
return {
|
|
4570
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
4571
|
+
loaded: null
|
|
4572
|
+
};
|
|
4370
4573
|
};
|
|
4371
4574
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
4372
4575
|
const clearConfigCache = () => {
|
|
4373
4576
|
cachedConfigs.clear();
|
|
4374
4577
|
};
|
|
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
|
-
}
|
|
4578
|
+
const loadConfigWalkingUp = async (rootDirectory) => {
|
|
4579
|
+
const localResult = await loadConfigFromDirectory(rootDirectory);
|
|
4580
|
+
if (localResult.status === "found") return localResult.loaded;
|
|
4581
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
|
|
4387
4582
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
4388
4583
|
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
|
-
}
|
|
4584
|
+
const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
|
|
4585
|
+
if (ancestorResult.status === "found") return ancestorResult.loaded;
|
|
4586
|
+
if (isProjectBoundary(ancestorDirectory)) return null;
|
|
4398
4587
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
4399
4588
|
}
|
|
4400
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4401
4589
|
return null;
|
|
4402
4590
|
};
|
|
4591
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
4592
|
+
const cached = cachedConfigs.get(rootDirectory);
|
|
4593
|
+
if (cached !== void 0) return cached;
|
|
4594
|
+
const loadPromise = loadConfigWalkingUp(rootDirectory);
|
|
4595
|
+
cachedConfigs.set(rootDirectory, loadPromise);
|
|
4596
|
+
return loadPromise;
|
|
4597
|
+
};
|
|
4403
4598
|
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
4404
4599
|
if (!config || !configSourceDirectory) return null;
|
|
4405
4600
|
const rawRootDir = config.rootDir;
|
|
@@ -4414,11 +4609,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
|
4414
4609
|
}
|
|
4415
4610
|
return resolvedRootDir;
|
|
4416
4611
|
};
|
|
4417
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
4612
|
+
const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
4418
4613
|
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
4419
4614
|
const reactSubprojects = discoverReactSubprojects(directory);
|
|
4420
4615
|
if (reactSubprojects.length === 0) return null;
|
|
4421
4616
|
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
4617
|
+
if (options.allowAmbiguous === true) return null;
|
|
4422
4618
|
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
4423
4619
|
};
|
|
4424
4620
|
/**
|
|
@@ -4426,13 +4622,13 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4426
4622
|
* (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
|
|
4427
4623
|
*
|
|
4428
4624
|
* 1. Resolve the requested directory to absolute.
|
|
4429
|
-
* 2. Load `
|
|
4430
|
-
* if present.
|
|
4625
|
+
* 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
|
|
4431
4626
|
* 3. Honor `config.rootDir` to redirect the scan to a nested
|
|
4432
4627
|
* project root, if configured.
|
|
4433
4628
|
* 4. Walk into a nested React subproject when the requested
|
|
4434
4629
|
* directory has no `package.json` of its own (raises
|
|
4435
|
-
* `AmbiguousProjectError` when multiple candidates exist
|
|
4630
|
+
* `AmbiguousProjectError` when multiple candidates exist unless
|
|
4631
|
+
* the caller opts into keeping the wrapper directory).
|
|
4436
4632
|
*
|
|
4437
4633
|
* Throws `ProjectNotFoundError` when neither the requested directory
|
|
4438
4634
|
* nor any discoverable nested project has a `package.json`.
|
|
@@ -4444,14 +4640,14 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4444
4640
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
4445
4641
|
* shell in agreement on what "the scan directory" means.
|
|
4446
4642
|
*/
|
|
4447
|
-
const resolveScanTarget = (requestedDirectory) => {
|
|
4643
|
+
const resolveScanTarget = async (requestedDirectory, options = {}) => {
|
|
4448
4644
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
4449
|
-
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
4645
|
+
const loadedConfig = await loadConfigWithSource(absoluteRequested);
|
|
4450
4646
|
const userConfig = loadedConfig?.config ?? null;
|
|
4451
4647
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
4452
4648
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
4453
4649
|
const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
|
|
4454
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
|
|
4650
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
|
|
4455
4651
|
if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
|
|
4456
4652
|
return {
|
|
4457
4653
|
resolvedDirectory,
|
|
@@ -4461,6 +4657,359 @@ const resolveScanTarget = (requestedDirectory) => {
|
|
|
4461
4657
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
4462
4658
|
};
|
|
4463
4659
|
};
|
|
4660
|
+
const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
|
|
4661
|
+
const buildExpoCheckContext = (rootDirectory, expoVersion) => {
|
|
4662
|
+
const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
|
|
4663
|
+
return {
|
|
4664
|
+
rootDirectory,
|
|
4665
|
+
packageJson,
|
|
4666
|
+
directDependencyNames: getDirectDependencyNames(packageJson),
|
|
4667
|
+
expoSdkMajor: getLowestDependencyMajor(expoVersion)
|
|
4668
|
+
};
|
|
4669
|
+
};
|
|
4670
|
+
const buildExpoDiagnostic = (input) => ({
|
|
4671
|
+
filePath: input.filePath ?? "package.json",
|
|
4672
|
+
plugin: "react-doctor",
|
|
4673
|
+
rule: input.rule,
|
|
4674
|
+
severity: input.severity ?? "warning",
|
|
4675
|
+
message: input.message,
|
|
4676
|
+
help: input.help,
|
|
4677
|
+
line: input.line ?? 0,
|
|
4678
|
+
column: input.column ?? 0,
|
|
4679
|
+
category: input.category ?? "Correctness"
|
|
4680
|
+
});
|
|
4681
|
+
const CRITICAL_OVERRIDE_NAMES = new Set([
|
|
4682
|
+
"@expo/cli",
|
|
4683
|
+
"@expo/config",
|
|
4684
|
+
"@expo/metro-config",
|
|
4685
|
+
"@expo/metro-runtime",
|
|
4686
|
+
"@expo/metro",
|
|
4687
|
+
"metro"
|
|
4688
|
+
]);
|
|
4689
|
+
const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
|
|
4690
|
+
const collectOverrideNames = (packageJson) => new Set([
|
|
4691
|
+
...Object.keys(packageJson.overrides ?? {}),
|
|
4692
|
+
...Object.keys(packageJson.resolutions ?? {}),
|
|
4693
|
+
...Object.keys(packageJson.pnpm?.overrides ?? {})
|
|
4694
|
+
]);
|
|
4695
|
+
const checkExpoDependencyOverrides = (context) => {
|
|
4696
|
+
const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
|
|
4697
|
+
if (overriddenCriticalNames.length === 0) return [];
|
|
4698
|
+
const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
|
|
4699
|
+
return [buildExpoDiagnostic({
|
|
4700
|
+
rule: "expo-no-conflicting-dependency-override",
|
|
4701
|
+
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`,
|
|
4702
|
+
help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
|
|
4703
|
+
})];
|
|
4704
|
+
};
|
|
4705
|
+
const isPathGitIgnored = (rootDirectory, absolutePath) => {
|
|
4706
|
+
const result = spawnSync("git", [
|
|
4707
|
+
"check-ignore",
|
|
4708
|
+
"-q",
|
|
4709
|
+
absolutePath
|
|
4710
|
+
], {
|
|
4711
|
+
cwd: rootDirectory,
|
|
4712
|
+
stdio: [
|
|
4713
|
+
"ignore",
|
|
4714
|
+
"ignore",
|
|
4715
|
+
"ignore"
|
|
4716
|
+
]
|
|
4717
|
+
});
|
|
4718
|
+
if (result.error) return null;
|
|
4719
|
+
if (result.status === 0) return true;
|
|
4720
|
+
if (result.status === 1) return false;
|
|
4721
|
+
return null;
|
|
4722
|
+
};
|
|
4723
|
+
const LOCAL_ENV_FILE_NAMES = [
|
|
4724
|
+
".env.local",
|
|
4725
|
+
".env.development.local",
|
|
4726
|
+
".env.production.local",
|
|
4727
|
+
".env.test.local"
|
|
4728
|
+
];
|
|
4729
|
+
const checkExpoEnvLocalFiles = (context) => {
|
|
4730
|
+
const { rootDirectory } = context;
|
|
4731
|
+
const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
|
|
4732
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
4733
|
+
if (!isFile(filePath)) return false;
|
|
4734
|
+
return isPathGitIgnored(rootDirectory, filePath) === false;
|
|
4735
|
+
});
|
|
4736
|
+
if (committedEnvFiles.length === 0) return [];
|
|
4737
|
+
return [buildExpoDiagnostic({
|
|
4738
|
+
rule: "expo-env-local-not-gitignored",
|
|
4739
|
+
category: "Security",
|
|
4740
|
+
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`,
|
|
4741
|
+
help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
|
|
4742
|
+
})];
|
|
4743
|
+
};
|
|
4744
|
+
const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
|
|
4745
|
+
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";
|
|
4746
|
+
const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
|
|
4747
|
+
const unimodulesEntry = (packageName) => ({
|
|
4748
|
+
packageName,
|
|
4749
|
+
rule: "expo-no-unimodules-packages",
|
|
4750
|
+
message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
|
|
4751
|
+
help: UNIMODULES_HELP
|
|
4752
|
+
});
|
|
4753
|
+
const FLAGGED_DEPENDENCIES = [
|
|
4754
|
+
unimodulesEntry("@unimodules/core"),
|
|
4755
|
+
unimodulesEntry("@unimodules/react-native-adapter"),
|
|
4756
|
+
unimodulesEntry("react-native-unimodules"),
|
|
4757
|
+
{
|
|
4758
|
+
packageName: "expo-cli",
|
|
4759
|
+
rule: "expo-no-cli-dependencies",
|
|
4760
|
+
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`",
|
|
4761
|
+
help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
|
|
4762
|
+
},
|
|
4763
|
+
{
|
|
4764
|
+
packageName: "eas-cli",
|
|
4765
|
+
rule: "expo-no-cli-dependencies",
|
|
4766
|
+
message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
|
|
4767
|
+
help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
|
|
4768
|
+
},
|
|
4769
|
+
{
|
|
4770
|
+
packageName: "expo-modules-autolinking",
|
|
4771
|
+
rule: "expo-no-redundant-dependency",
|
|
4772
|
+
message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
|
|
4773
|
+
help: "Remove `expo-modules-autolinking` from your package.json"
|
|
4774
|
+
},
|
|
4775
|
+
{
|
|
4776
|
+
packageName: "expo-dev-launcher",
|
|
4777
|
+
rule: "expo-no-redundant-dependency",
|
|
4778
|
+
message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
4779
|
+
help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
|
|
4780
|
+
},
|
|
4781
|
+
{
|
|
4782
|
+
packageName: "expo-dev-menu",
|
|
4783
|
+
rule: "expo-no-redundant-dependency",
|
|
4784
|
+
message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
4785
|
+
help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
|
|
4786
|
+
},
|
|
4787
|
+
{
|
|
4788
|
+
packageName: "expo-modules-core",
|
|
4789
|
+
rule: "expo-no-redundant-dependency",
|
|
4790
|
+
message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
|
|
4791
|
+
help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
|
|
4792
|
+
},
|
|
4793
|
+
{
|
|
4794
|
+
packageName: "@expo/metro-config",
|
|
4795
|
+
rule: "expo-no-redundant-dependency",
|
|
4796
|
+
message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
|
|
4797
|
+
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
|
|
4798
|
+
},
|
|
4799
|
+
{
|
|
4800
|
+
packageName: "@types/react-native",
|
|
4801
|
+
rule: "expo-no-redundant-dependency",
|
|
4802
|
+
message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
|
|
4803
|
+
help: "Remove `@types/react-native` from your package.json",
|
|
4804
|
+
minSdkMajor: 48
|
|
4805
|
+
},
|
|
4806
|
+
{
|
|
4807
|
+
packageName: "@expo/config-plugins",
|
|
4808
|
+
rule: "expo-no-redundant-dependency",
|
|
4809
|
+
message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
|
|
4810
|
+
help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
|
|
4811
|
+
minSdkMajor: 48
|
|
4812
|
+
},
|
|
4813
|
+
{
|
|
4814
|
+
packageName: "@expo/prebuild-config",
|
|
4815
|
+
rule: "expo-no-redundant-dependency",
|
|
4816
|
+
message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
|
|
4817
|
+
help: "Remove `@expo/prebuild-config` from your package.json",
|
|
4818
|
+
minSdkMajor: 53
|
|
4819
|
+
},
|
|
4820
|
+
{
|
|
4821
|
+
packageName: "expo-permissions",
|
|
4822
|
+
rule: "expo-no-redundant-dependency",
|
|
4823
|
+
message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
|
|
4824
|
+
help: "Remove `expo-permissions` and request permissions from the relevant module instead",
|
|
4825
|
+
minSdkMajor: 50
|
|
4826
|
+
},
|
|
4827
|
+
{
|
|
4828
|
+
packageName: "expo-app-loading",
|
|
4829
|
+
rule: "expo-no-redundant-dependency",
|
|
4830
|
+
message: "\"expo-app-loading\" was removed in SDK 49",
|
|
4831
|
+
help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
|
|
4832
|
+
minSdkMajor: 49
|
|
4833
|
+
},
|
|
4834
|
+
{
|
|
4835
|
+
packageName: "expo-firebase-analytics",
|
|
4836
|
+
rule: "expo-no-redundant-dependency",
|
|
4837
|
+
message: "\"expo-firebase-analytics\" was removed in SDK 48",
|
|
4838
|
+
help: FIREBASE_HELP,
|
|
4839
|
+
minSdkMajor: 48
|
|
4840
|
+
},
|
|
4841
|
+
{
|
|
4842
|
+
packageName: "expo-firebase-recaptcha",
|
|
4843
|
+
rule: "expo-no-redundant-dependency",
|
|
4844
|
+
message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
|
|
4845
|
+
help: FIREBASE_HELP,
|
|
4846
|
+
minSdkMajor: 48
|
|
4847
|
+
},
|
|
4848
|
+
{
|
|
4849
|
+
packageName: "expo-firebase-core",
|
|
4850
|
+
rule: "expo-no-redundant-dependency",
|
|
4851
|
+
message: "\"expo-firebase-core\" was removed in SDK 48",
|
|
4852
|
+
help: FIREBASE_HELP,
|
|
4853
|
+
minSdkMajor: 48
|
|
4854
|
+
}
|
|
4855
|
+
];
|
|
4856
|
+
const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
|
|
4857
|
+
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
|
|
4858
|
+
if (flaggedDependency.minSdkMajor === void 0) return true;
|
|
4859
|
+
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
|
|
4860
|
+
}).map((flaggedDependency) => buildExpoDiagnostic({
|
|
4861
|
+
rule: flaggedDependency.rule,
|
|
4862
|
+
message: flaggedDependency.message,
|
|
4863
|
+
help: flaggedDependency.help
|
|
4864
|
+
}));
|
|
4865
|
+
const findLocalModuleNativeFiles = (rootDirectory) => {
|
|
4866
|
+
const modulesDirectory = path.join(rootDirectory, "modules");
|
|
4867
|
+
if (!isDirectory(modulesDirectory)) return [];
|
|
4868
|
+
const nativeFilePaths = [];
|
|
4869
|
+
for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
|
|
4870
|
+
if (!moduleEntry.isDirectory()) continue;
|
|
4871
|
+
const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
|
|
4872
|
+
const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
|
|
4873
|
+
if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
|
|
4874
|
+
const iosDirectory = path.join(moduleDirectory, "ios");
|
|
4875
|
+
if (isDirectory(iosDirectory)) {
|
|
4876
|
+
for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
return nativeFilePaths;
|
|
4880
|
+
};
|
|
4881
|
+
const checkExpoGitignore = (context) => {
|
|
4882
|
+
const { rootDirectory } = context;
|
|
4883
|
+
const diagnostics = [];
|
|
4884
|
+
const expoStateDirectory = path.join(rootDirectory, ".expo");
|
|
4885
|
+
if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
|
|
4886
|
+
rule: "expo-gitignore",
|
|
4887
|
+
message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
|
|
4888
|
+
help: "Add `.expo/` to your .gitignore"
|
|
4889
|
+
}));
|
|
4890
|
+
if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
|
|
4891
|
+
rule: "expo-gitignore",
|
|
4892
|
+
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",
|
|
4893
|
+
help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
|
|
4894
|
+
}));
|
|
4895
|
+
return diagnostics;
|
|
4896
|
+
};
|
|
4897
|
+
const LOCKFILE_NAMES = [
|
|
4898
|
+
"pnpm-lock.yaml",
|
|
4899
|
+
"yarn.lock",
|
|
4900
|
+
"package-lock.json",
|
|
4901
|
+
"bun.lockb",
|
|
4902
|
+
"bun.lock"
|
|
4903
|
+
];
|
|
4904
|
+
const checkExpoLockfile = (context) => {
|
|
4905
|
+
const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
|
|
4906
|
+
const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
|
|
4907
|
+
if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
|
|
4908
|
+
rule: "expo-lockfile",
|
|
4909
|
+
message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
|
|
4910
|
+
help: "Install dependencies with your package manager to generate a lock file, then commit it"
|
|
4911
|
+
})];
|
|
4912
|
+
if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
|
|
4913
|
+
rule: "expo-lockfile",
|
|
4914
|
+
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`,
|
|
4915
|
+
help: "Delete the lock files for the package managers you are not using and keep only one"
|
|
4916
|
+
})];
|
|
4917
|
+
return [];
|
|
4918
|
+
};
|
|
4919
|
+
const METRO_CONFIG_FILE_NAMES = [
|
|
4920
|
+
"metro.config.js",
|
|
4921
|
+
"metro.config.cjs",
|
|
4922
|
+
"metro.config.mjs",
|
|
4923
|
+
"metro.config.ts"
|
|
4924
|
+
];
|
|
4925
|
+
const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
|
|
4926
|
+
"expo/metro-config",
|
|
4927
|
+
"@sentry/react-native/metro",
|
|
4928
|
+
"getSentryExpoConfig"
|
|
4929
|
+
];
|
|
4930
|
+
const checkExpoMetroConfig = (context) => {
|
|
4931
|
+
const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
|
|
4932
|
+
if (metroConfigPath === void 0) return [];
|
|
4933
|
+
let contents;
|
|
4934
|
+
try {
|
|
4935
|
+
contents = fs.readFileSync(metroConfigPath, "utf-8");
|
|
4936
|
+
} catch {
|
|
4937
|
+
return [];
|
|
4938
|
+
}
|
|
4939
|
+
if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
|
|
4940
|
+
return [buildExpoDiagnostic({
|
|
4941
|
+
rule: "expo-metro-config",
|
|
4942
|
+
filePath: path.basename(metroConfigPath),
|
|
4943
|
+
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",
|
|
4944
|
+
help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
|
|
4945
|
+
})];
|
|
4946
|
+
};
|
|
4947
|
+
const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
|
|
4948
|
+
const checkExpoPackageJsonConflicts = (context) => {
|
|
4949
|
+
const { packageJson } = context;
|
|
4950
|
+
const diagnostics = [];
|
|
4951
|
+
const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
|
|
4952
|
+
if (conflictingScriptNames.length > 0) {
|
|
4953
|
+
const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
|
|
4954
|
+
const shadowsExpoCli = conflictingScriptNames.includes("expo");
|
|
4955
|
+
diagnostics.push(buildExpoDiagnostic({
|
|
4956
|
+
rule: "expo-package-json-conflict",
|
|
4957
|
+
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" : ""}`,
|
|
4958
|
+
help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
|
|
4959
|
+
}));
|
|
4960
|
+
}
|
|
4961
|
+
const packageName = packageJson.name;
|
|
4962
|
+
if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
|
|
4963
|
+
rule: "expo-package-json-conflict",
|
|
4964
|
+
message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
|
|
4965
|
+
help: "Rename your package so it no longer matches one of its dependencies"
|
|
4966
|
+
}));
|
|
4967
|
+
return diagnostics;
|
|
4968
|
+
};
|
|
4969
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
4970
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
4971
|
+
const checkExpoRouterReactNavigation = (context) => {
|
|
4972
|
+
const { expoSdkMajor } = context;
|
|
4973
|
+
if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
|
|
4974
|
+
if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
|
|
4975
|
+
if (!context.directDependencyNames.has("expo-router")) return [];
|
|
4976
|
+
const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
|
|
4977
|
+
if (reactNavigationNames.length === 0) return [];
|
|
4978
|
+
return [buildExpoDiagnostic({
|
|
4979
|
+
rule: "expo-router-no-react-navigation",
|
|
4980
|
+
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"}`,
|
|
4981
|
+
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/"
|
|
4982
|
+
})];
|
|
4983
|
+
};
|
|
4984
|
+
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
4985
|
+
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
4986
|
+
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
4987
|
+
const checkExpoVectorIcons = (context) => {
|
|
4988
|
+
if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
|
|
4989
|
+
const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
|
|
4990
|
+
const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
|
|
4991
|
+
if (!hasScopedPackage || !hasConflictingPackage) return [];
|
|
4992
|
+
return [buildExpoDiagnostic({
|
|
4993
|
+
rule: "expo-vector-icons-conflict",
|
|
4994
|
+
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",
|
|
4995
|
+
help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
|
|
4996
|
+
})];
|
|
4997
|
+
};
|
|
4998
|
+
const checkExpoProject = (rootDirectory, project) => {
|
|
4999
|
+
if (project.expoVersion === null) return [];
|
|
5000
|
+
const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
|
|
5001
|
+
return [
|
|
5002
|
+
...checkExpoFlaggedDependencies(context),
|
|
5003
|
+
...checkExpoDependencyOverrides(context),
|
|
5004
|
+
...checkExpoRouterReactNavigation(context),
|
|
5005
|
+
...checkExpoVectorIcons(context),
|
|
5006
|
+
...checkExpoPackageJsonConflicts(context),
|
|
5007
|
+
...checkExpoLockfile(context),
|
|
5008
|
+
...checkExpoGitignore(context),
|
|
5009
|
+
...checkExpoEnvLocalFiles(context),
|
|
5010
|
+
...checkExpoMetroConfig(context)
|
|
5011
|
+
];
|
|
5012
|
+
};
|
|
4464
5013
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
4465
5014
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
4466
5015
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -4711,6 +5260,28 @@ const collectIgnorePatterns = (rootDirectory) => {
|
|
|
4711
5260
|
cachedPatternsByRoot.set(rootDirectory, patterns);
|
|
4712
5261
|
return patterns;
|
|
4713
5262
|
};
|
|
5263
|
+
/**
|
|
5264
|
+
* Resolves a path to its canonical, symlink-free form, falling back to
|
|
5265
|
+
* the input when it cannot be realpath'd (broken symlink, permission
|
|
5266
|
+
* error) so a best-effort normalization never throws.
|
|
5267
|
+
*
|
|
5268
|
+
* deslop's dead-code module graph is collected with `fast-glob` (which
|
|
5269
|
+
* keeps the scan root's symlinks intact) while imports are resolved
|
|
5270
|
+
* through `oxc-resolver` (which returns realpath'd targets). When the
|
|
5271
|
+
* project root sits behind a symlink — e.g. macOS iCloud-synced
|
|
5272
|
+
* `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
|
|
5273
|
+
* spaces diverge: every resolved import misses the graph and the files
|
|
5274
|
+
* they point at (commonly every `@/…` alias target) are mis-reported as
|
|
5275
|
+
* unreachable. Canonicalizing the root before the scan keeps both path
|
|
5276
|
+
* spaces in agreement.
|
|
5277
|
+
*/
|
|
5278
|
+
const toCanonicalPath = (filePath) => {
|
|
5279
|
+
try {
|
|
5280
|
+
return fs.realpathSync(filePath);
|
|
5281
|
+
} catch {
|
|
5282
|
+
return filePath;
|
|
5283
|
+
}
|
|
5284
|
+
};
|
|
4714
5285
|
const DEAD_CODE_PLUGIN = "deslop";
|
|
4715
5286
|
const DEAD_CODE_CATEGORY = "Maintainability";
|
|
4716
5287
|
const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
|
|
@@ -4967,7 +5538,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
|
|
|
4967
5538
|
});
|
|
4968
5539
|
});
|
|
4969
5540
|
const checkDeadCode = async (options) => {
|
|
4970
|
-
const {
|
|
5541
|
+
const { userConfig } = options;
|
|
5542
|
+
const rootDirectory = toCanonicalPath(options.rootDirectory);
|
|
4971
5543
|
if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
|
|
4972
5544
|
const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
|
|
4973
5545
|
const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
|
|
@@ -5120,8 +5692,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
|
5120
5692
|
const cache = yield* Cache.make({
|
|
5121
5693
|
capacity: 16,
|
|
5122
5694
|
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
5123
|
-
lookup: (directory) => Effect.
|
|
5124
|
-
const loaded = loadConfigWithSource(directory);
|
|
5695
|
+
lookup: (directory) => Effect.promise(async () => {
|
|
5696
|
+
const loaded = await loadConfigWithSource(directory);
|
|
5125
5697
|
const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
|
|
5126
5698
|
return {
|
|
5127
5699
|
config: loaded?.config ?? null,
|
|
@@ -5718,6 +6290,7 @@ const buildCapabilities = (project) => {
|
|
|
5718
6290
|
const capabilities = /* @__PURE__ */ new Set();
|
|
5719
6291
|
capabilities.add(project.framework);
|
|
5720
6292
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
6293
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
5721
6294
|
const reactMajor = project.reactMajorVersion;
|
|
5722
6295
|
if (reactMajor !== null) {
|
|
5723
6296
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -5889,10 +6462,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
5889
6462
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
5890
6463
|
return fs.realpathSync(rootDirectory);
|
|
5891
6464
|
};
|
|
6465
|
+
const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
|
|
6466
|
+
if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
|
|
6467
|
+
return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
6468
|
+
};
|
|
5892
6469
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
5893
6470
|
const enabledRules = {};
|
|
5894
6471
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
5895
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
6472
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
5896
6473
|
if (severity === "off") continue;
|
|
5897
6474
|
enabledRules[ruleKey] = severity;
|
|
5898
6475
|
}
|
|
@@ -5934,7 +6511,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
5934
6511
|
category: rule.category
|
|
5935
6512
|
}, severityControls);
|
|
5936
6513
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
5937
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
6514
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
5938
6515
|
if (severity === "off") continue;
|
|
5939
6516
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
5940
6517
|
}
|
|
@@ -5991,6 +6568,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
5991
6568
|
}
|
|
5992
6569
|
return uniqueDiagnostics;
|
|
5993
6570
|
};
|
|
6571
|
+
/**
|
|
6572
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
6573
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
6574
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
6575
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
6576
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
6577
|
+
* batches do).
|
|
6578
|
+
*
|
|
6579
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
6580
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
6581
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
6582
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
6583
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
6584
|
+
* top of a still-running first one.
|
|
6585
|
+
*/
|
|
6586
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
6587
|
+
const results = new Array(items.length);
|
|
6588
|
+
if (items.length === 0) return results;
|
|
6589
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
6590
|
+
let nextIndex = 0;
|
|
6591
|
+
const errors = [];
|
|
6592
|
+
const runWorker = async () => {
|
|
6593
|
+
while (errors.length === 0) {
|
|
6594
|
+
const index = nextIndex;
|
|
6595
|
+
nextIndex += 1;
|
|
6596
|
+
if (index >= items.length) return;
|
|
6597
|
+
try {
|
|
6598
|
+
results[index] = await task(items[index], index);
|
|
6599
|
+
} catch (error) {
|
|
6600
|
+
errors.push(error);
|
|
6601
|
+
return;
|
|
6602
|
+
}
|
|
6603
|
+
}
|
|
6604
|
+
};
|
|
6605
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
6606
|
+
if (errors.length > 0) throw errors[0];
|
|
6607
|
+
return results;
|
|
6608
|
+
};
|
|
5994
6609
|
const getPublicEnvPrefix = (framework) => {
|
|
5995
6610
|
switch (framework) {
|
|
5996
6611
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -6673,6 +7288,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
6673
7288
|
*/
|
|
6674
7289
|
const spawnLintBatches = async (input) => {
|
|
6675
7290
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
7291
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
6676
7292
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
6677
7293
|
const allDiagnostics = [];
|
|
6678
7294
|
const droppedFiles = [];
|
|
@@ -6692,23 +7308,31 @@ const spawnLintBatches = async (input) => {
|
|
|
6692
7308
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
6693
7309
|
}
|
|
6694
7310
|
};
|
|
7311
|
+
let startedFileCount = 0;
|
|
6695
7312
|
let scannedFileCount = 0;
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
const
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
7313
|
+
let displayedFileCount = 0;
|
|
7314
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
7315
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
7316
|
+
if (displayedFileCount < ceiling) {
|
|
7317
|
+
displayedFileCount += 1;
|
|
7318
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
7319
|
+
}
|
|
7320
|
+
}, 50) : null;
|
|
7321
|
+
progressTimer?.unref?.();
|
|
7322
|
+
try {
|
|
7323
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
7324
|
+
startedFileCount += batch.length;
|
|
6705
7325
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
6706
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
6707
7326
|
scannedFileCount += batch.length;
|
|
6708
|
-
onFileProgress
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
7327
|
+
if (onFileProgress) {
|
|
7328
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
7329
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
7330
|
+
}
|
|
7331
|
+
return batchDiagnostics;
|
|
7332
|
+
});
|
|
7333
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
7334
|
+
} finally {
|
|
7335
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
6712
7336
|
}
|
|
6713
7337
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
6714
7338
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -6835,7 +7459,8 @@ const runOxlint = async (options) => {
|
|
|
6835
7459
|
onPartialFailure,
|
|
6836
7460
|
onFileProgress: options.onFileProgress,
|
|
6837
7461
|
spawnTimeoutMs,
|
|
6838
|
-
outputMaxBytes
|
|
7462
|
+
outputMaxBytes,
|
|
7463
|
+
concurrency: options.concurrency
|
|
6839
7464
|
});
|
|
6840
7465
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
6841
7466
|
try {
|
|
@@ -6903,6 +7528,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6903
7528
|
const partialFailures = yield* LintPartialFailures;
|
|
6904
7529
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
6905
7530
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
7531
|
+
const concurrency = yield* OxlintConcurrency;
|
|
6906
7532
|
const collectedFailures = [];
|
|
6907
7533
|
const diagnostics = yield* Effect.tryPromise({
|
|
6908
7534
|
try: () => runOxlint({
|
|
@@ -6921,7 +7547,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6921
7547
|
},
|
|
6922
7548
|
onFileProgress: input.onFileProgress,
|
|
6923
7549
|
spawnTimeoutMs,
|
|
6924
|
-
outputMaxBytes
|
|
7550
|
+
outputMaxBytes,
|
|
7551
|
+
concurrency
|
|
6925
7552
|
}),
|
|
6926
7553
|
catch: ensureReactDoctorError
|
|
6927
7554
|
});
|
|
@@ -7245,7 +7872,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7245
7872
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
7246
7873
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
7247
7874
|
const isDiffMode = input.includePaths.length > 0;
|
|
7248
|
-
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ??
|
|
7875
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
7249
7876
|
const transform = buildDiagnosticPipeline({
|
|
7250
7877
|
rootDirectory: scanDirectory,
|
|
7251
7878
|
userConfig: resolvedConfig.config,
|
|
@@ -7254,7 +7881,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7254
7881
|
showWarnings
|
|
7255
7882
|
});
|
|
7256
7883
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
7257
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7884
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7885
|
+
...checkReducedMotion(scanDirectory),
|
|
7886
|
+
...checkPnpmHardening(scanDirectory),
|
|
7887
|
+
...checkExpoProject(scanDirectory, project)
|
|
7888
|
+
];
|
|
7258
7889
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
7259
7890
|
const lintFailure = yield* Ref.make({
|
|
7260
7891
|
didFail: false,
|
|
@@ -7266,6 +7897,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7266
7897
|
didFail: false,
|
|
7267
7898
|
reason: null
|
|
7268
7899
|
});
|
|
7900
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
7901
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
7269
7902
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
7270
7903
|
const scanStartTime = Date.now();
|
|
7271
7904
|
let lastReportedTotalFileCount = 0;
|
|
@@ -7282,7 +7915,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7282
7915
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
7283
7916
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
7284
7917
|
lastReportedTotalFileCount = totalFileCount;
|
|
7285
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
7918
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
7286
7919
|
}
|
|
7287
7920
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
7288
7921
|
yield* Ref.set(lintFailure, {
|
|
@@ -7314,7 +7947,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7314
7947
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
7315
7948
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
7316
7949
|
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
7317
|
-
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
|
|
7950
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
7318
7951
|
yield* reporterService.finalize;
|
|
7319
7952
|
const finalDiagnostics = [
|
|
7320
7953
|
...envCollected,
|
|
@@ -7366,7 +7999,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7366
7999
|
"inspect.isCi": input.isCi,
|
|
7367
8000
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
7368
8001
|
} }));
|
|
7369
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
7370
8002
|
const parseNodeVersion = (versionString) => {
|
|
7371
8003
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
7372
8004
|
return {
|
|
@@ -7665,6 +8297,26 @@ const buildJsonReport = (input) => {
|
|
|
7665
8297
|
};
|
|
7666
8298
|
};
|
|
7667
8299
|
/**
|
|
8300
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
8301
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
8302
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
8303
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
8304
|
+
* branch logic lives here once.
|
|
8305
|
+
*/
|
|
8306
|
+
const buildSkippedChecks = (input) => {
|
|
8307
|
+
const skippedChecks = [];
|
|
8308
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
8309
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
8310
|
+
const skippedCheckReasons = {};
|
|
8311
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
8312
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
8313
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
8314
|
+
return {
|
|
8315
|
+
skippedChecks,
|
|
8316
|
+
skippedCheckReasons
|
|
8317
|
+
};
|
|
8318
|
+
};
|
|
8319
|
+
/**
|
|
7668
8320
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
7669
8321
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
7670
8322
|
* spawn, not `spawnSync`).
|
|
@@ -7770,7 +8422,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
|
|
|
7770
8422
|
const clearAutoSuppressionCaches = () => {};
|
|
7771
8423
|
//#endregion
|
|
7772
8424
|
//#region ../api/dist/index.js
|
|
7773
|
-
const
|
|
8425
|
+
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);
|
|
7774
8426
|
const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
7775
8427
|
const effectiveConfig = configOverride ?? scanTarget.userConfig;
|
|
7776
8428
|
const includePaths = options.includePaths ?? [];
|
|
@@ -7779,7 +8431,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7779
8431
|
includePaths,
|
|
7780
8432
|
customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
|
|
7781
8433
|
respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
|
|
7782
|
-
warnings: options.warnings ?? effectiveConfig?.warnings ??
|
|
8434
|
+
warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
|
|
7783
8435
|
adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
|
|
7784
8436
|
ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
|
|
7785
8437
|
runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
|
|
@@ -7789,13 +8441,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7789
8441
|
};
|
|
7790
8442
|
const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
7791
8443
|
if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
|
|
7792
|
-
const skippedChecks =
|
|
7793
|
-
if (output.didLintFail) skippedChecks.push("lint");
|
|
7794
|
-
if (output.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
7795
|
-
const skippedCheckReasons = {};
|
|
7796
|
-
if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
|
|
7797
|
-
else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
|
|
7798
|
-
if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
|
|
8444
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
|
|
7799
8445
|
return {
|
|
7800
8446
|
diagnostics: [...output.diagnostics],
|
|
7801
8447
|
score: output.score,
|
|
@@ -7807,8 +8453,8 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
|
7807
8453
|
};
|
|
7808
8454
|
const diagnose = async (directory, options = {}) => {
|
|
7809
8455
|
const startTime = globalThis.performance.now();
|
|
7810
|
-
const program = buildInspectProgram(resolveScanTarget(directory), options);
|
|
7811
|
-
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(
|
|
8456
|
+
const program = buildInspectProgram(await resolveScanTarget(directory), options);
|
|
8457
|
+
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
|
|
7812
8458
|
};
|
|
7813
8459
|
//#endregion
|
|
7814
8460
|
//#region src/index.ts
|
|
@@ -7817,6 +8463,7 @@ const clearCaches = () => {
|
|
|
7817
8463
|
clearConfigCache();
|
|
7818
8464
|
clearPackageJsonCache();
|
|
7819
8465
|
clearIgnorePatternsCache();
|
|
8466
|
+
clearPackageRoleCache();
|
|
7820
8467
|
clearAutoSuppressionCaches();
|
|
7821
8468
|
};
|
|
7822
8469
|
const toJsonReport = (result, options) => buildJsonReport({
|
|
@@ -7840,4 +8487,5 @@ const toJsonReport = (result, options) => buildJsonReport({
|
|
|
7840
8487
|
//#endregion
|
|
7841
8488
|
export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
|
|
7842
8489
|
|
|
7843
|
-
//# sourceMappingURL=index.js.map
|
|
8490
|
+
//# sourceMappingURL=index.js.map
|
|
8491
|
+
//# debugId=fce73b02-d297-5132-af08-817f37e1467c
|