react-doctor 0.2.14-dev.c2198ed → 0.2.14-dev.e9e71bb
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 +50 -2
- package/dist/cli.js +14075 -3806
- package/dist/index.d.ts +110 -13
- package/dist/index.js +998 -249
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +11 -4
- package/dist/cli-logger-BgVL1vBI.js +0 -7722
- package/dist/rolldown-runtime-uZX_iqCz.js +0 -35
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
|
|
@@ -59,6 +63,7 @@ var Diagnostic = class extends Schema.Class("Diagnostic")({
|
|
|
59
63
|
plugin: Schema.String,
|
|
60
64
|
rule: Schema.String,
|
|
61
65
|
severity: Severity,
|
|
66
|
+
title: Schema.optional(Schema.String),
|
|
62
67
|
message: Schema.String,
|
|
63
68
|
help: Schema.String,
|
|
64
69
|
url: Schema.optional(Schema.String),
|
|
@@ -2094,6 +2099,8 @@ const isFile = (filePath) => {
|
|
|
2094
2099
|
}
|
|
2095
2100
|
};
|
|
2096
2101
|
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
2102
|
+
const GENERATED_BUNDLE_FILE_PATTERN = /\.(iife|umd|global|min)\.js$/i;
|
|
2103
|
+
const MINIFIED_SNIFF_BYTES = 65536;
|
|
2097
2104
|
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
2098
2105
|
const IGNORED_DIRECTORIES = new Set([
|
|
2099
2106
|
".git",
|
|
@@ -2109,6 +2116,34 @@ const IGNORED_DIRECTORIES = new Set([
|
|
|
2109
2116
|
"out",
|
|
2110
2117
|
"storybook-static"
|
|
2111
2118
|
]);
|
|
2119
|
+
const isLintableSourceFile = (filePath) => SOURCE_FILE_PATTERN.test(filePath) && !GENERATED_BUNDLE_FILE_PATTERN.test(filePath);
|
|
2120
|
+
const isMinifiedSource = (absolutePath) => {
|
|
2121
|
+
let fileDescriptor;
|
|
2122
|
+
try {
|
|
2123
|
+
fileDescriptor = fs.openSync(absolutePath, "r");
|
|
2124
|
+
const buffer = Buffer.alloc(MINIFIED_SNIFF_BYTES);
|
|
2125
|
+
const bytesRead = fs.readSync(fileDescriptor, buffer, 0, MINIFIED_SNIFF_BYTES, 0);
|
|
2126
|
+
const prefix = buffer.toString("utf8", 0, bytesRead);
|
|
2127
|
+
const lines = prefix.split("\n");
|
|
2128
|
+
const longestLineLength = lines.reduce((longest, line) => Math.max(longest, line.length), 0);
|
|
2129
|
+
const averageLineLength = prefix.length / lines.length;
|
|
2130
|
+
return longestLineLength > 1e3 && averageLineLength > 500;
|
|
2131
|
+
} catch {
|
|
2132
|
+
return false;
|
|
2133
|
+
} finally {
|
|
2134
|
+
if (fileDescriptor !== void 0) fs.closeSync(fileDescriptor);
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
const isLargeMinifiedFile = (absolutePath) => {
|
|
2138
|
+
let sizeBytes;
|
|
2139
|
+
try {
|
|
2140
|
+
sizeBytes = fs.statSync(absolutePath).size;
|
|
2141
|
+
} catch {
|
|
2142
|
+
return false;
|
|
2143
|
+
}
|
|
2144
|
+
if (sizeBytes < 2e4) return false;
|
|
2145
|
+
return isMinifiedSource(absolutePath);
|
|
2146
|
+
};
|
|
2112
2147
|
const IGNORABLE_READDIR_ERROR_CODES = new Set([
|
|
2113
2148
|
"EACCES",
|
|
2114
2149
|
"EPERM",
|
|
@@ -2139,7 +2174,7 @@ const countSourceFilesViaFilesystem = (rootDirectory) => {
|
|
|
2139
2174
|
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
|
|
2140
2175
|
continue;
|
|
2141
2176
|
}
|
|
2142
|
-
if (entry.isFile() &&
|
|
2177
|
+
if (entry.isFile() && isLintableSourceFile(entry.name) && !isLargeMinifiedFile(path.join(currentDirectory, entry.name))) count++;
|
|
2143
2178
|
}
|
|
2144
2179
|
}
|
|
2145
2180
|
return count;
|
|
@@ -2157,7 +2192,7 @@ const countSourceFilesViaGit = (rootDirectory) => {
|
|
|
2157
2192
|
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
2158
2193
|
});
|
|
2159
2194
|
if (result.error || result.status !== 0) return null;
|
|
2160
|
-
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 &&
|
|
2195
|
+
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath) && !isLargeMinifiedFile(path.resolve(rootDirectory, filePath))).length;
|
|
2161
2196
|
};
|
|
2162
2197
|
const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
|
|
2163
2198
|
const cachedPackageJsons = /* @__PURE__ */ new Map();
|
|
@@ -2843,29 +2878,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
2843
2878
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
2844
2879
|
};
|
|
2845
2880
|
};
|
|
2846
|
-
const
|
|
2847
|
-
|
|
2881
|
+
const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
|
|
2882
|
+
const rootValue = select(rootPackageJson);
|
|
2883
|
+
if (rootValue !== null) return rootValue;
|
|
2848
2884
|
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
2849
|
-
if (patterns.length === 0) return
|
|
2885
|
+
if (patterns.length === 0) return null;
|
|
2850
2886
|
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
2851
2887
|
for (const pattern of patterns) {
|
|
2852
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
2888
|
+
const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
|
|
2853
2889
|
for (const workspaceDirectory of directories) {
|
|
2854
2890
|
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
2855
2891
|
visitedDirectories.add(workspaceDirectory);
|
|
2856
|
-
|
|
2892
|
+
const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
|
|
2893
|
+
if (value !== null) return value;
|
|
2857
2894
|
}
|
|
2858
2895
|
}
|
|
2859
|
-
return
|
|
2896
|
+
return null;
|
|
2860
2897
|
};
|
|
2898
|
+
const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
|
|
2861
2899
|
const NAMES = new Set([
|
|
2862
2900
|
"react-native",
|
|
2863
2901
|
"react-native-tvos",
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2902
|
+
...new Set([
|
|
2903
|
+
"expo",
|
|
2904
|
+
"expo-router",
|
|
2905
|
+
"@expo/cli",
|
|
2906
|
+
"@expo/metro-config",
|
|
2907
|
+
"@expo/metro-runtime"
|
|
2908
|
+
]),
|
|
2869
2909
|
"react-native-windows",
|
|
2870
2910
|
"react-native-macos"
|
|
2871
2911
|
]);
|
|
@@ -2889,6 +2929,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
|
|
|
2889
2929
|
return false;
|
|
2890
2930
|
};
|
|
2891
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);
|
|
2892
2937
|
const getPreactVersion = (packageJson) => {
|
|
2893
2938
|
return {
|
|
2894
2939
|
...packageJson.peerDependencies,
|
|
@@ -3128,6 +3173,19 @@ const discoverProject = (directory) => {
|
|
|
3128
3173
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
3129
3174
|
const sourceFileCount = countSourceFiles(directory);
|
|
3130
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
|
+
}
|
|
3131
3189
|
const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
|
|
3132
3190
|
const preactVersion = getPreactVersion(packageJson);
|
|
3133
3191
|
const projectInfo = {
|
|
@@ -3145,6 +3203,7 @@ const discoverProject = (directory) => {
|
|
|
3145
3203
|
preactVersion,
|
|
3146
3204
|
preactMajorVersion: parseReactMajor(preactVersion),
|
|
3147
3205
|
hasReactNativeWorkspace,
|
|
3206
|
+
expoVersion,
|
|
3148
3207
|
hasReanimated,
|
|
3149
3208
|
sourceFileCount
|
|
3150
3209
|
};
|
|
@@ -3234,13 +3293,31 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
3234
3293
|
"tsconfig.json",
|
|
3235
3294
|
"tsconfig.base.json",
|
|
3236
3295
|
"package.json",
|
|
3237
|
-
"
|
|
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",
|
|
3238
3304
|
"oxlint.json",
|
|
3239
3305
|
".oxlintrc.json"
|
|
3240
3306
|
];
|
|
3241
3307
|
const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
3242
3308
|
const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
|
|
3309
|
+
const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192;
|
|
3243
3310
|
const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
|
|
3311
|
+
const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
3312
|
+
"Security",
|
|
3313
|
+
"Bugs",
|
|
3314
|
+
"Performance",
|
|
3315
|
+
"Accessibility",
|
|
3316
|
+
"Maintainability"
|
|
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"]);
|
|
3244
3321
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
3245
3322
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
3246
3323
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -3360,10 +3437,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
3360
3437
|
*/
|
|
3361
3438
|
const buildRuleSeverityControls = (config) => {
|
|
3362
3439
|
if (!config) return void 0;
|
|
3363
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
3440
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
3364
3441
|
return {
|
|
3365
3442
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
3366
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
3443
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
3444
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
3367
3445
|
};
|
|
3368
3446
|
};
|
|
3369
3447
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -3727,6 +3805,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
3727
3805
|
}
|
|
3728
3806
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
3729
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
|
+
};
|
|
3730
3871
|
/**
|
|
3731
3872
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
3732
3873
|
* accounting for the various shapes oxlint emits:
|
|
@@ -3862,10 +4003,13 @@ const collectStringSet = (values) => {
|
|
|
3862
4003
|
* wins over `test-noise`)
|
|
3863
4004
|
* 2. severity overrides (top-level `rules` / `categories`, with
|
|
3864
4005
|
* `"off"` dropping)
|
|
3865
|
-
* 3.
|
|
3866
|
-
*
|
|
4006
|
+
* 3. warning suppression (only when `showWarnings` is false: drops every
|
|
4007
|
+
* `"warning"`-severity diagnostic unless a severity override opts a
|
|
4008
|
+
* specific rule / category back in)
|
|
4009
|
+
* 4. ignore filters (rules / file patterns / per-file overrides)
|
|
4010
|
+
* 5. `rn-no-raw-text` suppression via configured `textComponents` and
|
|
3867
4011
|
* `rawTextWrapperComponents` (config-driven JSX enclosure checks)
|
|
3868
|
-
*
|
|
4012
|
+
* 6. inline suppressions (`// react-doctor-disable-next-line ...`)
|
|
3869
4013
|
*
|
|
3870
4014
|
* Returns `null` when the diagnostic is dropped, the (possibly
|
|
3871
4015
|
* severity-restamped) diagnostic otherwise.
|
|
@@ -3875,7 +4019,7 @@ const collectStringSet = (values) => {
|
|
|
3875
4019
|
* `mergeAndFilterDiagnostics` wrapper apply this closure per element.
|
|
3876
4020
|
*/
|
|
3877
4021
|
const buildDiagnosticPipeline = (input) => {
|
|
3878
|
-
const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables } = input;
|
|
4022
|
+
const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables, showWarnings } = input;
|
|
3879
4023
|
const severityControls = buildRuleSeverityControls(userConfig);
|
|
3880
4024
|
const ignoredRules = new Set(Array.isArray(userConfig?.ignore?.rules) ? userConfig.ignore.rules.filter((rule) => typeof rule === "string") : []);
|
|
3881
4025
|
const ignoredFilePatterns = compileIgnoredFilePatterns(userConfig);
|
|
@@ -3886,6 +4030,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3886
4030
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
3887
4031
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
3888
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
|
+
};
|
|
3889
4042
|
const getFileLines = (filePath) => {
|
|
3890
4043
|
const cached = fileLinesCache.get(filePath);
|
|
3891
4044
|
if (cached !== void 0) return cached;
|
|
@@ -3912,6 +4065,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3912
4065
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
3913
4066
|
return false;
|
|
3914
4067
|
};
|
|
4068
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
4069
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
4070
|
+
return false;
|
|
4071
|
+
};
|
|
3915
4072
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
3916
4073
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
3917
4074
|
if (diagnostic.line <= 0) return false;
|
|
@@ -3925,15 +4082,22 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3925
4082
|
return { apply: (diagnostic) => {
|
|
3926
4083
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
3927
4084
|
let current = diagnostic;
|
|
4085
|
+
let explicitSeverityOverride;
|
|
4086
|
+
let explicitRuleOverride;
|
|
3928
4087
|
if (severityControls) {
|
|
3929
4088
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
3930
|
-
|
|
4089
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
4090
|
+
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
3931
4091
|
ruleKey,
|
|
3932
4092
|
category
|
|
3933
4093
|
}, severityControls);
|
|
3934
|
-
if (
|
|
3935
|
-
if (
|
|
4094
|
+
if (explicitSeverityOverride === "off") return null;
|
|
4095
|
+
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
4096
|
+
}
|
|
4097
|
+
if (explicitRuleOverride === void 0) {
|
|
4098
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
3936
4099
|
}
|
|
4100
|
+
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
3937
4101
|
if (userConfig) {
|
|
3938
4102
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
3939
4103
|
if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) return null;
|
|
@@ -4118,6 +4282,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
4118
4282
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
4119
4283
|
}).pipe(Effect.orDie));
|
|
4120
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
|
+
/**
|
|
4121
4296
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
4122
4297
|
* startup so the eval harness can raise the budget under sandbox
|
|
4123
4298
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -4137,6 +4312,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
4137
4312
|
* tests that exercise the cap behavior.
|
|
4138
4313
|
*/
|
|
4139
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
|
+
} }) {};
|
|
4140
4339
|
const DIAGNOSTIC_SURFACES = [
|
|
4141
4340
|
"cli",
|
|
4142
4341
|
"prComment",
|
|
@@ -4165,10 +4364,18 @@ const VALID_RULE_SEVERITIES = [
|
|
|
4165
4364
|
"warn",
|
|
4166
4365
|
"off"
|
|
4167
4366
|
];
|
|
4367
|
+
const KNOWN_CATEGORY_LABEL = DIAGNOSTIC_CATEGORY_BUCKETS.join(", ");
|
|
4368
|
+
const isDiagnosticCategoryBucket = (value) => DIAGNOSTIC_CATEGORY_BUCKETS.includes(value);
|
|
4369
|
+
const filterKnownCategories = (fieldName, categories) => categories.filter((category) => {
|
|
4370
|
+
if (isDiagnosticCategoryBucket(category)) return true;
|
|
4371
|
+
warnConfigIssue(`config field "${fieldName}" lists "${category}", which is not a known category (expected one of: ${KNOWN_CATEGORY_LABEL}); ignoring the entry.`);
|
|
4372
|
+
return false;
|
|
4373
|
+
});
|
|
4168
4374
|
const BOOLEAN_FIELD_NAMES = [
|
|
4169
4375
|
"lint",
|
|
4170
4376
|
"deadCode",
|
|
4171
4377
|
"verbose",
|
|
4378
|
+
"warnings",
|
|
4172
4379
|
"customRulesOnly",
|
|
4173
4380
|
"share",
|
|
4174
4381
|
"noScore",
|
|
@@ -4217,13 +4424,15 @@ const validateSurfaceControls = (surface, rawControls) => {
|
|
|
4217
4424
|
warnConfigIssue(`config field "surfaces.${surface}" must be an object (got ${typeof rawControls}); ignoring this surface.`);
|
|
4218
4425
|
return;
|
|
4219
4426
|
}
|
|
4220
|
-
const
|
|
4427
|
+
const validatedSurfaceControls = {};
|
|
4221
4428
|
for (const fieldName of SURFACE_CONTROL_FIELD_NAMES) {
|
|
4222
4429
|
if (rawControls[fieldName] === void 0) continue;
|
|
4223
|
-
const
|
|
4224
|
-
|
|
4430
|
+
const qualifiedName = `surfaces.${surface}.${fieldName}`;
|
|
4431
|
+
const result = validateStringArrayField(qualifiedName, rawControls[fieldName]);
|
|
4432
|
+
if (result === void 0) continue;
|
|
4433
|
+
validatedSurfaceControls[fieldName] = fieldName === "includeCategories" || fieldName === "excludeCategories" ? filterKnownCategories(qualifiedName, result) : result;
|
|
4225
4434
|
}
|
|
4226
|
-
return
|
|
4435
|
+
return validatedSurfaceControls;
|
|
4227
4436
|
};
|
|
4228
4437
|
const validateSurfacesField = (rawSurfaces) => {
|
|
4229
4438
|
if (!isPlainObject$1(rawSurfaces)) {
|
|
@@ -4241,7 +4450,7 @@ const validateSurfacesField = (rawSurfaces) => {
|
|
|
4241
4450
|
}
|
|
4242
4451
|
return validated;
|
|
4243
4452
|
};
|
|
4244
|
-
const validateSeverityMap = (fieldName, rawMap) => {
|
|
4453
|
+
const validateSeverityMap = (fieldName, rawMap, keysAreCategories = false) => {
|
|
4245
4454
|
if (!isPlainObject$1(rawMap)) {
|
|
4246
4455
|
warnConfigIssue(`config field "${fieldName}" must be an object (got ${typeof rawMap}); ignoring this field.`);
|
|
4247
4456
|
return;
|
|
@@ -4252,6 +4461,10 @@ const validateSeverityMap = (fieldName, rawMap) => {
|
|
|
4252
4461
|
warnConfigIssue(`config field "${fieldName}" has an empty key; ignoring the entry.`);
|
|
4253
4462
|
continue;
|
|
4254
4463
|
}
|
|
4464
|
+
if (keysAreCategories && !isDiagnosticCategoryBucket(key)) {
|
|
4465
|
+
warnConfigIssue(`config field "${fieldName}.${key}" is not a known category (expected one of: ${KNOWN_CATEGORY_LABEL}); ignoring the entry.`);
|
|
4466
|
+
continue;
|
|
4467
|
+
}
|
|
4255
4468
|
if (!isRuleSeverity(value)) {
|
|
4256
4469
|
warnConfigIssue(`config field "${fieldName}.${key}" must be one of: ${VALID_RULE_SEVERITIES.join(", ")} (got ${formatType(value)}); ignoring the entry.`);
|
|
4257
4470
|
continue;
|
|
@@ -4272,76 +4485,116 @@ const validateConfigTypes = (config) => {
|
|
|
4272
4485
|
for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
|
|
4273
4486
|
for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
|
|
4274
4487
|
applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
|
|
4275
|
-
for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value));
|
|
4488
|
+
for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
|
|
4276
4489
|
applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
|
|
4277
4490
|
return validated;
|
|
4278
4491
|
};
|
|
4279
4492
|
const warn = (message) => {
|
|
4280
4493
|
Effect.runSync(Console.warn(message));
|
|
4281
4494
|
};
|
|
4282
|
-
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";
|
|
4283
4508
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
4284
|
-
const
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
const packageJsonPath = path.join(directory, "package.json");
|
|
4298
|
-
if (isFile(packageJsonPath)) try {
|
|
4299
|
-
const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
|
|
4300
|
-
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"));
|
|
4301
4522
|
if (isPlainObject(packageJson)) {
|
|
4302
4523
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
4303
|
-
if (isPlainObject(embeddedConfig)) return
|
|
4304
|
-
|
|
4305
|
-
|
|
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
|
+
}
|
|
4306
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;
|
|
4307
4561
|
}
|
|
4308
|
-
} catch {
|
|
4309
|
-
return null;
|
|
4310
4562
|
}
|
|
4311
|
-
|
|
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
|
+
};
|
|
4312
4573
|
};
|
|
4313
4574
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
4314
4575
|
const clearConfigCache = () => {
|
|
4315
4576
|
cachedConfigs.clear();
|
|
4316
4577
|
};
|
|
4317
|
-
const
|
|
4318
|
-
const
|
|
4319
|
-
if (
|
|
4320
|
-
|
|
4321
|
-
if (localConfig) {
|
|
4322
|
-
cachedConfigs.set(rootDirectory, localConfig);
|
|
4323
|
-
return localConfig;
|
|
4324
|
-
}
|
|
4325
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
4326
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4327
|
-
return null;
|
|
4328
|
-
}
|
|
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;
|
|
4329
4582
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
4330
4583
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
4331
|
-
const
|
|
4332
|
-
if (
|
|
4333
|
-
|
|
4334
|
-
return ancestorConfig;
|
|
4335
|
-
}
|
|
4336
|
-
if (isProjectBoundary(ancestorDirectory)) {
|
|
4337
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4338
|
-
return null;
|
|
4339
|
-
}
|
|
4584
|
+
const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
|
|
4585
|
+
if (ancestorResult.status === "found") return ancestorResult.loaded;
|
|
4586
|
+
if (isProjectBoundary(ancestorDirectory)) return null;
|
|
4340
4587
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
4341
4588
|
}
|
|
4342
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4343
4589
|
return null;
|
|
4344
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
|
+
};
|
|
4345
4598
|
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
4346
4599
|
if (!config || !configSourceDirectory) return null;
|
|
4347
4600
|
const rawRootDir = config.rootDir;
|
|
@@ -4356,11 +4609,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
|
4356
4609
|
}
|
|
4357
4610
|
return resolvedRootDir;
|
|
4358
4611
|
};
|
|
4359
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
4612
|
+
const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
4360
4613
|
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
4361
4614
|
const reactSubprojects = discoverReactSubprojects(directory);
|
|
4362
4615
|
if (reactSubprojects.length === 0) return null;
|
|
4363
4616
|
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
4617
|
+
if (options.allowAmbiguous === true) return null;
|
|
4364
4618
|
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
4365
4619
|
};
|
|
4366
4620
|
/**
|
|
@@ -4368,13 +4622,13 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4368
4622
|
* (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
|
|
4369
4623
|
*
|
|
4370
4624
|
* 1. Resolve the requested directory to absolute.
|
|
4371
|
-
* 2. Load `
|
|
4372
|
-
* if present.
|
|
4625
|
+
* 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
|
|
4373
4626
|
* 3. Honor `config.rootDir` to redirect the scan to a nested
|
|
4374
4627
|
* project root, if configured.
|
|
4375
4628
|
* 4. Walk into a nested React subproject when the requested
|
|
4376
4629
|
* directory has no `package.json` of its own (raises
|
|
4377
|
-
* `AmbiguousProjectError` when multiple candidates exist
|
|
4630
|
+
* `AmbiguousProjectError` when multiple candidates exist unless
|
|
4631
|
+
* the caller opts into keeping the wrapper directory).
|
|
4378
4632
|
*
|
|
4379
4633
|
* Throws `ProjectNotFoundError` when neither the requested directory
|
|
4380
4634
|
* nor any discoverable nested project has a `package.json`.
|
|
@@ -4386,14 +4640,14 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4386
4640
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
4387
4641
|
* shell in agreement on what "the scan directory" means.
|
|
4388
4642
|
*/
|
|
4389
|
-
const resolveScanTarget = (requestedDirectory) => {
|
|
4643
|
+
const resolveScanTarget = async (requestedDirectory, options = {}) => {
|
|
4390
4644
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
4391
|
-
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
4645
|
+
const loadedConfig = await loadConfigWithSource(absoluteRequested);
|
|
4392
4646
|
const userConfig = loadedConfig?.config ?? null;
|
|
4393
4647
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
4394
4648
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
4395
4649
|
const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
|
|
4396
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
|
|
4650
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
|
|
4397
4651
|
if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
|
|
4398
4652
|
return {
|
|
4399
4653
|
resolvedDirectory,
|
|
@@ -4403,6 +4657,359 @@ const resolveScanTarget = (requestedDirectory) => {
|
|
|
4403
4657
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
4404
4658
|
};
|
|
4405
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
|
+
};
|
|
4406
5013
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
4407
5014
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
4408
5015
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -4572,99 +5179,6 @@ const checkReducedMotion = (rootDirectory) => {
|
|
|
4572
5179
|
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
4573
5180
|
};
|
|
4574
5181
|
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
4575
|
-
const toStringSet = (values) => {
|
|
4576
|
-
if (!values || values.length === 0) return /* @__PURE__ */ new Set();
|
|
4577
|
-
return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
|
|
4578
|
-
};
|
|
4579
|
-
const buildResolvedControls = (surface, userControls) => {
|
|
4580
|
-
const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
|
|
4581
|
-
const includeTags = toStringSet(userControls?.includeTags);
|
|
4582
|
-
for (const tag of includeTags) excludeTags.delete(tag);
|
|
4583
|
-
for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
|
|
4584
|
-
return {
|
|
4585
|
-
includeTags,
|
|
4586
|
-
excludeTags,
|
|
4587
|
-
includeCategories: toStringSet(userControls?.includeCategories),
|
|
4588
|
-
excludeCategories: toStringSet(userControls?.excludeCategories),
|
|
4589
|
-
includeRuleKeys: toStringSet(userControls?.includeRules),
|
|
4590
|
-
excludeRuleKeys: toStringSet(userControls?.excludeRules)
|
|
4591
|
-
};
|
|
4592
|
-
};
|
|
4593
|
-
const intersects = (values, candidates) => values.some((value) => candidates.has(value));
|
|
4594
|
-
const isDiagnosticOnSurface = (diagnostic, surface, config) => {
|
|
4595
|
-
const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
|
|
4596
|
-
const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
|
|
4597
|
-
if (resolved.includeRuleKeys.has(ruleKey)) return true;
|
|
4598
|
-
if (resolved.includeCategories.has(category)) return true;
|
|
4599
|
-
if (intersects(tags, resolved.includeTags)) return true;
|
|
4600
|
-
if (resolved.excludeRuleKeys.has(ruleKey)) return false;
|
|
4601
|
-
if (resolved.excludeCategories.has(category)) return false;
|
|
4602
|
-
if (intersects(tags, resolved.excludeTags)) return false;
|
|
4603
|
-
return true;
|
|
4604
|
-
};
|
|
4605
|
-
const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
|
|
4606
|
-
const listSourceFilesViaGit = (rootDirectory) => {
|
|
4607
|
-
const result = spawnSync("git", [
|
|
4608
|
-
"ls-files",
|
|
4609
|
-
"-z",
|
|
4610
|
-
"--cached",
|
|
4611
|
-
"--others",
|
|
4612
|
-
"--exclude-standard"
|
|
4613
|
-
], {
|
|
4614
|
-
cwd: rootDirectory,
|
|
4615
|
-
encoding: "utf-8",
|
|
4616
|
-
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
4617
|
-
});
|
|
4618
|
-
if (result.error || result.status !== 0) return null;
|
|
4619
|
-
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
|
|
4620
|
-
};
|
|
4621
|
-
const listSourceFilesViaFilesystem = (rootDirectory) => {
|
|
4622
|
-
const filePaths = [];
|
|
4623
|
-
const stack = [rootDirectory];
|
|
4624
|
-
while (stack.length > 0) {
|
|
4625
|
-
const currentDirectory = stack.pop();
|
|
4626
|
-
const entries = readDirectoryEntries(currentDirectory);
|
|
4627
|
-
for (const entry of entries) {
|
|
4628
|
-
const absolutePath = path.join(currentDirectory, entry.name);
|
|
4629
|
-
if (entry.isDirectory()) {
|
|
4630
|
-
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
|
|
4631
|
-
continue;
|
|
4632
|
-
}
|
|
4633
|
-
if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
|
|
4634
|
-
}
|
|
4635
|
-
}
|
|
4636
|
-
return filePaths;
|
|
4637
|
-
};
|
|
4638
|
-
const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
|
|
4639
|
-
const resolveLintIncludePaths = (rootDirectory, userConfig) => {
|
|
4640
|
-
if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
|
|
4641
|
-
const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
|
|
4642
|
-
return listSourceFiles(rootDirectory).filter((filePath) => {
|
|
4643
|
-
if (!JSX_FILE_PATTERN.test(filePath)) return false;
|
|
4644
|
-
return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
|
|
4645
|
-
});
|
|
4646
|
-
};
|
|
4647
|
-
var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
4648
|
-
static layerNode = Layer.effect(Config, Effect.gen(function* () {
|
|
4649
|
-
const cache = yield* Cache.make({
|
|
4650
|
-
capacity: 16,
|
|
4651
|
-
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
4652
|
-
lookup: (directory) => Effect.sync(() => {
|
|
4653
|
-
const loaded = loadConfigWithSource(directory);
|
|
4654
|
-
const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
|
|
4655
|
-
return {
|
|
4656
|
-
config: loaded?.config ?? null,
|
|
4657
|
-
resolvedDirectory: redirected ?? directory,
|
|
4658
|
-
configSourceDirectory: loaded?.sourceDirectory ?? null
|
|
4659
|
-
};
|
|
4660
|
-
})
|
|
4661
|
-
});
|
|
4662
|
-
return Config.of({ resolve: Effect.fn("Config.resolve")(function* (directory) {
|
|
4663
|
-
return yield* Cache.get(cache, directory);
|
|
4664
|
-
}) });
|
|
4665
|
-
}));
|
|
4666
|
-
static layerOf = (resolved) => Layer.succeed(Config, Config.of({ resolve: () => Effect.succeed(resolved) }));
|
|
4667
|
-
};
|
|
4668
5182
|
const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
|
|
4669
5183
|
const FALSY_VALUES = new Set([
|
|
4670
5184
|
"false",
|
|
@@ -4746,6 +5260,30 @@ const collectIgnorePatterns = (rootDirectory) => {
|
|
|
4746
5260
|
cachedPatternsByRoot.set(rootDirectory, patterns);
|
|
4747
5261
|
return patterns;
|
|
4748
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
|
+
};
|
|
5285
|
+
const DEAD_CODE_PLUGIN = "deslop";
|
|
5286
|
+
const DEAD_CODE_CATEGORY = "Maintainability";
|
|
4749
5287
|
const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
|
|
4750
5288
|
const DEAD_CODE_WORKER_SCRIPT = `
|
|
4751
5289
|
const inputChunks = [];
|
|
@@ -4921,7 +5459,11 @@ const buildDeadCodeWorkerError = (workerError) => {
|
|
|
4921
5459
|
return error;
|
|
4922
5460
|
};
|
|
4923
5461
|
const createDeadCodeWorker = (input) => {
|
|
4924
|
-
const child = spawn(process.execPath, [
|
|
5462
|
+
const child = spawn(process.execPath, [
|
|
5463
|
+
`--max-old-space-size=${DEAD_CODE_WORKER_MAX_OLD_SPACE_MB}`,
|
|
5464
|
+
"-e",
|
|
5465
|
+
DEAD_CODE_WORKER_SCRIPT
|
|
5466
|
+
], {
|
|
4925
5467
|
stdio: [
|
|
4926
5468
|
"pipe",
|
|
4927
5469
|
"pipe",
|
|
@@ -4996,7 +5538,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
|
|
|
4996
5538
|
});
|
|
4997
5539
|
});
|
|
4998
5540
|
const checkDeadCode = async (options) => {
|
|
4999
|
-
const {
|
|
5541
|
+
const { userConfig } = options;
|
|
5542
|
+
const rootDirectory = toCanonicalPath(options.rootDirectory);
|
|
5000
5543
|
if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
|
|
5001
5544
|
const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
|
|
5002
5545
|
const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
|
|
@@ -5009,59 +5552,162 @@ const checkDeadCode = async (options) => {
|
|
|
5009
5552
|
const diagnostics = [];
|
|
5010
5553
|
for (const unusedFile of result.unusedFiles) diagnostics.push({
|
|
5011
5554
|
filePath: toRelative(unusedFile.path),
|
|
5012
|
-
plugin:
|
|
5555
|
+
plugin: DEAD_CODE_PLUGIN,
|
|
5013
5556
|
rule: "unused-file",
|
|
5014
5557
|
severity: "warning",
|
|
5015
5558
|
message: "Unused file — not reachable from any entry point",
|
|
5016
5559
|
help: "Delete the file if it is truly unreachable, or import it from an entry point.",
|
|
5017
5560
|
line: 0,
|
|
5018
5561
|
column: 0,
|
|
5019
|
-
category:
|
|
5562
|
+
category: DEAD_CODE_CATEGORY
|
|
5020
5563
|
});
|
|
5021
5564
|
for (const unusedExport of result.unusedExports) {
|
|
5022
5565
|
const label = unusedExport.isTypeOnly ? "type export" : "export";
|
|
5023
5566
|
diagnostics.push({
|
|
5024
5567
|
filePath: toRelative(unusedExport.path),
|
|
5025
|
-
plugin:
|
|
5568
|
+
plugin: DEAD_CODE_PLUGIN,
|
|
5026
5569
|
rule: unusedExport.isTypeOnly ? "unused-type" : "unused-export",
|
|
5027
5570
|
severity: "warning",
|
|
5028
5571
|
message: `Unused ${label}: \`${unusedExport.name}\``,
|
|
5029
5572
|
help: "Drop the `export` keyword (or remove the declaration) if no other module uses this symbol.",
|
|
5030
5573
|
line: unusedExport.line,
|
|
5031
5574
|
column: unusedExport.column,
|
|
5032
|
-
category:
|
|
5575
|
+
category: DEAD_CODE_CATEGORY
|
|
5033
5576
|
});
|
|
5034
5577
|
}
|
|
5035
5578
|
for (const unusedDependency of result.unusedDependencies) {
|
|
5036
5579
|
const label = unusedDependency.isDevDependency ? "devDependency" : "dependency";
|
|
5037
5580
|
diagnostics.push({
|
|
5038
5581
|
filePath: "package.json",
|
|
5039
|
-
plugin:
|
|
5582
|
+
plugin: DEAD_CODE_PLUGIN,
|
|
5040
5583
|
rule: unusedDependency.isDevDependency ? "unused-dev-dependency" : "unused-dependency",
|
|
5041
5584
|
severity: "warning",
|
|
5042
5585
|
message: `Unused ${label}: \`${unusedDependency.name}\``,
|
|
5043
5586
|
help: "Remove the dependency from package.json if it is genuinely unused.",
|
|
5044
5587
|
line: 0,
|
|
5045
5588
|
column: 0,
|
|
5046
|
-
category:
|
|
5589
|
+
category: DEAD_CODE_CATEGORY
|
|
5047
5590
|
});
|
|
5048
5591
|
}
|
|
5049
5592
|
for (const cycle of result.circularDependencies) {
|
|
5050
5593
|
if (cycle.files.length === 0) continue;
|
|
5051
5594
|
diagnostics.push({
|
|
5052
5595
|
filePath: toRelative(cycle.files[0]),
|
|
5053
|
-
plugin:
|
|
5596
|
+
plugin: DEAD_CODE_PLUGIN,
|
|
5054
5597
|
rule: "circular-dependency",
|
|
5055
5598
|
severity: "warning",
|
|
5056
5599
|
message: `Circular import cycle: ${cycle.files.map(toRelative).join(" → ")}`,
|
|
5057
5600
|
help: "Break the cycle by extracting the shared code into a third module that both files import.",
|
|
5058
5601
|
line: 0,
|
|
5059
5602
|
column: 0,
|
|
5060
|
-
category:
|
|
5603
|
+
category: DEAD_CODE_CATEGORY
|
|
5061
5604
|
});
|
|
5062
5605
|
}
|
|
5063
5606
|
return diagnostics;
|
|
5064
5607
|
};
|
|
5608
|
+
const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
|
|
5609
|
+
const isSurfacingOverride = (override) => override === "warn" || override === "error";
|
|
5610
|
+
const deadCodeMaySurfaceWhenWarningsHidden = (userConfig) => {
|
|
5611
|
+
const severityControls = buildRuleSeverityControls(userConfig);
|
|
5612
|
+
if (!severityControls) return false;
|
|
5613
|
+
if (isSurfacingOverride(severityControls.categories?.["Maintainability"])) return true;
|
|
5614
|
+
for (const [ruleKey, override] of Object.entries(severityControls.rules ?? {})) if (ruleKey.startsWith(DEAD_CODE_RULE_KEY_PREFIX) && isSurfacingOverride(override)) return true;
|
|
5615
|
+
return false;
|
|
5616
|
+
};
|
|
5617
|
+
const toStringSet = (values) => {
|
|
5618
|
+
if (!values || values.length === 0) return /* @__PURE__ */ new Set();
|
|
5619
|
+
return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
|
|
5620
|
+
};
|
|
5621
|
+
const buildResolvedControls = (surface, userControls) => {
|
|
5622
|
+
const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
|
|
5623
|
+
const includeTags = toStringSet(userControls?.includeTags);
|
|
5624
|
+
for (const tag of includeTags) excludeTags.delete(tag);
|
|
5625
|
+
for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
|
|
5626
|
+
return {
|
|
5627
|
+
includeTags,
|
|
5628
|
+
excludeTags,
|
|
5629
|
+
includeCategories: toStringSet(userControls?.includeCategories),
|
|
5630
|
+
excludeCategories: toStringSet(userControls?.excludeCategories),
|
|
5631
|
+
includeRuleKeys: toStringSet(userControls?.includeRules),
|
|
5632
|
+
excludeRuleKeys: toStringSet(userControls?.excludeRules)
|
|
5633
|
+
};
|
|
5634
|
+
};
|
|
5635
|
+
const intersects = (values, candidates) => values.some((value) => candidates.has(value));
|
|
5636
|
+
const isDiagnosticOnSurface = (diagnostic, surface, config) => {
|
|
5637
|
+
const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
|
|
5638
|
+
const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
|
|
5639
|
+
if (resolved.includeRuleKeys.has(ruleKey)) return true;
|
|
5640
|
+
if (resolved.includeCategories.has(category)) return true;
|
|
5641
|
+
if (intersects(tags, resolved.includeTags)) return true;
|
|
5642
|
+
if (resolved.excludeRuleKeys.has(ruleKey)) return false;
|
|
5643
|
+
if (resolved.excludeCategories.has(category)) return false;
|
|
5644
|
+
if (intersects(tags, resolved.excludeTags)) return false;
|
|
5645
|
+
return true;
|
|
5646
|
+
};
|
|
5647
|
+
const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
|
|
5648
|
+
const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(path.resolve(rootDirectory, relativePath)));
|
|
5649
|
+
const listSourceFilesViaGit = (rootDirectory) => {
|
|
5650
|
+
const result = spawnSync("git", [
|
|
5651
|
+
"ls-files",
|
|
5652
|
+
"-z",
|
|
5653
|
+
"--cached",
|
|
5654
|
+
"--others",
|
|
5655
|
+
"--exclude-standard"
|
|
5656
|
+
], {
|
|
5657
|
+
cwd: rootDirectory,
|
|
5658
|
+
encoding: "utf-8",
|
|
5659
|
+
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
5660
|
+
});
|
|
5661
|
+
if (result.error || result.status !== 0) return null;
|
|
5662
|
+
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
|
|
5663
|
+
};
|
|
5664
|
+
const listSourceFilesViaFilesystem = (rootDirectory) => {
|
|
5665
|
+
const filePaths = [];
|
|
5666
|
+
const stack = [rootDirectory];
|
|
5667
|
+
while (stack.length > 0) {
|
|
5668
|
+
const currentDirectory = stack.pop();
|
|
5669
|
+
const entries = readDirectoryEntries(currentDirectory);
|
|
5670
|
+
for (const entry of entries) {
|
|
5671
|
+
const absolutePath = path.join(currentDirectory, entry.name);
|
|
5672
|
+
if (entry.isDirectory()) {
|
|
5673
|
+
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
|
|
5674
|
+
continue;
|
|
5675
|
+
}
|
|
5676
|
+
if (entry.isFile() && isLintableSourceFile(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
return filePaths;
|
|
5680
|
+
};
|
|
5681
|
+
const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
|
|
5682
|
+
const resolveLintIncludePaths = (rootDirectory, userConfig) => {
|
|
5683
|
+
if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
|
|
5684
|
+
const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
|
|
5685
|
+
return listSourceFiles(rootDirectory).filter((filePath) => {
|
|
5686
|
+
if (!JSX_FILE_PATTERN.test(filePath)) return false;
|
|
5687
|
+
return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
|
|
5688
|
+
});
|
|
5689
|
+
};
|
|
5690
|
+
var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
5691
|
+
static layerNode = Layer.effect(Config, Effect.gen(function* () {
|
|
5692
|
+
const cache = yield* Cache.make({
|
|
5693
|
+
capacity: 16,
|
|
5694
|
+
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
5695
|
+
lookup: (directory) => Effect.promise(async () => {
|
|
5696
|
+
const loaded = await loadConfigWithSource(directory);
|
|
5697
|
+
const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
|
|
5698
|
+
return {
|
|
5699
|
+
config: loaded?.config ?? null,
|
|
5700
|
+
resolvedDirectory: redirected ?? directory,
|
|
5701
|
+
configSourceDirectory: loaded?.sourceDirectory ?? null
|
|
5702
|
+
};
|
|
5703
|
+
})
|
|
5704
|
+
});
|
|
5705
|
+
return Config.of({ resolve: Effect.fn("Config.resolve")(function* (directory) {
|
|
5706
|
+
return yield* Cache.get(cache, directory);
|
|
5707
|
+
}) });
|
|
5708
|
+
}));
|
|
5709
|
+
static layerOf = (resolved) => Layer.succeed(Config, Config.of({ resolve: () => Effect.succeed(resolved) }));
|
|
5710
|
+
};
|
|
5065
5711
|
/**
|
|
5066
5712
|
* `DeadCode` runs whole-project reachability analysis and streams
|
|
5067
5713
|
* diagnostics. Reachability is a whole-project property — the
|
|
@@ -5567,12 +6213,12 @@ const findFilesWithDisableDirectivesViaGit = async (rootDirectory, includePaths)
|
|
|
5567
6213
|
return null;
|
|
5568
6214
|
}
|
|
5569
6215
|
if (grepResult === null) return null;
|
|
5570
|
-
return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 &&
|
|
6216
|
+
return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
|
|
5571
6217
|
};
|
|
5572
6218
|
const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
|
|
5573
6219
|
const matches = [];
|
|
5574
6220
|
const checkFile = (relativePath) => {
|
|
5575
|
-
if (!
|
|
6221
|
+
if (!isLintableSourceFile(relativePath)) return;
|
|
5576
6222
|
const absolutePath = path.join(rootDirectory, relativePath);
|
|
5577
6223
|
let content;
|
|
5578
6224
|
try {
|
|
@@ -5644,6 +6290,7 @@ const buildCapabilities = (project) => {
|
|
|
5644
6290
|
const capabilities = /* @__PURE__ */ new Set();
|
|
5645
6291
|
capabilities.add(project.framework);
|
|
5646
6292
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
6293
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
5647
6294
|
const reactMajor = project.reactMajorVersion;
|
|
5648
6295
|
if (reactMajor !== null) {
|
|
5649
6296
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -5815,10 +6462,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
5815
6462
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
5816
6463
|
return fs.realpathSync(rootDirectory);
|
|
5817
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
|
+
};
|
|
5818
6469
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
5819
6470
|
const enabledRules = {};
|
|
5820
6471
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
5821
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
6472
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
5822
6473
|
if (severity === "off") continue;
|
|
5823
6474
|
enabledRules[ruleKey] = severity;
|
|
5824
6475
|
}
|
|
@@ -5860,7 +6511,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
5860
6511
|
category: rule.category
|
|
5861
6512
|
}, severityControls);
|
|
5862
6513
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
5863
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
6514
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
5864
6515
|
if (severity === "off") continue;
|
|
5865
6516
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
5866
6517
|
}
|
|
@@ -5917,6 +6568,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
5917
6568
|
}
|
|
5918
6569
|
return uniqueDiagnostics;
|
|
5919
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
|
+
};
|
|
5920
6609
|
const getPublicEnvPrefix = (framework) => {
|
|
5921
6610
|
switch (framework) {
|
|
5922
6611
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -6032,7 +6721,7 @@ const KNOWN_SECRET_RULES = [
|
|
|
6032
6721
|
}
|
|
6033
6722
|
];
|
|
6034
6723
|
const CANDIDATE_TOKEN_PATTERN = /[A-Za-z0-9_][A-Za-z0-9_-]*/g;
|
|
6035
|
-
const
|
|
6724
|
+
const HEX_DIGEST_PATTERN = /^(?:[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
6036
6725
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6037
6726
|
const HAS_LETTER_PATTERN = /[A-Za-z]/;
|
|
6038
6727
|
const HAS_DIGIT_PATTERN = /[0-9]/;
|
|
@@ -6049,7 +6738,7 @@ const shannonEntropyBits = (value) => {
|
|
|
6049
6738
|
const looksLikeHighEntropySecret = (token) => {
|
|
6050
6739
|
if (token.length < 32) return false;
|
|
6051
6740
|
if (!HAS_LETTER_PATTERN.test(token) || !HAS_DIGIT_PATTERN.test(token)) return false;
|
|
6052
|
-
if (
|
|
6741
|
+
if (HEX_DIGEST_PATTERN.test(token) || UUID_PATTERN.test(token)) return false;
|
|
6053
6742
|
return shannonEntropyBits(token) >= 3;
|
|
6054
6743
|
};
|
|
6055
6744
|
const redactHighEntropyTokens = (text) => text.replace(CANDIDATE_TOKEN_PATTERN, (token) => looksLikeHighEntropySecret(token) ? REDACTED_PLACEHOLDER : token);
|
|
@@ -6376,25 +7065,26 @@ const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
|
|
|
6376
7065
|
return bindingResolution !== null && !bindingResolution.isReactUseBinding;
|
|
6377
7066
|
};
|
|
6378
7067
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
6379
|
-
const
|
|
7068
|
+
const REACT_COMPILER_TITLE = "React Compiler can't optimize this";
|
|
7069
|
+
const REACT_COMPILER_MESSAGE = "This component misses React Compiler's automatic memoization & re-renders more than it should. Rewrite the flagged code so the compiler can optimize it.";
|
|
6380
7070
|
const PLUGIN_CATEGORY_MAP = {
|
|
6381
|
-
react: "
|
|
6382
|
-
"react-hooks": "
|
|
6383
|
-
"react-hooks-js": "
|
|
6384
|
-
"react-doctor": "
|
|
7071
|
+
react: "Bugs",
|
|
7072
|
+
"react-hooks": "Bugs",
|
|
7073
|
+
"react-hooks-js": "Performance",
|
|
7074
|
+
"react-doctor": "Bugs",
|
|
6385
7075
|
"jsx-a11y": "Accessibility",
|
|
6386
|
-
effect: "
|
|
6387
|
-
eslint: "
|
|
6388
|
-
oxc: "
|
|
6389
|
-
typescript: "
|
|
6390
|
-
unicorn: "
|
|
6391
|
-
import: "
|
|
6392
|
-
promise: "
|
|
6393
|
-
n: "
|
|
6394
|
-
node: "
|
|
6395
|
-
vitest: "
|
|
6396
|
-
jest: "
|
|
6397
|
-
nextjs: "
|
|
7076
|
+
effect: "Bugs",
|
|
7077
|
+
eslint: "Bugs",
|
|
7078
|
+
oxc: "Bugs",
|
|
7079
|
+
typescript: "Bugs",
|
|
7080
|
+
unicorn: "Bugs",
|
|
7081
|
+
import: "Performance",
|
|
7082
|
+
promise: "Bugs",
|
|
7083
|
+
n: "Bugs",
|
|
7084
|
+
node: "Bugs",
|
|
7085
|
+
vitest: "Bugs",
|
|
7086
|
+
jest: "Bugs",
|
|
7087
|
+
nextjs: "Bugs"
|
|
6398
7088
|
};
|
|
6399
7089
|
const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
6400
7090
|
const getRuleRecommendation = (ruleName, project) => {
|
|
@@ -6402,6 +7092,8 @@ const getRuleRecommendation = (ruleName, project) => {
|
|
|
6402
7092
|
return reactDoctorPlugin.rules[ruleName]?.recommendation;
|
|
6403
7093
|
};
|
|
6404
7094
|
const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
|
|
7095
|
+
const getRuleTitle = (ruleName) => reactDoctorPlugin.rules[ruleName]?.title;
|
|
7096
|
+
const resolveDiagnosticTitle = (plugin, rule) => plugin === "react-hooks-js" ? REACT_COMPILER_TITLE : getRuleTitle(rule);
|
|
6405
7097
|
const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
|
|
6406
7098
|
const cleaned = resolveCleanedDiagnostic(typeof message === "string" ? message : "", typeof help === "string" ? help : "", plugin, rule, project);
|
|
6407
7099
|
return {
|
|
@@ -6430,7 +7122,7 @@ const parseRuleCode = (code) => {
|
|
|
6430
7122
|
rule: match[2]
|
|
6431
7123
|
};
|
|
6432
7124
|
};
|
|
6433
|
-
const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "
|
|
7125
|
+
const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Bugs";
|
|
6434
7126
|
const isOxlintOutput = (value) => {
|
|
6435
7127
|
if (typeof value !== "object" || value === null) return false;
|
|
6436
7128
|
const candidate = value;
|
|
@@ -6454,7 +7146,16 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
|
|
|
6454
7146
|
throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
|
|
6455
7147
|
}
|
|
6456
7148
|
if (!isOxlintOutput(parsed)) throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
|
|
6457
|
-
|
|
7149
|
+
const minifiedFileCache = /* @__PURE__ */ new Map();
|
|
7150
|
+
const isMinifiedDiagnosticFile = (filename) => {
|
|
7151
|
+
const absolutePath = path.isAbsolute(filename) ? filename : path.resolve(rootDirectory || ".", filename);
|
|
7152
|
+
const cached = minifiedFileCache.get(absolutePath);
|
|
7153
|
+
if (cached !== void 0) return cached;
|
|
7154
|
+
const minified = isMinifiedSource(absolutePath);
|
|
7155
|
+
minifiedFileCache.set(absolutePath, minified);
|
|
7156
|
+
return minified;
|
|
7157
|
+
};
|
|
7158
|
+
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && isLintableSourceFile(diagnostic.filename) && !isMinifiedDiagnosticFile(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
|
|
6458
7159
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
6459
7160
|
const primaryLabel = diagnostic.labels[0];
|
|
6460
7161
|
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule, project);
|
|
@@ -6463,6 +7164,7 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
|
|
|
6463
7164
|
plugin,
|
|
6464
7165
|
rule,
|
|
6465
7166
|
severity: diagnostic.severity,
|
|
7167
|
+
title: resolveDiagnosticTitle(plugin, rule),
|
|
6466
7168
|
message: cleaned.message,
|
|
6467
7169
|
help: cleaned.help,
|
|
6468
7170
|
url: diagnostic.url,
|
|
@@ -6586,6 +7288,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
6586
7288
|
*/
|
|
6587
7289
|
const spawnLintBatches = async (input) => {
|
|
6588
7290
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
7291
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
6589
7292
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
6590
7293
|
const allDiagnostics = [];
|
|
6591
7294
|
const droppedFiles = [];
|
|
@@ -6605,23 +7308,31 @@ const spawnLintBatches = async (input) => {
|
|
|
6605
7308
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
6606
7309
|
}
|
|
6607
7310
|
};
|
|
7311
|
+
let startedFileCount = 0;
|
|
6608
7312
|
let scannedFileCount = 0;
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
const
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
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;
|
|
6618
7325
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
6619
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
6620
7326
|
scannedFileCount += batch.length;
|
|
6621
|
-
onFileProgress
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
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);
|
|
6625
7336
|
}
|
|
6626
7337
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
6627
7338
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -6748,7 +7459,8 @@ const runOxlint = async (options) => {
|
|
|
6748
7459
|
onPartialFailure,
|
|
6749
7460
|
onFileProgress: options.onFileProgress,
|
|
6750
7461
|
spawnTimeoutMs,
|
|
6751
|
-
outputMaxBytes
|
|
7462
|
+
outputMaxBytes,
|
|
7463
|
+
concurrency: options.concurrency
|
|
6752
7464
|
});
|
|
6753
7465
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
6754
7466
|
try {
|
|
@@ -6816,6 +7528,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6816
7528
|
const partialFailures = yield* LintPartialFailures;
|
|
6817
7529
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
6818
7530
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
7531
|
+
const concurrency = yield* OxlintConcurrency;
|
|
6819
7532
|
const collectedFailures = [];
|
|
6820
7533
|
const diagnostics = yield* Effect.tryPromise({
|
|
6821
7534
|
try: () => runOxlint({
|
|
@@ -6834,7 +7547,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6834
7547
|
},
|
|
6835
7548
|
onFileProgress: input.onFileProgress,
|
|
6836
7549
|
spawnTimeoutMs,
|
|
6837
|
-
outputMaxBytes
|
|
7550
|
+
outputMaxBytes,
|
|
7551
|
+
concurrency
|
|
6838
7552
|
}),
|
|
6839
7553
|
catch: ensureReactDoctorError
|
|
6840
7554
|
});
|
|
@@ -6880,7 +7594,8 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
|
|
|
6880
7594
|
static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
|
|
6881
7595
|
update: () => Effect.void,
|
|
6882
7596
|
succeed: () => Effect.void,
|
|
6883
|
-
fail: () => Effect.void
|
|
7597
|
+
fail: () => Effect.void,
|
|
7598
|
+
stop: () => Effect.void
|
|
6884
7599
|
}) }));
|
|
6885
7600
|
static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
|
|
6886
7601
|
yield* Ref.update(events, (existing) => [...existing, {
|
|
@@ -6899,6 +7614,10 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
|
|
|
6899
7614
|
fail: (displayText) => Ref.update(events, (existing) => [...existing, {
|
|
6900
7615
|
_tag: "Failed",
|
|
6901
7616
|
text: displayText
|
|
7617
|
+
}]),
|
|
7618
|
+
stop: () => Ref.update(events, (existing) => [...existing, {
|
|
7619
|
+
_tag: "Stopped",
|
|
7620
|
+
text
|
|
6902
7621
|
}])
|
|
6903
7622
|
};
|
|
6904
7623
|
}) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
|
|
@@ -7148,18 +7867,25 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7148
7867
|
repo
|
|
7149
7868
|
}).pipe(Effect.orElseSucceed(() => null)) : Effect.succeed(null));
|
|
7150
7869
|
const lintIncludePaths = computeJsxIncludePaths([...input.includePaths]) ?? resolveLintIncludePaths(scanDirectory, resolvedConfig.config);
|
|
7870
|
+
const scannedFilePaths = input.suppressScanSummary ? (lintIncludePaths ?? (yield* filesService.listSourceFiles(scanDirectory))).map((relativePath) => path.resolve(scanDirectory, relativePath)) : [];
|
|
7151
7871
|
const beforeLint = hooks.beforeLint ?? NO_HOOKS.beforeLint;
|
|
7152
7872
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
7153
7873
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
7154
7874
|
const isDiffMode = input.includePaths.length > 0;
|
|
7875
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
7155
7876
|
const transform = buildDiagnosticPipeline({
|
|
7156
7877
|
rootDirectory: scanDirectory,
|
|
7157
7878
|
userConfig: resolvedConfig.config,
|
|
7158
7879
|
readFileLinesSync: fileReader(filesService, scanDirectory),
|
|
7159
|
-
respectInlineDisables: input.respectInlineDisables
|
|
7880
|
+
respectInlineDisables: input.respectInlineDisables,
|
|
7881
|
+
showWarnings
|
|
7160
7882
|
});
|
|
7161
7883
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
7162
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7884
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7885
|
+
...checkReducedMotion(scanDirectory),
|
|
7886
|
+
...checkPnpmHardening(scanDirectory),
|
|
7887
|
+
...checkExpoProject(scanDirectory, project)
|
|
7888
|
+
];
|
|
7163
7889
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
7164
7890
|
const lintFailure = yield* Ref.make({
|
|
7165
7891
|
didFail: false,
|
|
@@ -7171,6 +7897,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7171
7897
|
didFail: false,
|
|
7172
7898
|
reason: null
|
|
7173
7899
|
});
|
|
7900
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
7901
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
7174
7902
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
7175
7903
|
const scanStartTime = Date.now();
|
|
7176
7904
|
let lastReportedTotalFileCount = 0;
|
|
@@ -7187,7 +7915,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7187
7915
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
7188
7916
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
7189
7917
|
lastReportedTotalFileCount = totalFileCount;
|
|
7190
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
7918
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
7191
7919
|
}
|
|
7192
7920
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
7193
7921
|
yield* Ref.set(lintFailure, {
|
|
@@ -7202,7 +7930,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7202
7930
|
const lintFailureState = yield* Ref.get(lintFailure);
|
|
7203
7931
|
yield* afterLint(lintFailureState.didFail);
|
|
7204
7932
|
if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
|
|
7205
|
-
const shouldRunDeadCode = input.runDeadCode && !isDiffMode;
|
|
7933
|
+
const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
|
|
7206
7934
|
const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update("Analyzing dead code...").pipe(Effect.andThen(Stream.runCollect(applyPerElementPipeline(deadCodeService.run({
|
|
7207
7935
|
rootDirectory: scanDirectory,
|
|
7208
7936
|
userConfig: resolvedConfig.config
|
|
@@ -7214,10 +7942,12 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7214
7942
|
return Stream.empty;
|
|
7215
7943
|
}))))))));
|
|
7216
7944
|
const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
|
|
7217
|
-
const
|
|
7945
|
+
const scanElapsedMilliseconds = Date.now() - scanStartTime;
|
|
7946
|
+
const scanElapsedSeconds = (scanElapsedMilliseconds / 1e3).toFixed(1);
|
|
7218
7947
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
7219
7948
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
7220
|
-
else yield* scanProgress.
|
|
7949
|
+
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
7950
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
7221
7951
|
yield* reporterService.finalize;
|
|
7222
7952
|
const finalDiagnostics = [
|
|
7223
7953
|
...envCollected,
|
|
@@ -7257,7 +7987,10 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7257
7987
|
lintFailureReasonKind: lintFailureState.reasonKind,
|
|
7258
7988
|
lintPartialFailures,
|
|
7259
7989
|
didDeadCodeFail: deadCodeFailureState.didFail,
|
|
7260
|
-
deadCodeFailureReason: deadCodeFailureState.reason
|
|
7990
|
+
deadCodeFailureReason: deadCodeFailureState.reason,
|
|
7991
|
+
scannedFileCount: totalFileCount,
|
|
7992
|
+
scannedFilePaths,
|
|
7993
|
+
scanElapsedMilliseconds
|
|
7261
7994
|
};
|
|
7262
7995
|
}).pipe(Effect.withSpan("runInspect", { attributes: {
|
|
7263
7996
|
"inspect.directory": input.directory,
|
|
@@ -7266,7 +7999,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7266
7999
|
"inspect.isCi": input.isCi,
|
|
7267
8000
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
7268
8001
|
} }));
|
|
7269
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
7270
8002
|
const parseNodeVersion = (versionString) => {
|
|
7271
8003
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
7272
8004
|
return {
|
|
@@ -7383,7 +8115,7 @@ const isPathInsideDirectory = (childAbsolutePath, parentAbsolutePath) => {
|
|
|
7383
8115
|
static layerNode = Layer.effect(StagedFiles, Effect.gen(function* () {
|
|
7384
8116
|
const git = yield* Git;
|
|
7385
8117
|
return StagedFiles.of({
|
|
7386
|
-
discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter(
|
|
8118
|
+
discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter(isLintableSourceFile))),
|
|
7387
8119
|
materialize: ({ directory, stagedFiles, tempDirectory }) => Effect.gen(function* () {
|
|
7388
8120
|
const materializedFiles = [];
|
|
7389
8121
|
const resolvedTempDirectory = path.resolve(tempDirectory);
|
|
@@ -7565,6 +8297,26 @@ const buildJsonReport = (input) => {
|
|
|
7565
8297
|
};
|
|
7566
8298
|
};
|
|
7567
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
|
+
/**
|
|
7568
8320
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
7569
8321
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
7570
8322
|
* spawn, not `spawnSync`).
|
|
@@ -7592,7 +8344,7 @@ const getDiffInfo = (directory, explicitBaseBranch) => Effect.runPromise(Effect.
|
|
|
7592
8344
|
GitBaseBranchInvalid: (reason) => Effect.die(new Error(reason.detail)),
|
|
7593
8345
|
GitBaseBranchMissing: (reason) => Effect.die(new Error(reason.message))
|
|
7594
8346
|
})));
|
|
7595
|
-
const filterSourceFiles = (filePaths) => filePaths.filter(
|
|
8347
|
+
const filterSourceFiles = (filePaths) => filePaths.filter(isLintableSourceFile);
|
|
7596
8348
|
var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
7597
8349
|
let p = process || {}, argv = p.argv || [], env = p.env || {};
|
|
7598
8350
|
let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
|
|
@@ -7670,7 +8422,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
|
|
|
7670
8422
|
const clearAutoSuppressionCaches = () => {};
|
|
7671
8423
|
//#endregion
|
|
7672
8424
|
//#region ../api/dist/index.js
|
|
7673
|
-
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);
|
|
7674
8426
|
const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
7675
8427
|
const effectiveConfig = configOverride ?? scanTarget.userConfig;
|
|
7676
8428
|
const includePaths = options.includePaths ?? [];
|
|
@@ -7679,6 +8431,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7679
8431
|
includePaths,
|
|
7680
8432
|
customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
|
|
7681
8433
|
respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
|
|
8434
|
+
warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
|
|
7682
8435
|
adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
|
|
7683
8436
|
ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
|
|
7684
8437
|
runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
|
|
@@ -7688,13 +8441,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7688
8441
|
};
|
|
7689
8442
|
const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
7690
8443
|
if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
|
|
7691
|
-
const skippedChecks =
|
|
7692
|
-
if (output.didLintFail) skippedChecks.push("lint");
|
|
7693
|
-
if (output.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
7694
|
-
const skippedCheckReasons = {};
|
|
7695
|
-
if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
|
|
7696
|
-
else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
|
|
7697
|
-
if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
|
|
8444
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
|
|
7698
8445
|
return {
|
|
7699
8446
|
diagnostics: [...output.diagnostics],
|
|
7700
8447
|
score: output.score,
|
|
@@ -7706,8 +8453,8 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
|
7706
8453
|
};
|
|
7707
8454
|
const diagnose = async (directory, options = {}) => {
|
|
7708
8455
|
const startTime = globalThis.performance.now();
|
|
7709
|
-
const program = buildInspectProgram(resolveScanTarget(directory), options);
|
|
7710
|
-
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);
|
|
7711
8458
|
};
|
|
7712
8459
|
//#endregion
|
|
7713
8460
|
//#region src/index.ts
|
|
@@ -7716,6 +8463,7 @@ const clearCaches = () => {
|
|
|
7716
8463
|
clearConfigCache();
|
|
7717
8464
|
clearPackageJsonCache();
|
|
7718
8465
|
clearIgnorePatternsCache();
|
|
8466
|
+
clearPackageRoleCache();
|
|
7719
8467
|
clearAutoSuppressionCaches();
|
|
7720
8468
|
};
|
|
7721
8469
|
const toJsonReport = (result, options) => buildJsonReport({
|
|
@@ -7739,4 +8487,5 @@ const toJsonReport = (result, options) => buildJsonReport({
|
|
|
7739
8487
|
//#endregion
|
|
7740
8488
|
export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
|
|
7741
8489
|
|
|
7742
|
-
//# sourceMappingURL=index.js.map
|
|
8490
|
+
//# sourceMappingURL=index.js.map
|
|
8491
|
+
//# debugId=fce73b02-d297-5132-af08-817f37e1467c
|