react-doctor 0.2.14-dev.8b313ba → 0.2.14-dev.938376
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/cli.js +1151 -152
- package/dist/index.d.ts +45 -7
- package/dist/index.js +661 -76
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ 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";
|
|
18
19
|
import * as Fiber from "effect/Fiber";
|
|
19
20
|
import * as Filter from "effect/Filter";
|
|
@@ -26,7 +27,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
|
|
|
26
27
|
import * as NodePath from "@effect/platform-node-shared/NodePath";
|
|
27
28
|
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
28
29
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
29
|
-
import os from "node:os";
|
|
30
30
|
import * as ts from "typescript";
|
|
31
31
|
import { gzipSync } from "node:zlib";
|
|
32
32
|
//#region \0rolldown/runtime.js
|
|
@@ -2874,29 +2874,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
2874
2874
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
2875
2875
|
};
|
|
2876
2876
|
};
|
|
2877
|
-
const
|
|
2878
|
-
|
|
2877
|
+
const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
|
|
2878
|
+
const rootValue = select(rootPackageJson);
|
|
2879
|
+
if (rootValue !== null) return rootValue;
|
|
2879
2880
|
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
2880
|
-
if (patterns.length === 0) return
|
|
2881
|
+
if (patterns.length === 0) return null;
|
|
2881
2882
|
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
2882
2883
|
for (const pattern of patterns) {
|
|
2883
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
2884
|
+
const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
|
|
2884
2885
|
for (const workspaceDirectory of directories) {
|
|
2885
2886
|
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
2886
2887
|
visitedDirectories.add(workspaceDirectory);
|
|
2887
|
-
|
|
2888
|
+
const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
|
|
2889
|
+
if (value !== null) return value;
|
|
2888
2890
|
}
|
|
2889
2891
|
}
|
|
2890
|
-
return
|
|
2892
|
+
return null;
|
|
2891
2893
|
};
|
|
2894
|
+
const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
|
|
2892
2895
|
const NAMES = new Set([
|
|
2893
2896
|
"react-native",
|
|
2894
2897
|
"react-native-tvos",
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2898
|
+
...new Set([
|
|
2899
|
+
"expo",
|
|
2900
|
+
"expo-router",
|
|
2901
|
+
"@expo/cli",
|
|
2902
|
+
"@expo/metro-config",
|
|
2903
|
+
"@expo/metro-runtime"
|
|
2904
|
+
]),
|
|
2900
2905
|
"react-native-windows",
|
|
2901
2906
|
"react-native-macos"
|
|
2902
2907
|
]);
|
|
@@ -2920,6 +2925,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
|
|
|
2920
2925
|
return false;
|
|
2921
2926
|
};
|
|
2922
2927
|
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
|
|
2928
|
+
const getExpoDependencySpec = (packageJson) => {
|
|
2929
|
+
const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
|
|
2930
|
+
return typeof spec === "string" ? spec : null;
|
|
2931
|
+
};
|
|
2932
|
+
const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
|
|
2923
2933
|
const getPreactVersion = (packageJson) => {
|
|
2924
2934
|
return {
|
|
2925
2935
|
...packageJson.peerDependencies,
|
|
@@ -3159,6 +3169,19 @@ const discoverProject = (directory) => {
|
|
|
3159
3169
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
3160
3170
|
const sourceFileCount = countSourceFiles(directory);
|
|
3161
3171
|
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
3172
|
+
let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
|
|
3173
|
+
if (expoVersion !== null && isCatalogReference(expoVersion)) {
|
|
3174
|
+
const catalogName = extractCatalogName(expoVersion);
|
|
3175
|
+
let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
|
|
3176
|
+
if (!resolvedExpoVersion) {
|
|
3177
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
3178
|
+
if (monorepoRoot) {
|
|
3179
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
3180
|
+
if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
expoVersion = resolvedExpoVersion ?? expoVersion;
|
|
3184
|
+
}
|
|
3162
3185
|
const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
|
|
3163
3186
|
const preactVersion = getPreactVersion(packageJson);
|
|
3164
3187
|
const projectInfo = {
|
|
@@ -3176,6 +3199,7 @@ const discoverProject = (directory) => {
|
|
|
3176
3199
|
preactVersion,
|
|
3177
3200
|
preactMajorVersion: parseReactMajor(preactVersion),
|
|
3178
3201
|
hasReactNativeWorkspace,
|
|
3202
|
+
expoVersion,
|
|
3179
3203
|
hasReanimated,
|
|
3180
3204
|
sourceFileCount
|
|
3181
3205
|
};
|
|
@@ -3280,6 +3304,9 @@ const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
|
3280
3304
|
"Accessibility",
|
|
3281
3305
|
"Maintainability"
|
|
3282
3306
|
];
|
|
3307
|
+
const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
|
|
3308
|
+
const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
|
|
3309
|
+
const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
|
|
3283
3310
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
3284
3311
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
3285
3312
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -3399,10 +3426,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
3399
3426
|
*/
|
|
3400
3427
|
const buildRuleSeverityControls = (config) => {
|
|
3401
3428
|
if (!config) return void 0;
|
|
3402
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
3429
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
3403
3430
|
return {
|
|
3404
3431
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
3405
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
3432
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
3433
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
3406
3434
|
};
|
|
3407
3435
|
};
|
|
3408
3436
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -3766,6 +3794,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
3766
3794
|
}
|
|
3767
3795
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
3768
3796
|
};
|
|
3797
|
+
const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
|
|
3798
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
3799
|
+
const findNearestPackageDirectory = (filename) => {
|
|
3800
|
+
if (!filename) return null;
|
|
3801
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
3802
|
+
if (fromCache !== void 0) return fromCache;
|
|
3803
|
+
let currentDirectory = path.dirname(filename);
|
|
3804
|
+
while (true) {
|
|
3805
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
3806
|
+
let hasPackageJson = false;
|
|
3807
|
+
try {
|
|
3808
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
3809
|
+
} catch {
|
|
3810
|
+
hasPackageJson = false;
|
|
3811
|
+
}
|
|
3812
|
+
if (hasPackageJson) {
|
|
3813
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
3814
|
+
return currentDirectory;
|
|
3815
|
+
}
|
|
3816
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
3817
|
+
if (parentDirectory === currentDirectory) {
|
|
3818
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
3819
|
+
return null;
|
|
3820
|
+
}
|
|
3821
|
+
currentDirectory = parentDirectory;
|
|
3822
|
+
}
|
|
3823
|
+
};
|
|
3824
|
+
const readManifest = (packageJsonPath) => {
|
|
3825
|
+
try {
|
|
3826
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
3827
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
3828
|
+
return null;
|
|
3829
|
+
} catch {
|
|
3830
|
+
return null;
|
|
3831
|
+
}
|
|
3832
|
+
};
|
|
3833
|
+
const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
|
|
3834
|
+
const classifyByDirectoryCohort = (packageDirectory) => {
|
|
3835
|
+
let current = packageDirectory;
|
|
3836
|
+
while (true) {
|
|
3837
|
+
if (path.basename(current) === "apps") return "app";
|
|
3838
|
+
const parent = path.dirname(current);
|
|
3839
|
+
if (parent === current) return null;
|
|
3840
|
+
current = parent;
|
|
3841
|
+
}
|
|
3842
|
+
};
|
|
3843
|
+
const clearPackageRoleCache = () => {
|
|
3844
|
+
cachedRoleByPackageDirectory.clear();
|
|
3845
|
+
cachedPackageDirectoryByFilename.clear();
|
|
3846
|
+
};
|
|
3847
|
+
const classifyPackageRole = (filename) => {
|
|
3848
|
+
if (!filename) return "unknown";
|
|
3849
|
+
const packageDirectory = findNearestPackageDirectory(filename);
|
|
3850
|
+
if (!packageDirectory) return "unknown";
|
|
3851
|
+
const cached = cachedRoleByPackageDirectory.get(packageDirectory);
|
|
3852
|
+
if (cached !== void 0) return cached;
|
|
3853
|
+
const manifest = readManifest(path.join(packageDirectory, "package.json"));
|
|
3854
|
+
let result;
|
|
3855
|
+
if (manifest && hasPublishContract(manifest)) result = "library";
|
|
3856
|
+
else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
|
|
3857
|
+
cachedRoleByPackageDirectory.set(packageDirectory, result);
|
|
3858
|
+
return result;
|
|
3859
|
+
};
|
|
3769
3860
|
/**
|
|
3770
3861
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
3771
3862
|
* accounting for the various shapes oxlint emits:
|
|
@@ -3928,6 +4019,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3928
4019
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
3929
4020
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
3930
4021
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
4022
|
+
const libraryFileCache = /* @__PURE__ */ new Map();
|
|
4023
|
+
const isLibraryFile = (filePath) => {
|
|
4024
|
+
let cached = libraryFileCache.get(filePath);
|
|
4025
|
+
if (cached === void 0) {
|
|
4026
|
+
cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
|
|
4027
|
+
libraryFileCache.set(filePath, cached);
|
|
4028
|
+
}
|
|
4029
|
+
return cached;
|
|
4030
|
+
};
|
|
3931
4031
|
const getFileLines = (filePath) => {
|
|
3932
4032
|
const cached = fileLinesCache.get(filePath);
|
|
3933
4033
|
if (cached !== void 0) return cached;
|
|
@@ -3954,6 +4054,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3954
4054
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
3955
4055
|
return false;
|
|
3956
4056
|
};
|
|
4057
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
4058
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
4059
|
+
return false;
|
|
4060
|
+
};
|
|
3957
4061
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
3958
4062
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
3959
4063
|
if (diagnostic.line <= 0) return false;
|
|
@@ -3968,8 +4072,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3968
4072
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
3969
4073
|
let current = diagnostic;
|
|
3970
4074
|
let explicitSeverityOverride;
|
|
4075
|
+
let explicitRuleOverride;
|
|
3971
4076
|
if (severityControls) {
|
|
3972
4077
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
4078
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
3973
4079
|
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
3974
4080
|
ruleKey,
|
|
3975
4081
|
category
|
|
@@ -3977,6 +4083,9 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3977
4083
|
if (explicitSeverityOverride === "off") return null;
|
|
3978
4084
|
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
3979
4085
|
}
|
|
4086
|
+
if (explicitRuleOverride === void 0) {
|
|
4087
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
4088
|
+
}
|
|
3980
4089
|
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
3981
4090
|
if (userConfig) {
|
|
3982
4091
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
@@ -4162,6 +4271,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
4162
4271
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
4163
4272
|
}).pipe(Effect.orDie));
|
|
4164
4273
|
/**
|
|
4274
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
4275
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
4276
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
4277
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
4278
|
+
*/
|
|
4279
|
+
const resolveScanConcurrency = (requested) => {
|
|
4280
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
4281
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
4282
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
4283
|
+
};
|
|
4284
|
+
/**
|
|
4165
4285
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
4166
4286
|
* startup so the eval harness can raise the budget under sandbox
|
|
4167
4287
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -4181,6 +4301,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
4181
4301
|
* tests that exercise the cap behavior.
|
|
4182
4302
|
*/
|
|
4183
4303
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
4304
|
+
/**
|
|
4305
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
4306
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
4307
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
4308
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
4309
|
+
* CI callers that never touch the flag:
|
|
4310
|
+
*
|
|
4311
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
4312
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
4313
|
+
* - a positive integer → that many workers (clamped)
|
|
4314
|
+
*
|
|
4315
|
+
* The resolved value is always within
|
|
4316
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
4317
|
+
*/
|
|
4318
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
4319
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
4320
|
+
if (raw === void 0) return 1;
|
|
4321
|
+
const normalized = raw.trim().toLowerCase();
|
|
4322
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
4323
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
4324
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
4325
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
4326
|
+
return resolveScanConcurrency(parsed);
|
|
4327
|
+
} }) {};
|
|
4184
4328
|
const DIAGNOSTIC_SURFACES = [
|
|
4185
4329
|
"cli",
|
|
4186
4330
|
"prComment",
|
|
@@ -4341,16 +4485,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
|
|
|
4341
4485
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
4342
4486
|
const loadConfigFromDirectory = (directory) => {
|
|
4343
4487
|
const configFilePath = path.join(directory, CONFIG_FILENAME);
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4488
|
+
let sawBrokenConfigFile = false;
|
|
4489
|
+
if (isFile(configFilePath)) {
|
|
4490
|
+
try {
|
|
4491
|
+
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
4492
|
+
const parsed = JSON.parse(fileContent);
|
|
4493
|
+
if (isPlainObject(parsed)) return {
|
|
4494
|
+
status: "found",
|
|
4495
|
+
loaded: {
|
|
4496
|
+
config: validateConfigTypes(parsed),
|
|
4497
|
+
sourceDirectory: directory
|
|
4498
|
+
}
|
|
4499
|
+
};
|
|
4500
|
+
warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
4501
|
+
} catch (error) {
|
|
4502
|
+
warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4503
|
+
}
|
|
4504
|
+
sawBrokenConfigFile = true;
|
|
4354
4505
|
}
|
|
4355
4506
|
const packageJsonPath = path.join(directory, "package.json");
|
|
4356
4507
|
if (isFile(packageJsonPath)) try {
|
|
@@ -4359,14 +4510,18 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
4359
4510
|
if (isPlainObject(packageJson)) {
|
|
4360
4511
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
4361
4512
|
if (isPlainObject(embeddedConfig)) return {
|
|
4362
|
-
|
|
4363
|
-
|
|
4513
|
+
status: "found",
|
|
4514
|
+
loaded: {
|
|
4515
|
+
config: validateConfigTypes(embeddedConfig),
|
|
4516
|
+
sourceDirectory: directory
|
|
4517
|
+
}
|
|
4364
4518
|
};
|
|
4365
4519
|
}
|
|
4366
|
-
} catch {
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4520
|
+
} catch {}
|
|
4521
|
+
return {
|
|
4522
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
4523
|
+
loaded: null
|
|
4524
|
+
};
|
|
4370
4525
|
};
|
|
4371
4526
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
4372
4527
|
const clearConfigCache = () => {
|
|
@@ -4375,21 +4530,21 @@ const clearConfigCache = () => {
|
|
|
4375
4530
|
const loadConfigWithSource = (rootDirectory) => {
|
|
4376
4531
|
const cached = cachedConfigs.get(rootDirectory);
|
|
4377
4532
|
if (cached !== void 0) return cached;
|
|
4378
|
-
const
|
|
4379
|
-
if (
|
|
4380
|
-
cachedConfigs.set(rootDirectory,
|
|
4381
|
-
return
|
|
4533
|
+
const localResult = loadConfigFromDirectory(rootDirectory);
|
|
4534
|
+
if (localResult.status === "found") {
|
|
4535
|
+
cachedConfigs.set(rootDirectory, localResult.loaded);
|
|
4536
|
+
return localResult.loaded;
|
|
4382
4537
|
}
|
|
4383
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
4538
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
|
|
4384
4539
|
cachedConfigs.set(rootDirectory, null);
|
|
4385
4540
|
return null;
|
|
4386
4541
|
}
|
|
4387
4542
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
4388
4543
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
4389
|
-
const
|
|
4390
|
-
if (
|
|
4391
|
-
cachedConfigs.set(rootDirectory,
|
|
4392
|
-
return
|
|
4544
|
+
const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
|
|
4545
|
+
if (ancestorResult.status === "found") {
|
|
4546
|
+
cachedConfigs.set(rootDirectory, ancestorResult.loaded);
|
|
4547
|
+
return ancestorResult.loaded;
|
|
4393
4548
|
}
|
|
4394
4549
|
if (isProjectBoundary(ancestorDirectory)) {
|
|
4395
4550
|
cachedConfigs.set(rootDirectory, null);
|
|
@@ -4414,11 +4569,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
|
4414
4569
|
}
|
|
4415
4570
|
return resolvedRootDir;
|
|
4416
4571
|
};
|
|
4417
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
4572
|
+
const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
4418
4573
|
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
4419
4574
|
const reactSubprojects = discoverReactSubprojects(directory);
|
|
4420
4575
|
if (reactSubprojects.length === 0) return null;
|
|
4421
4576
|
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
4577
|
+
if (options.allowAmbiguous === true) return null;
|
|
4422
4578
|
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
4423
4579
|
};
|
|
4424
4580
|
/**
|
|
@@ -4432,7 +4588,8 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4432
4588
|
* project root, if configured.
|
|
4433
4589
|
* 4. Walk into a nested React subproject when the requested
|
|
4434
4590
|
* directory has no `package.json` of its own (raises
|
|
4435
|
-
* `AmbiguousProjectError` when multiple candidates exist
|
|
4591
|
+
* `AmbiguousProjectError` when multiple candidates exist unless
|
|
4592
|
+
* the caller opts into keeping the wrapper directory).
|
|
4436
4593
|
*
|
|
4437
4594
|
* Throws `ProjectNotFoundError` when neither the requested directory
|
|
4438
4595
|
* nor any discoverable nested project has a `package.json`.
|
|
@@ -4444,14 +4601,14 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4444
4601
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
4445
4602
|
* shell in agreement on what "the scan directory" means.
|
|
4446
4603
|
*/
|
|
4447
|
-
const resolveScanTarget = (requestedDirectory) => {
|
|
4604
|
+
const resolveScanTarget = (requestedDirectory, options = {}) => {
|
|
4448
4605
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
4449
4606
|
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
4450
4607
|
const userConfig = loadedConfig?.config ?? null;
|
|
4451
4608
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
4452
4609
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
4453
4610
|
const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
|
|
4454
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
|
|
4611
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
|
|
4455
4612
|
if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
|
|
4456
4613
|
return {
|
|
4457
4614
|
resolvedDirectory,
|
|
@@ -4461,6 +4618,359 @@ const resolveScanTarget = (requestedDirectory) => {
|
|
|
4461
4618
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
4462
4619
|
};
|
|
4463
4620
|
};
|
|
4621
|
+
const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
|
|
4622
|
+
const buildExpoCheckContext = (rootDirectory, expoVersion) => {
|
|
4623
|
+
const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
|
|
4624
|
+
return {
|
|
4625
|
+
rootDirectory,
|
|
4626
|
+
packageJson,
|
|
4627
|
+
directDependencyNames: getDirectDependencyNames(packageJson),
|
|
4628
|
+
expoSdkMajor: getLowestDependencyMajor(expoVersion)
|
|
4629
|
+
};
|
|
4630
|
+
};
|
|
4631
|
+
const buildExpoDiagnostic = (input) => ({
|
|
4632
|
+
filePath: input.filePath ?? "package.json",
|
|
4633
|
+
plugin: "react-doctor",
|
|
4634
|
+
rule: input.rule,
|
|
4635
|
+
severity: input.severity ?? "warning",
|
|
4636
|
+
message: input.message,
|
|
4637
|
+
help: input.help,
|
|
4638
|
+
line: input.line ?? 0,
|
|
4639
|
+
column: input.column ?? 0,
|
|
4640
|
+
category: input.category ?? "Correctness"
|
|
4641
|
+
});
|
|
4642
|
+
const CRITICAL_OVERRIDE_NAMES = new Set([
|
|
4643
|
+
"@expo/cli",
|
|
4644
|
+
"@expo/config",
|
|
4645
|
+
"@expo/metro-config",
|
|
4646
|
+
"@expo/metro-runtime",
|
|
4647
|
+
"@expo/metro",
|
|
4648
|
+
"metro"
|
|
4649
|
+
]);
|
|
4650
|
+
const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
|
|
4651
|
+
const collectOverrideNames = (packageJson) => new Set([
|
|
4652
|
+
...Object.keys(packageJson.overrides ?? {}),
|
|
4653
|
+
...Object.keys(packageJson.resolutions ?? {}),
|
|
4654
|
+
...Object.keys(packageJson.pnpm?.overrides ?? {})
|
|
4655
|
+
]);
|
|
4656
|
+
const checkExpoDependencyOverrides = (context) => {
|
|
4657
|
+
const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
|
|
4658
|
+
if (overriddenCriticalNames.length === 0) return [];
|
|
4659
|
+
const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
|
|
4660
|
+
return [buildExpoDiagnostic({
|
|
4661
|
+
rule: "expo-no-conflicting-dependency-override",
|
|
4662
|
+
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`,
|
|
4663
|
+
help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
|
|
4664
|
+
})];
|
|
4665
|
+
};
|
|
4666
|
+
const isPathGitIgnored = (rootDirectory, absolutePath) => {
|
|
4667
|
+
const result = spawnSync("git", [
|
|
4668
|
+
"check-ignore",
|
|
4669
|
+
"-q",
|
|
4670
|
+
absolutePath
|
|
4671
|
+
], {
|
|
4672
|
+
cwd: rootDirectory,
|
|
4673
|
+
stdio: [
|
|
4674
|
+
"ignore",
|
|
4675
|
+
"ignore",
|
|
4676
|
+
"ignore"
|
|
4677
|
+
]
|
|
4678
|
+
});
|
|
4679
|
+
if (result.error) return null;
|
|
4680
|
+
if (result.status === 0) return true;
|
|
4681
|
+
if (result.status === 1) return false;
|
|
4682
|
+
return null;
|
|
4683
|
+
};
|
|
4684
|
+
const LOCAL_ENV_FILE_NAMES = [
|
|
4685
|
+
".env.local",
|
|
4686
|
+
".env.development.local",
|
|
4687
|
+
".env.production.local",
|
|
4688
|
+
".env.test.local"
|
|
4689
|
+
];
|
|
4690
|
+
const checkExpoEnvLocalFiles = (context) => {
|
|
4691
|
+
const { rootDirectory } = context;
|
|
4692
|
+
const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
|
|
4693
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
4694
|
+
if (!isFile(filePath)) return false;
|
|
4695
|
+
return isPathGitIgnored(rootDirectory, filePath) === false;
|
|
4696
|
+
});
|
|
4697
|
+
if (committedEnvFiles.length === 0) return [];
|
|
4698
|
+
return [buildExpoDiagnostic({
|
|
4699
|
+
rule: "expo-env-local-not-gitignored",
|
|
4700
|
+
category: "Security",
|
|
4701
|
+
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`,
|
|
4702
|
+
help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
|
|
4703
|
+
})];
|
|
4704
|
+
};
|
|
4705
|
+
const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
|
|
4706
|
+
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";
|
|
4707
|
+
const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
|
|
4708
|
+
const unimodulesEntry = (packageName) => ({
|
|
4709
|
+
packageName,
|
|
4710
|
+
rule: "expo-no-unimodules-packages",
|
|
4711
|
+
message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
|
|
4712
|
+
help: UNIMODULES_HELP
|
|
4713
|
+
});
|
|
4714
|
+
const FLAGGED_DEPENDENCIES = [
|
|
4715
|
+
unimodulesEntry("@unimodules/core"),
|
|
4716
|
+
unimodulesEntry("@unimodules/react-native-adapter"),
|
|
4717
|
+
unimodulesEntry("react-native-unimodules"),
|
|
4718
|
+
{
|
|
4719
|
+
packageName: "expo-cli",
|
|
4720
|
+
rule: "expo-no-cli-dependencies",
|
|
4721
|
+
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`",
|
|
4722
|
+
help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
|
|
4723
|
+
},
|
|
4724
|
+
{
|
|
4725
|
+
packageName: "eas-cli",
|
|
4726
|
+
rule: "expo-no-cli-dependencies",
|
|
4727
|
+
message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
|
|
4728
|
+
help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
|
|
4729
|
+
},
|
|
4730
|
+
{
|
|
4731
|
+
packageName: "expo-modules-autolinking",
|
|
4732
|
+
rule: "expo-no-redundant-dependency",
|
|
4733
|
+
message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
|
|
4734
|
+
help: "Remove `expo-modules-autolinking` from your package.json"
|
|
4735
|
+
},
|
|
4736
|
+
{
|
|
4737
|
+
packageName: "expo-dev-launcher",
|
|
4738
|
+
rule: "expo-no-redundant-dependency",
|
|
4739
|
+
message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
4740
|
+
help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
|
|
4741
|
+
},
|
|
4742
|
+
{
|
|
4743
|
+
packageName: "expo-dev-menu",
|
|
4744
|
+
rule: "expo-no-redundant-dependency",
|
|
4745
|
+
message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
4746
|
+
help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
|
|
4747
|
+
},
|
|
4748
|
+
{
|
|
4749
|
+
packageName: "expo-modules-core",
|
|
4750
|
+
rule: "expo-no-redundant-dependency",
|
|
4751
|
+
message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
|
|
4752
|
+
help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
|
|
4753
|
+
},
|
|
4754
|
+
{
|
|
4755
|
+
packageName: "@expo/metro-config",
|
|
4756
|
+
rule: "expo-no-redundant-dependency",
|
|
4757
|
+
message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
|
|
4758
|
+
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
|
|
4759
|
+
},
|
|
4760
|
+
{
|
|
4761
|
+
packageName: "@types/react-native",
|
|
4762
|
+
rule: "expo-no-redundant-dependency",
|
|
4763
|
+
message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
|
|
4764
|
+
help: "Remove `@types/react-native` from your package.json",
|
|
4765
|
+
minSdkMajor: 48
|
|
4766
|
+
},
|
|
4767
|
+
{
|
|
4768
|
+
packageName: "@expo/config-plugins",
|
|
4769
|
+
rule: "expo-no-redundant-dependency",
|
|
4770
|
+
message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
|
|
4771
|
+
help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
|
|
4772
|
+
minSdkMajor: 48
|
|
4773
|
+
},
|
|
4774
|
+
{
|
|
4775
|
+
packageName: "@expo/prebuild-config",
|
|
4776
|
+
rule: "expo-no-redundant-dependency",
|
|
4777
|
+
message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
|
|
4778
|
+
help: "Remove `@expo/prebuild-config` from your package.json",
|
|
4779
|
+
minSdkMajor: 53
|
|
4780
|
+
},
|
|
4781
|
+
{
|
|
4782
|
+
packageName: "expo-permissions",
|
|
4783
|
+
rule: "expo-no-redundant-dependency",
|
|
4784
|
+
message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
|
|
4785
|
+
help: "Remove `expo-permissions` and request permissions from the relevant module instead",
|
|
4786
|
+
minSdkMajor: 50
|
|
4787
|
+
},
|
|
4788
|
+
{
|
|
4789
|
+
packageName: "expo-app-loading",
|
|
4790
|
+
rule: "expo-no-redundant-dependency",
|
|
4791
|
+
message: "\"expo-app-loading\" was removed in SDK 49",
|
|
4792
|
+
help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
|
|
4793
|
+
minSdkMajor: 49
|
|
4794
|
+
},
|
|
4795
|
+
{
|
|
4796
|
+
packageName: "expo-firebase-analytics",
|
|
4797
|
+
rule: "expo-no-redundant-dependency",
|
|
4798
|
+
message: "\"expo-firebase-analytics\" was removed in SDK 48",
|
|
4799
|
+
help: FIREBASE_HELP,
|
|
4800
|
+
minSdkMajor: 48
|
|
4801
|
+
},
|
|
4802
|
+
{
|
|
4803
|
+
packageName: "expo-firebase-recaptcha",
|
|
4804
|
+
rule: "expo-no-redundant-dependency",
|
|
4805
|
+
message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
|
|
4806
|
+
help: FIREBASE_HELP,
|
|
4807
|
+
minSdkMajor: 48
|
|
4808
|
+
},
|
|
4809
|
+
{
|
|
4810
|
+
packageName: "expo-firebase-core",
|
|
4811
|
+
rule: "expo-no-redundant-dependency",
|
|
4812
|
+
message: "\"expo-firebase-core\" was removed in SDK 48",
|
|
4813
|
+
help: FIREBASE_HELP,
|
|
4814
|
+
minSdkMajor: 48
|
|
4815
|
+
}
|
|
4816
|
+
];
|
|
4817
|
+
const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
|
|
4818
|
+
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
|
|
4819
|
+
if (flaggedDependency.minSdkMajor === void 0) return true;
|
|
4820
|
+
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
|
|
4821
|
+
}).map((flaggedDependency) => buildExpoDiagnostic({
|
|
4822
|
+
rule: flaggedDependency.rule,
|
|
4823
|
+
message: flaggedDependency.message,
|
|
4824
|
+
help: flaggedDependency.help
|
|
4825
|
+
}));
|
|
4826
|
+
const findLocalModuleNativeFiles = (rootDirectory) => {
|
|
4827
|
+
const modulesDirectory = path.join(rootDirectory, "modules");
|
|
4828
|
+
if (!isDirectory(modulesDirectory)) return [];
|
|
4829
|
+
const nativeFilePaths = [];
|
|
4830
|
+
for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
|
|
4831
|
+
if (!moduleEntry.isDirectory()) continue;
|
|
4832
|
+
const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
|
|
4833
|
+
const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
|
|
4834
|
+
if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
|
|
4835
|
+
const iosDirectory = path.join(moduleDirectory, "ios");
|
|
4836
|
+
if (isDirectory(iosDirectory)) {
|
|
4837
|
+
for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
return nativeFilePaths;
|
|
4841
|
+
};
|
|
4842
|
+
const checkExpoGitignore = (context) => {
|
|
4843
|
+
const { rootDirectory } = context;
|
|
4844
|
+
const diagnostics = [];
|
|
4845
|
+
const expoStateDirectory = path.join(rootDirectory, ".expo");
|
|
4846
|
+
if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
|
|
4847
|
+
rule: "expo-gitignore",
|
|
4848
|
+
message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
|
|
4849
|
+
help: "Add `.expo/` to your .gitignore"
|
|
4850
|
+
}));
|
|
4851
|
+
if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
|
|
4852
|
+
rule: "expo-gitignore",
|
|
4853
|
+
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",
|
|
4854
|
+
help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
|
|
4855
|
+
}));
|
|
4856
|
+
return diagnostics;
|
|
4857
|
+
};
|
|
4858
|
+
const LOCKFILE_NAMES = [
|
|
4859
|
+
"pnpm-lock.yaml",
|
|
4860
|
+
"yarn.lock",
|
|
4861
|
+
"package-lock.json",
|
|
4862
|
+
"bun.lockb",
|
|
4863
|
+
"bun.lock"
|
|
4864
|
+
];
|
|
4865
|
+
const checkExpoLockfile = (context) => {
|
|
4866
|
+
const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
|
|
4867
|
+
const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
|
|
4868
|
+
if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
|
|
4869
|
+
rule: "expo-lockfile",
|
|
4870
|
+
message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
|
|
4871
|
+
help: "Install dependencies with your package manager to generate a lock file, then commit it"
|
|
4872
|
+
})];
|
|
4873
|
+
if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
|
|
4874
|
+
rule: "expo-lockfile",
|
|
4875
|
+
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`,
|
|
4876
|
+
help: "Delete the lock files for the package managers you are not using and keep only one"
|
|
4877
|
+
})];
|
|
4878
|
+
return [];
|
|
4879
|
+
};
|
|
4880
|
+
const METRO_CONFIG_FILE_NAMES = [
|
|
4881
|
+
"metro.config.js",
|
|
4882
|
+
"metro.config.cjs",
|
|
4883
|
+
"metro.config.mjs",
|
|
4884
|
+
"metro.config.ts"
|
|
4885
|
+
];
|
|
4886
|
+
const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
|
|
4887
|
+
"expo/metro-config",
|
|
4888
|
+
"@sentry/react-native/metro",
|
|
4889
|
+
"getSentryExpoConfig"
|
|
4890
|
+
];
|
|
4891
|
+
const checkExpoMetroConfig = (context) => {
|
|
4892
|
+
const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
|
|
4893
|
+
if (metroConfigPath === void 0) return [];
|
|
4894
|
+
let contents;
|
|
4895
|
+
try {
|
|
4896
|
+
contents = fs.readFileSync(metroConfigPath, "utf-8");
|
|
4897
|
+
} catch {
|
|
4898
|
+
return [];
|
|
4899
|
+
}
|
|
4900
|
+
if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
|
|
4901
|
+
return [buildExpoDiagnostic({
|
|
4902
|
+
rule: "expo-metro-config",
|
|
4903
|
+
filePath: path.basename(metroConfigPath),
|
|
4904
|
+
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",
|
|
4905
|
+
help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
|
|
4906
|
+
})];
|
|
4907
|
+
};
|
|
4908
|
+
const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
|
|
4909
|
+
const checkExpoPackageJsonConflicts = (context) => {
|
|
4910
|
+
const { packageJson } = context;
|
|
4911
|
+
const diagnostics = [];
|
|
4912
|
+
const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
|
|
4913
|
+
if (conflictingScriptNames.length > 0) {
|
|
4914
|
+
const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
|
|
4915
|
+
const shadowsExpoCli = conflictingScriptNames.includes("expo");
|
|
4916
|
+
diagnostics.push(buildExpoDiagnostic({
|
|
4917
|
+
rule: "expo-package-json-conflict",
|
|
4918
|
+
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" : ""}`,
|
|
4919
|
+
help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
|
|
4920
|
+
}));
|
|
4921
|
+
}
|
|
4922
|
+
const packageName = packageJson.name;
|
|
4923
|
+
if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
|
|
4924
|
+
rule: "expo-package-json-conflict",
|
|
4925
|
+
message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
|
|
4926
|
+
help: "Rename your package so it no longer matches one of its dependencies"
|
|
4927
|
+
}));
|
|
4928
|
+
return diagnostics;
|
|
4929
|
+
};
|
|
4930
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
4931
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
4932
|
+
const checkExpoRouterReactNavigation = (context) => {
|
|
4933
|
+
const { expoSdkMajor } = context;
|
|
4934
|
+
if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
|
|
4935
|
+
if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
|
|
4936
|
+
if (!context.directDependencyNames.has("expo-router")) return [];
|
|
4937
|
+
const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
|
|
4938
|
+
if (reactNavigationNames.length === 0) return [];
|
|
4939
|
+
return [buildExpoDiagnostic({
|
|
4940
|
+
rule: "expo-router-no-react-navigation",
|
|
4941
|
+
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"}`,
|
|
4942
|
+
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/"
|
|
4943
|
+
})];
|
|
4944
|
+
};
|
|
4945
|
+
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
4946
|
+
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
4947
|
+
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
4948
|
+
const checkExpoVectorIcons = (context) => {
|
|
4949
|
+
if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
|
|
4950
|
+
const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
|
|
4951
|
+
const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
|
|
4952
|
+
if (!hasScopedPackage || !hasConflictingPackage) return [];
|
|
4953
|
+
return [buildExpoDiagnostic({
|
|
4954
|
+
rule: "expo-vector-icons-conflict",
|
|
4955
|
+
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",
|
|
4956
|
+
help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
|
|
4957
|
+
})];
|
|
4958
|
+
};
|
|
4959
|
+
const checkExpoProject = (rootDirectory, project) => {
|
|
4960
|
+
if (project.expoVersion === null) return [];
|
|
4961
|
+
const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
|
|
4962
|
+
return [
|
|
4963
|
+
...checkExpoFlaggedDependencies(context),
|
|
4964
|
+
...checkExpoDependencyOverrides(context),
|
|
4965
|
+
...checkExpoRouterReactNavigation(context),
|
|
4966
|
+
...checkExpoVectorIcons(context),
|
|
4967
|
+
...checkExpoPackageJsonConflicts(context),
|
|
4968
|
+
...checkExpoLockfile(context),
|
|
4969
|
+
...checkExpoGitignore(context),
|
|
4970
|
+
...checkExpoEnvLocalFiles(context),
|
|
4971
|
+
...checkExpoMetroConfig(context)
|
|
4972
|
+
];
|
|
4973
|
+
};
|
|
4464
4974
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
4465
4975
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
4466
4976
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -5741,6 +6251,7 @@ const buildCapabilities = (project) => {
|
|
|
5741
6251
|
const capabilities = /* @__PURE__ */ new Set();
|
|
5742
6252
|
capabilities.add(project.framework);
|
|
5743
6253
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
6254
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
5744
6255
|
const reactMajor = project.reactMajorVersion;
|
|
5745
6256
|
if (reactMajor !== null) {
|
|
5746
6257
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -5912,10 +6423,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
5912
6423
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
5913
6424
|
return fs.realpathSync(rootDirectory);
|
|
5914
6425
|
};
|
|
6426
|
+
const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
|
|
6427
|
+
if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
|
|
6428
|
+
return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
6429
|
+
};
|
|
5915
6430
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
5916
6431
|
const enabledRules = {};
|
|
5917
6432
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
5918
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
6433
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
5919
6434
|
if (severity === "off") continue;
|
|
5920
6435
|
enabledRules[ruleKey] = severity;
|
|
5921
6436
|
}
|
|
@@ -5957,7 +6472,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
5957
6472
|
category: rule.category
|
|
5958
6473
|
}, severityControls);
|
|
5959
6474
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
5960
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
6475
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
5961
6476
|
if (severity === "off") continue;
|
|
5962
6477
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
5963
6478
|
}
|
|
@@ -6014,6 +6529,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
6014
6529
|
}
|
|
6015
6530
|
return uniqueDiagnostics;
|
|
6016
6531
|
};
|
|
6532
|
+
/**
|
|
6533
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
6534
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
6535
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
6536
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
6537
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
6538
|
+
* batches do).
|
|
6539
|
+
*
|
|
6540
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
6541
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
6542
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
6543
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
6544
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
6545
|
+
* top of a still-running first one.
|
|
6546
|
+
*/
|
|
6547
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
6548
|
+
const results = new Array(items.length);
|
|
6549
|
+
if (items.length === 0) return results;
|
|
6550
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
6551
|
+
let nextIndex = 0;
|
|
6552
|
+
const errors = [];
|
|
6553
|
+
const runWorker = async () => {
|
|
6554
|
+
while (errors.length === 0) {
|
|
6555
|
+
const index = nextIndex;
|
|
6556
|
+
nextIndex += 1;
|
|
6557
|
+
if (index >= items.length) return;
|
|
6558
|
+
try {
|
|
6559
|
+
results[index] = await task(items[index], index);
|
|
6560
|
+
} catch (error) {
|
|
6561
|
+
errors.push(error);
|
|
6562
|
+
return;
|
|
6563
|
+
}
|
|
6564
|
+
}
|
|
6565
|
+
};
|
|
6566
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
6567
|
+
if (errors.length > 0) throw errors[0];
|
|
6568
|
+
return results;
|
|
6569
|
+
};
|
|
6017
6570
|
const getPublicEnvPrefix = (framework) => {
|
|
6018
6571
|
switch (framework) {
|
|
6019
6572
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -6696,6 +7249,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
6696
7249
|
*/
|
|
6697
7250
|
const spawnLintBatches = async (input) => {
|
|
6698
7251
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
7252
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
6699
7253
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
6700
7254
|
const allDiagnostics = [];
|
|
6701
7255
|
const droppedFiles = [];
|
|
@@ -6715,23 +7269,31 @@ const spawnLintBatches = async (input) => {
|
|
|
6715
7269
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
6716
7270
|
}
|
|
6717
7271
|
};
|
|
7272
|
+
let startedFileCount = 0;
|
|
6718
7273
|
let scannedFileCount = 0;
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
const
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
7274
|
+
let displayedFileCount = 0;
|
|
7275
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
7276
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
7277
|
+
if (displayedFileCount < ceiling) {
|
|
7278
|
+
displayedFileCount += 1;
|
|
7279
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
7280
|
+
}
|
|
7281
|
+
}, 50) : null;
|
|
7282
|
+
progressTimer?.unref?.();
|
|
7283
|
+
try {
|
|
7284
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
7285
|
+
startedFileCount += batch.length;
|
|
6728
7286
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
6729
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
6730
7287
|
scannedFileCount += batch.length;
|
|
6731
|
-
onFileProgress
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
7288
|
+
if (onFileProgress) {
|
|
7289
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
7290
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
7291
|
+
}
|
|
7292
|
+
return batchDiagnostics;
|
|
7293
|
+
});
|
|
7294
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
7295
|
+
} finally {
|
|
7296
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
6735
7297
|
}
|
|
6736
7298
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
6737
7299
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -6858,7 +7420,8 @@ const runOxlint = async (options) => {
|
|
|
6858
7420
|
onPartialFailure,
|
|
6859
7421
|
onFileProgress: options.onFileProgress,
|
|
6860
7422
|
spawnTimeoutMs,
|
|
6861
|
-
outputMaxBytes
|
|
7423
|
+
outputMaxBytes,
|
|
7424
|
+
concurrency: options.concurrency
|
|
6862
7425
|
});
|
|
6863
7426
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
6864
7427
|
try {
|
|
@@ -6926,6 +7489,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6926
7489
|
const partialFailures = yield* LintPartialFailures;
|
|
6927
7490
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
6928
7491
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
7492
|
+
const concurrency = yield* OxlintConcurrency;
|
|
6929
7493
|
const collectedFailures = [];
|
|
6930
7494
|
const diagnostics = yield* Effect.tryPromise({
|
|
6931
7495
|
try: () => runOxlint({
|
|
@@ -6944,7 +7508,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6944
7508
|
},
|
|
6945
7509
|
onFileProgress: input.onFileProgress,
|
|
6946
7510
|
spawnTimeoutMs,
|
|
6947
|
-
outputMaxBytes
|
|
7511
|
+
outputMaxBytes,
|
|
7512
|
+
concurrency
|
|
6948
7513
|
}),
|
|
6949
7514
|
catch: ensureReactDoctorError
|
|
6950
7515
|
});
|
|
@@ -7268,7 +7833,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7268
7833
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
7269
7834
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
7270
7835
|
const isDiffMode = input.includePaths.length > 0;
|
|
7271
|
-
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ??
|
|
7836
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
7272
7837
|
const transform = buildDiagnosticPipeline({
|
|
7273
7838
|
rootDirectory: scanDirectory,
|
|
7274
7839
|
userConfig: resolvedConfig.config,
|
|
@@ -7277,7 +7842,11 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7277
7842
|
showWarnings
|
|
7278
7843
|
});
|
|
7279
7844
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
7280
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7845
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7846
|
+
...checkReducedMotion(scanDirectory),
|
|
7847
|
+
...checkPnpmHardening(scanDirectory),
|
|
7848
|
+
...checkExpoProject(scanDirectory, project)
|
|
7849
|
+
];
|
|
7281
7850
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
7282
7851
|
const lintFailure = yield* Ref.make({
|
|
7283
7852
|
didFail: false,
|
|
@@ -7289,6 +7858,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7289
7858
|
didFail: false,
|
|
7290
7859
|
reason: null
|
|
7291
7860
|
});
|
|
7861
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
7862
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
7292
7863
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
7293
7864
|
const scanStartTime = Date.now();
|
|
7294
7865
|
let lastReportedTotalFileCount = 0;
|
|
@@ -7305,7 +7876,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7305
7876
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
7306
7877
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
7307
7878
|
lastReportedTotalFileCount = totalFileCount;
|
|
7308
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
7879
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
7309
7880
|
}
|
|
7310
7881
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
7311
7882
|
yield* Ref.set(lintFailure, {
|
|
@@ -7337,7 +7908,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7337
7908
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
7338
7909
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
7339
7910
|
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
7340
|
-
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
|
|
7911
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
7341
7912
|
yield* reporterService.finalize;
|
|
7342
7913
|
const finalDiagnostics = [
|
|
7343
7914
|
...envCollected,
|
|
@@ -7389,7 +7960,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7389
7960
|
"inspect.isCi": input.isCi,
|
|
7390
7961
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
7391
7962
|
} }));
|
|
7392
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
7393
7963
|
const parseNodeVersion = (versionString) => {
|
|
7394
7964
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
7395
7965
|
return {
|
|
@@ -7688,6 +8258,26 @@ const buildJsonReport = (input) => {
|
|
|
7688
8258
|
};
|
|
7689
8259
|
};
|
|
7690
8260
|
/**
|
|
8261
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
8262
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
8263
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
8264
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
8265
|
+
* branch logic lives here once.
|
|
8266
|
+
*/
|
|
8267
|
+
const buildSkippedChecks = (input) => {
|
|
8268
|
+
const skippedChecks = [];
|
|
8269
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
8270
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
8271
|
+
const skippedCheckReasons = {};
|
|
8272
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
8273
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
8274
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
8275
|
+
return {
|
|
8276
|
+
skippedChecks,
|
|
8277
|
+
skippedCheckReasons
|
|
8278
|
+
};
|
|
8279
|
+
};
|
|
8280
|
+
/**
|
|
7691
8281
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
7692
8282
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
7693
8283
|
* spawn, not `spawnSync`).
|
|
@@ -7793,7 +8383,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
|
|
|
7793
8383
|
const clearAutoSuppressionCaches = () => {};
|
|
7794
8384
|
//#endregion
|
|
7795
8385
|
//#region ../api/dist/index.js
|
|
7796
|
-
const
|
|
8386
|
+
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
8387
|
const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
7798
8388
|
const effectiveConfig = configOverride ?? scanTarget.userConfig;
|
|
7799
8389
|
const includePaths = options.includePaths ?? [];
|
|
@@ -7802,7 +8392,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7802
8392
|
includePaths,
|
|
7803
8393
|
customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
|
|
7804
8394
|
respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
|
|
7805
|
-
warnings: options.warnings ?? effectiveConfig?.warnings ??
|
|
8395
|
+
warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
|
|
7806
8396
|
adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
|
|
7807
8397
|
ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
|
|
7808
8398
|
runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
|
|
@@ -7812,13 +8402,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7812
8402
|
};
|
|
7813
8403
|
const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
7814
8404
|
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;
|
|
8405
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
|
|
7822
8406
|
return {
|
|
7823
8407
|
diagnostics: [...output.diagnostics],
|
|
7824
8408
|
score: output.score,
|
|
@@ -7831,7 +8415,7 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
|
7831
8415
|
const diagnose = async (directory, options = {}) => {
|
|
7832
8416
|
const startTime = globalThis.performance.now();
|
|
7833
8417
|
const program = buildInspectProgram(resolveScanTarget(directory), options);
|
|
7834
|
-
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(
|
|
8418
|
+
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
|
|
7835
8419
|
};
|
|
7836
8420
|
//#endregion
|
|
7837
8421
|
//#region src/index.ts
|
|
@@ -7840,6 +8424,7 @@ const clearCaches = () => {
|
|
|
7840
8424
|
clearConfigCache();
|
|
7841
8425
|
clearPackageJsonCache();
|
|
7842
8426
|
clearIgnorePatternsCache();
|
|
8427
|
+
clearPackageRoleCache();
|
|
7843
8428
|
clearAutoSuppressionCaches();
|
|
7844
8429
|
};
|
|
7845
8430
|
const toJsonReport = (result, options) => buildJsonReport({
|