react-doctor 0.2.14-dev.7b4ddf7 → 0.2.14-dev.938376
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/cli.js +12608 -3512
- package/dist/index.d.ts +95 -14
- package/dist/index.js +1095 -229
- package/package.json +9 -5
- package/dist/cli-logger-CSZagq1E.js +0 -7564
- package/dist/rolldown-runtime-uZX_iqCz.js +0 -35
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as Redacted from "effect/Redacted";
|
|
|
14
14
|
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
15
15
|
import * as Otlp from "effect/unstable/observability/Otlp";
|
|
16
16
|
import * as Context from "effect/Context";
|
|
17
|
+
import os from "node:os";
|
|
17
18
|
import * as Console from "effect/Console";
|
|
18
19
|
import * as Fiber from "effect/Fiber";
|
|
19
20
|
import * as Filter from "effect/Filter";
|
|
@@ -26,7 +27,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
|
|
|
26
27
|
import * as NodePath from "@effect/platform-node-shared/NodePath";
|
|
27
28
|
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
28
29
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
29
|
-
import os from "node:os";
|
|
30
30
|
import * as ts from "typescript";
|
|
31
31
|
import { gzipSync } from "node:zlib";
|
|
32
32
|
//#region \0rolldown/runtime.js
|
|
@@ -59,6 +59,7 @@ var Diagnostic = class extends Schema.Class("Diagnostic")({
|
|
|
59
59
|
plugin: Schema.String,
|
|
60
60
|
rule: Schema.String,
|
|
61
61
|
severity: Severity,
|
|
62
|
+
title: Schema.optional(Schema.String),
|
|
62
63
|
message: Schema.String,
|
|
63
64
|
help: Schema.String,
|
|
64
65
|
url: Schema.optional(Schema.String),
|
|
@@ -2094,6 +2095,8 @@ const isFile = (filePath) => {
|
|
|
2094
2095
|
}
|
|
2095
2096
|
};
|
|
2096
2097
|
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
2098
|
+
const GENERATED_BUNDLE_FILE_PATTERN = /\.(iife|umd|global|min)\.js$/i;
|
|
2099
|
+
const MINIFIED_SNIFF_BYTES = 65536;
|
|
2097
2100
|
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
2098
2101
|
const IGNORED_DIRECTORIES = new Set([
|
|
2099
2102
|
".git",
|
|
@@ -2109,6 +2112,34 @@ const IGNORED_DIRECTORIES = new Set([
|
|
|
2109
2112
|
"out",
|
|
2110
2113
|
"storybook-static"
|
|
2111
2114
|
]);
|
|
2115
|
+
const isLintableSourceFile = (filePath) => SOURCE_FILE_PATTERN.test(filePath) && !GENERATED_BUNDLE_FILE_PATTERN.test(filePath);
|
|
2116
|
+
const isMinifiedSource = (absolutePath) => {
|
|
2117
|
+
let fileDescriptor;
|
|
2118
|
+
try {
|
|
2119
|
+
fileDescriptor = fs.openSync(absolutePath, "r");
|
|
2120
|
+
const buffer = Buffer.alloc(MINIFIED_SNIFF_BYTES);
|
|
2121
|
+
const bytesRead = fs.readSync(fileDescriptor, buffer, 0, MINIFIED_SNIFF_BYTES, 0);
|
|
2122
|
+
const prefix = buffer.toString("utf8", 0, bytesRead);
|
|
2123
|
+
const lines = prefix.split("\n");
|
|
2124
|
+
const longestLineLength = lines.reduce((longest, line) => Math.max(longest, line.length), 0);
|
|
2125
|
+
const averageLineLength = prefix.length / lines.length;
|
|
2126
|
+
return longestLineLength > 1e3 && averageLineLength > 500;
|
|
2127
|
+
} catch {
|
|
2128
|
+
return false;
|
|
2129
|
+
} finally {
|
|
2130
|
+
if (fileDescriptor !== void 0) fs.closeSync(fileDescriptor);
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
2133
|
+
const isLargeMinifiedFile = (absolutePath) => {
|
|
2134
|
+
let sizeBytes;
|
|
2135
|
+
try {
|
|
2136
|
+
sizeBytes = fs.statSync(absolutePath).size;
|
|
2137
|
+
} catch {
|
|
2138
|
+
return false;
|
|
2139
|
+
}
|
|
2140
|
+
if (sizeBytes < 2e4) return false;
|
|
2141
|
+
return isMinifiedSource(absolutePath);
|
|
2142
|
+
};
|
|
2112
2143
|
const IGNORABLE_READDIR_ERROR_CODES = new Set([
|
|
2113
2144
|
"EACCES",
|
|
2114
2145
|
"EPERM",
|
|
@@ -2139,7 +2170,7 @@ const countSourceFilesViaFilesystem = (rootDirectory) => {
|
|
|
2139
2170
|
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
|
|
2140
2171
|
continue;
|
|
2141
2172
|
}
|
|
2142
|
-
if (entry.isFile() &&
|
|
2173
|
+
if (entry.isFile() && isLintableSourceFile(entry.name) && !isLargeMinifiedFile(path.join(currentDirectory, entry.name))) count++;
|
|
2143
2174
|
}
|
|
2144
2175
|
}
|
|
2145
2176
|
return count;
|
|
@@ -2157,7 +2188,7 @@ const countSourceFilesViaGit = (rootDirectory) => {
|
|
|
2157
2188
|
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
2158
2189
|
});
|
|
2159
2190
|
if (result.error || result.status !== 0) return null;
|
|
2160
|
-
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 &&
|
|
2191
|
+
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath) && !isLargeMinifiedFile(path.resolve(rootDirectory, filePath))).length;
|
|
2161
2192
|
};
|
|
2162
2193
|
const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
|
|
2163
2194
|
const cachedPackageJsons = /* @__PURE__ */ new Map();
|
|
@@ -2843,29 +2874,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
|
2843
2874
|
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
2844
2875
|
};
|
|
2845
2876
|
};
|
|
2846
|
-
const
|
|
2847
|
-
|
|
2877
|
+
const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
|
|
2878
|
+
const rootValue = select(rootPackageJson);
|
|
2879
|
+
if (rootValue !== null) return rootValue;
|
|
2848
2880
|
const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
|
|
2849
|
-
if (patterns.length === 0) return
|
|
2881
|
+
if (patterns.length === 0) return null;
|
|
2850
2882
|
const visitedDirectories = /* @__PURE__ */ new Set();
|
|
2851
2883
|
for (const pattern of patterns) {
|
|
2852
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
2884
|
+
const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
|
|
2853
2885
|
for (const workspaceDirectory of directories) {
|
|
2854
2886
|
if (visitedDirectories.has(workspaceDirectory)) continue;
|
|
2855
2887
|
visitedDirectories.add(workspaceDirectory);
|
|
2856
|
-
|
|
2888
|
+
const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
|
|
2889
|
+
if (value !== null) return value;
|
|
2857
2890
|
}
|
|
2858
2891
|
}
|
|
2859
|
-
return
|
|
2892
|
+
return null;
|
|
2860
2893
|
};
|
|
2894
|
+
const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
|
|
2861
2895
|
const NAMES = new Set([
|
|
2862
2896
|
"react-native",
|
|
2863
2897
|
"react-native-tvos",
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2898
|
+
...new Set([
|
|
2899
|
+
"expo",
|
|
2900
|
+
"expo-router",
|
|
2901
|
+
"@expo/cli",
|
|
2902
|
+
"@expo/metro-config",
|
|
2903
|
+
"@expo/metro-runtime"
|
|
2904
|
+
]),
|
|
2869
2905
|
"react-native-windows",
|
|
2870
2906
|
"react-native-macos"
|
|
2871
2907
|
]);
|
|
@@ -2889,6 +2925,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
|
|
|
2889
2925
|
return false;
|
|
2890
2926
|
};
|
|
2891
2927
|
const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
|
|
2928
|
+
const getExpoDependencySpec = (packageJson) => {
|
|
2929
|
+
const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
|
|
2930
|
+
return typeof spec === "string" ? spec : null;
|
|
2931
|
+
};
|
|
2932
|
+
const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
|
|
2892
2933
|
const getPreactVersion = (packageJson) => {
|
|
2893
2934
|
return {
|
|
2894
2935
|
...packageJson.peerDependencies,
|
|
@@ -3128,6 +3169,19 @@ const discoverProject = (directory) => {
|
|
|
3128
3169
|
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
3129
3170
|
const sourceFileCount = countSourceFiles(directory);
|
|
3130
3171
|
const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
|
|
3172
|
+
let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
|
|
3173
|
+
if (expoVersion !== null && isCatalogReference(expoVersion)) {
|
|
3174
|
+
const catalogName = extractCatalogName(expoVersion);
|
|
3175
|
+
let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
|
|
3176
|
+
if (!resolvedExpoVersion) {
|
|
3177
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
3178
|
+
if (monorepoRoot) {
|
|
3179
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
3180
|
+
if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
expoVersion = resolvedExpoVersion ?? expoVersion;
|
|
3184
|
+
}
|
|
3131
3185
|
const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
|
|
3132
3186
|
const preactVersion = getPreactVersion(packageJson);
|
|
3133
3187
|
const projectInfo = {
|
|
@@ -3145,6 +3199,7 @@ const discoverProject = (directory) => {
|
|
|
3145
3199
|
preactVersion,
|
|
3146
3200
|
preactMajorVersion: parseReactMajor(preactVersion),
|
|
3147
3201
|
hasReactNativeWorkspace,
|
|
3202
|
+
expoVersion,
|
|
3148
3203
|
hasReanimated,
|
|
3149
3204
|
sourceFileCount
|
|
3150
3205
|
};
|
|
@@ -3240,7 +3295,18 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
3240
3295
|
];
|
|
3241
3296
|
const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
3242
3297
|
const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
|
|
3298
|
+
const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192;
|
|
3243
3299
|
const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
|
|
3300
|
+
const DIAGNOSTIC_CATEGORY_BUCKETS = [
|
|
3301
|
+
"Security",
|
|
3302
|
+
"Bugs",
|
|
3303
|
+
"Performance",
|
|
3304
|
+
"Accessibility",
|
|
3305
|
+
"Maintainability"
|
|
3306
|
+
];
|
|
3307
|
+
const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
|
|
3308
|
+
const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
|
|
3309
|
+
const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
|
|
3244
3310
|
const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
|
|
3245
3311
|
const CONFIG_CACHE_TTL_MS = 300 * 1e3;
|
|
3246
3312
|
var InvalidGlobPatternError = class extends Error {
|
|
@@ -3360,10 +3426,11 @@ const restampSeverity = (diagnostic, override) => {
|
|
|
3360
3426
|
*/
|
|
3361
3427
|
const buildRuleSeverityControls = (config) => {
|
|
3362
3428
|
if (!config) return void 0;
|
|
3363
|
-
if (config.rules === void 0 && config.categories === void 0
|
|
3429
|
+
if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
|
|
3364
3430
|
return {
|
|
3365
3431
|
...config.rules !== void 0 ? { rules: config.rules } : {},
|
|
3366
|
-
...config.categories !== void 0 ? { categories: config.categories } : {}
|
|
3432
|
+
...config.categories !== void 0 ? { categories: config.categories } : {},
|
|
3433
|
+
...config.buckets !== void 0 ? { buckets: config.buckets } : {}
|
|
3367
3434
|
};
|
|
3368
3435
|
};
|
|
3369
3436
|
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
@@ -3727,6 +3794,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
|
|
|
3727
3794
|
}
|
|
3728
3795
|
return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
|
|
3729
3796
|
};
|
|
3797
|
+
const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
|
|
3798
|
+
const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
|
|
3799
|
+
const findNearestPackageDirectory = (filename) => {
|
|
3800
|
+
if (!filename) return null;
|
|
3801
|
+
const fromCache = cachedPackageDirectoryByFilename.get(filename);
|
|
3802
|
+
if (fromCache !== void 0) return fromCache;
|
|
3803
|
+
let currentDirectory = path.dirname(filename);
|
|
3804
|
+
while (true) {
|
|
3805
|
+
const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
|
|
3806
|
+
let hasPackageJson = false;
|
|
3807
|
+
try {
|
|
3808
|
+
hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
|
|
3809
|
+
} catch {
|
|
3810
|
+
hasPackageJson = false;
|
|
3811
|
+
}
|
|
3812
|
+
if (hasPackageJson) {
|
|
3813
|
+
cachedPackageDirectoryByFilename.set(filename, currentDirectory);
|
|
3814
|
+
return currentDirectory;
|
|
3815
|
+
}
|
|
3816
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
3817
|
+
if (parentDirectory === currentDirectory) {
|
|
3818
|
+
cachedPackageDirectoryByFilename.set(filename, null);
|
|
3819
|
+
return null;
|
|
3820
|
+
}
|
|
3821
|
+
currentDirectory = parentDirectory;
|
|
3822
|
+
}
|
|
3823
|
+
};
|
|
3824
|
+
const readManifest = (packageJsonPath) => {
|
|
3825
|
+
try {
|
|
3826
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
3827
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
3828
|
+
return null;
|
|
3829
|
+
} catch {
|
|
3830
|
+
return null;
|
|
3831
|
+
}
|
|
3832
|
+
};
|
|
3833
|
+
const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
|
|
3834
|
+
const classifyByDirectoryCohort = (packageDirectory) => {
|
|
3835
|
+
let current = packageDirectory;
|
|
3836
|
+
while (true) {
|
|
3837
|
+
if (path.basename(current) === "apps") return "app";
|
|
3838
|
+
const parent = path.dirname(current);
|
|
3839
|
+
if (parent === current) return null;
|
|
3840
|
+
current = parent;
|
|
3841
|
+
}
|
|
3842
|
+
};
|
|
3843
|
+
const clearPackageRoleCache = () => {
|
|
3844
|
+
cachedRoleByPackageDirectory.clear();
|
|
3845
|
+
cachedPackageDirectoryByFilename.clear();
|
|
3846
|
+
};
|
|
3847
|
+
const classifyPackageRole = (filename) => {
|
|
3848
|
+
if (!filename) return "unknown";
|
|
3849
|
+
const packageDirectory = findNearestPackageDirectory(filename);
|
|
3850
|
+
if (!packageDirectory) return "unknown";
|
|
3851
|
+
const cached = cachedRoleByPackageDirectory.get(packageDirectory);
|
|
3852
|
+
if (cached !== void 0) return cached;
|
|
3853
|
+
const manifest = readManifest(path.join(packageDirectory, "package.json"));
|
|
3854
|
+
let result;
|
|
3855
|
+
if (manifest && hasPublishContract(manifest)) result = "library";
|
|
3856
|
+
else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
|
|
3857
|
+
cachedRoleByPackageDirectory.set(packageDirectory, result);
|
|
3858
|
+
return result;
|
|
3859
|
+
};
|
|
3730
3860
|
/**
|
|
3731
3861
|
* Resolves the absolute path to read for a diagnostic's `filePath`,
|
|
3732
3862
|
* accounting for the various shapes oxlint emits:
|
|
@@ -3862,10 +3992,13 @@ const collectStringSet = (values) => {
|
|
|
3862
3992
|
* wins over `test-noise`)
|
|
3863
3993
|
* 2. severity overrides (top-level `rules` / `categories`, with
|
|
3864
3994
|
* `"off"` dropping)
|
|
3865
|
-
* 3.
|
|
3866
|
-
*
|
|
3995
|
+
* 3. warning suppression (only when `showWarnings` is false: drops every
|
|
3996
|
+
* `"warning"`-severity diagnostic unless a severity override opts a
|
|
3997
|
+
* specific rule / category back in)
|
|
3998
|
+
* 4. ignore filters (rules / file patterns / per-file overrides)
|
|
3999
|
+
* 5. `rn-no-raw-text` suppression via configured `textComponents` and
|
|
3867
4000
|
* `rawTextWrapperComponents` (config-driven JSX enclosure checks)
|
|
3868
|
-
*
|
|
4001
|
+
* 6. inline suppressions (`// react-doctor-disable-next-line ...`)
|
|
3869
4002
|
*
|
|
3870
4003
|
* Returns `null` when the diagnostic is dropped, the (possibly
|
|
3871
4004
|
* severity-restamped) diagnostic otherwise.
|
|
@@ -3875,7 +4008,7 @@ const collectStringSet = (values) => {
|
|
|
3875
4008
|
* `mergeAndFilterDiagnostics` wrapper apply this closure per element.
|
|
3876
4009
|
*/
|
|
3877
4010
|
const buildDiagnosticPipeline = (input) => {
|
|
3878
|
-
const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables } = input;
|
|
4011
|
+
const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables, showWarnings } = input;
|
|
3879
4012
|
const severityControls = buildRuleSeverityControls(userConfig);
|
|
3880
4013
|
const ignoredRules = new Set(Array.isArray(userConfig?.ignore?.rules) ? userConfig.ignore.rules.filter((rule) => typeof rule === "string") : []);
|
|
3881
4014
|
const ignoredFilePatterns = compileIgnoredFilePatterns(userConfig);
|
|
@@ -3886,6 +4019,15 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3886
4019
|
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
3887
4020
|
const fileLinesCache = /* @__PURE__ */ new Map();
|
|
3888
4021
|
const testFileCache = /* @__PURE__ */ new Map();
|
|
4022
|
+
const libraryFileCache = /* @__PURE__ */ new Map();
|
|
4023
|
+
const isLibraryFile = (filePath) => {
|
|
4024
|
+
let cached = libraryFileCache.get(filePath);
|
|
4025
|
+
if (cached === void 0) {
|
|
4026
|
+
cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
|
|
4027
|
+
libraryFileCache.set(filePath, cached);
|
|
4028
|
+
}
|
|
4029
|
+
return cached;
|
|
4030
|
+
};
|
|
3889
4031
|
const getFileLines = (filePath) => {
|
|
3890
4032
|
const cached = fileLinesCache.get(filePath);
|
|
3891
4033
|
if (cached !== void 0) return cached;
|
|
@@ -3912,6 +4054,10 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3912
4054
|
for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
|
|
3913
4055
|
return false;
|
|
3914
4056
|
};
|
|
4057
|
+
const isAppOnlyRule = (ruleIdentifier) => {
|
|
4058
|
+
for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
|
|
4059
|
+
return false;
|
|
4060
|
+
};
|
|
3915
4061
|
const isRnRawTextSuppressedByConfig = (diagnostic) => {
|
|
3916
4062
|
if (diagnostic.rule !== "rn-no-raw-text") return false;
|
|
3917
4063
|
if (diagnostic.line <= 0) return false;
|
|
@@ -3925,15 +4071,22 @@ const buildDiagnosticPipeline = (input) => {
|
|
|
3925
4071
|
return { apply: (diagnostic) => {
|
|
3926
4072
|
if (shouldAutoSuppress(diagnostic)) return null;
|
|
3927
4073
|
let current = diagnostic;
|
|
4074
|
+
let explicitSeverityOverride;
|
|
4075
|
+
let explicitRuleOverride;
|
|
3928
4076
|
if (severityControls) {
|
|
3929
4077
|
const { ruleKey, category } = getDiagnosticRuleIdentity(current);
|
|
3930
|
-
|
|
4078
|
+
explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
|
|
4079
|
+
explicitSeverityOverride = resolveRuleSeverityOverride({
|
|
3931
4080
|
ruleKey,
|
|
3932
4081
|
category
|
|
3933
4082
|
}, severityControls);
|
|
3934
|
-
if (
|
|
3935
|
-
if (
|
|
4083
|
+
if (explicitSeverityOverride === "off") return null;
|
|
4084
|
+
if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
|
|
4085
|
+
}
|
|
4086
|
+
if (explicitRuleOverride === void 0) {
|
|
4087
|
+
if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
|
|
3936
4088
|
}
|
|
4089
|
+
if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
|
|
3937
4090
|
if (userConfig) {
|
|
3938
4091
|
if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
|
|
3939
4092
|
if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) return null;
|
|
@@ -4118,6 +4271,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
4118
4271
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
4119
4272
|
}).pipe(Effect.orDie));
|
|
4120
4273
|
/**
|
|
4274
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
4275
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
4276
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
4277
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
4278
|
+
*/
|
|
4279
|
+
const resolveScanConcurrency = (requested) => {
|
|
4280
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
4281
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
4282
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
4283
|
+
};
|
|
4284
|
+
/**
|
|
4121
4285
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
4122
4286
|
* startup so the eval harness can raise the budget under sandbox
|
|
4123
4287
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -4137,6 +4301,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
4137
4301
|
* tests that exercise the cap behavior.
|
|
4138
4302
|
*/
|
|
4139
4303
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
4304
|
+
/**
|
|
4305
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
4306
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
4307
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
4308
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
4309
|
+
* CI callers that never touch the flag:
|
|
4310
|
+
*
|
|
4311
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
4312
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
4313
|
+
* - a positive integer → that many workers (clamped)
|
|
4314
|
+
*
|
|
4315
|
+
* The resolved value is always within
|
|
4316
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
4317
|
+
*/
|
|
4318
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
4319
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
4320
|
+
if (raw === void 0) return 1;
|
|
4321
|
+
const normalized = raw.trim().toLowerCase();
|
|
4322
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
4323
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
4324
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
4325
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
4326
|
+
return resolveScanConcurrency(parsed);
|
|
4327
|
+
} }) {};
|
|
4140
4328
|
const DIAGNOSTIC_SURFACES = [
|
|
4141
4329
|
"cli",
|
|
4142
4330
|
"prComment",
|
|
@@ -4165,10 +4353,18 @@ const VALID_RULE_SEVERITIES = [
|
|
|
4165
4353
|
"warn",
|
|
4166
4354
|
"off"
|
|
4167
4355
|
];
|
|
4356
|
+
const KNOWN_CATEGORY_LABEL = DIAGNOSTIC_CATEGORY_BUCKETS.join(", ");
|
|
4357
|
+
const isDiagnosticCategoryBucket = (value) => DIAGNOSTIC_CATEGORY_BUCKETS.includes(value);
|
|
4358
|
+
const filterKnownCategories = (fieldName, categories) => categories.filter((category) => {
|
|
4359
|
+
if (isDiagnosticCategoryBucket(category)) return true;
|
|
4360
|
+
warnConfigIssue(`config field "${fieldName}" lists "${category}", which is not a known category (expected one of: ${KNOWN_CATEGORY_LABEL}); ignoring the entry.`);
|
|
4361
|
+
return false;
|
|
4362
|
+
});
|
|
4168
4363
|
const BOOLEAN_FIELD_NAMES = [
|
|
4169
4364
|
"lint",
|
|
4170
4365
|
"deadCode",
|
|
4171
4366
|
"verbose",
|
|
4367
|
+
"warnings",
|
|
4172
4368
|
"customRulesOnly",
|
|
4173
4369
|
"share",
|
|
4174
4370
|
"noScore",
|
|
@@ -4217,13 +4413,15 @@ const validateSurfaceControls = (surface, rawControls) => {
|
|
|
4217
4413
|
warnConfigIssue(`config field "surfaces.${surface}" must be an object (got ${typeof rawControls}); ignoring this surface.`);
|
|
4218
4414
|
return;
|
|
4219
4415
|
}
|
|
4220
|
-
const
|
|
4416
|
+
const validatedSurfaceControls = {};
|
|
4221
4417
|
for (const fieldName of SURFACE_CONTROL_FIELD_NAMES) {
|
|
4222
4418
|
if (rawControls[fieldName] === void 0) continue;
|
|
4223
|
-
const
|
|
4224
|
-
|
|
4419
|
+
const qualifiedName = `surfaces.${surface}.${fieldName}`;
|
|
4420
|
+
const result = validateStringArrayField(qualifiedName, rawControls[fieldName]);
|
|
4421
|
+
if (result === void 0) continue;
|
|
4422
|
+
validatedSurfaceControls[fieldName] = fieldName === "includeCategories" || fieldName === "excludeCategories" ? filterKnownCategories(qualifiedName, result) : result;
|
|
4225
4423
|
}
|
|
4226
|
-
return
|
|
4424
|
+
return validatedSurfaceControls;
|
|
4227
4425
|
};
|
|
4228
4426
|
const validateSurfacesField = (rawSurfaces) => {
|
|
4229
4427
|
if (!isPlainObject$1(rawSurfaces)) {
|
|
@@ -4241,7 +4439,7 @@ const validateSurfacesField = (rawSurfaces) => {
|
|
|
4241
4439
|
}
|
|
4242
4440
|
return validated;
|
|
4243
4441
|
};
|
|
4244
|
-
const validateSeverityMap = (fieldName, rawMap) => {
|
|
4442
|
+
const validateSeverityMap = (fieldName, rawMap, keysAreCategories = false) => {
|
|
4245
4443
|
if (!isPlainObject$1(rawMap)) {
|
|
4246
4444
|
warnConfigIssue(`config field "${fieldName}" must be an object (got ${typeof rawMap}); ignoring this field.`);
|
|
4247
4445
|
return;
|
|
@@ -4252,6 +4450,10 @@ const validateSeverityMap = (fieldName, rawMap) => {
|
|
|
4252
4450
|
warnConfigIssue(`config field "${fieldName}" has an empty key; ignoring the entry.`);
|
|
4253
4451
|
continue;
|
|
4254
4452
|
}
|
|
4453
|
+
if (keysAreCategories && !isDiagnosticCategoryBucket(key)) {
|
|
4454
|
+
warnConfigIssue(`config field "${fieldName}.${key}" is not a known category (expected one of: ${KNOWN_CATEGORY_LABEL}); ignoring the entry.`);
|
|
4455
|
+
continue;
|
|
4456
|
+
}
|
|
4255
4457
|
if (!isRuleSeverity(value)) {
|
|
4256
4458
|
warnConfigIssue(`config field "${fieldName}.${key}" must be one of: ${VALID_RULE_SEVERITIES.join(", ")} (got ${formatType(value)}); ignoring the entry.`);
|
|
4257
4459
|
continue;
|
|
@@ -4272,7 +4474,7 @@ const validateConfigTypes = (config) => {
|
|
|
4272
4474
|
for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
|
|
4273
4475
|
for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
|
|
4274
4476
|
applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
|
|
4275
|
-
for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value));
|
|
4477
|
+
for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
|
|
4276
4478
|
applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
|
|
4277
4479
|
return validated;
|
|
4278
4480
|
};
|
|
@@ -4283,16 +4485,23 @@ const CONFIG_FILENAME = "react-doctor.config.json";
|
|
|
4283
4485
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
4284
4486
|
const loadConfigFromDirectory = (directory) => {
|
|
4285
4487
|
const configFilePath = path.join(directory, CONFIG_FILENAME);
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4488
|
+
let sawBrokenConfigFile = false;
|
|
4489
|
+
if (isFile(configFilePath)) {
|
|
4490
|
+
try {
|
|
4491
|
+
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
4492
|
+
const parsed = JSON.parse(fileContent);
|
|
4493
|
+
if (isPlainObject(parsed)) return {
|
|
4494
|
+
status: "found",
|
|
4495
|
+
loaded: {
|
|
4496
|
+
config: validateConfigTypes(parsed),
|
|
4497
|
+
sourceDirectory: directory
|
|
4498
|
+
}
|
|
4499
|
+
};
|
|
4500
|
+
warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
4501
|
+
} catch (error) {
|
|
4502
|
+
warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4503
|
+
}
|
|
4504
|
+
sawBrokenConfigFile = true;
|
|
4296
4505
|
}
|
|
4297
4506
|
const packageJsonPath = path.join(directory, "package.json");
|
|
4298
4507
|
if (isFile(packageJsonPath)) try {
|
|
@@ -4301,14 +4510,18 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
4301
4510
|
if (isPlainObject(packageJson)) {
|
|
4302
4511
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
4303
4512
|
if (isPlainObject(embeddedConfig)) return {
|
|
4304
|
-
|
|
4305
|
-
|
|
4513
|
+
status: "found",
|
|
4514
|
+
loaded: {
|
|
4515
|
+
config: validateConfigTypes(embeddedConfig),
|
|
4516
|
+
sourceDirectory: directory
|
|
4517
|
+
}
|
|
4306
4518
|
};
|
|
4307
4519
|
}
|
|
4308
|
-
} catch {
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4520
|
+
} catch {}
|
|
4521
|
+
return {
|
|
4522
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
4523
|
+
loaded: null
|
|
4524
|
+
};
|
|
4312
4525
|
};
|
|
4313
4526
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
4314
4527
|
const clearConfigCache = () => {
|
|
@@ -4317,21 +4530,21 @@ const clearConfigCache = () => {
|
|
|
4317
4530
|
const loadConfigWithSource = (rootDirectory) => {
|
|
4318
4531
|
const cached = cachedConfigs.get(rootDirectory);
|
|
4319
4532
|
if (cached !== void 0) return cached;
|
|
4320
|
-
const
|
|
4321
|
-
if (
|
|
4322
|
-
cachedConfigs.set(rootDirectory,
|
|
4323
|
-
return
|
|
4533
|
+
const localResult = loadConfigFromDirectory(rootDirectory);
|
|
4534
|
+
if (localResult.status === "found") {
|
|
4535
|
+
cachedConfigs.set(rootDirectory, localResult.loaded);
|
|
4536
|
+
return localResult.loaded;
|
|
4324
4537
|
}
|
|
4325
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
4538
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) {
|
|
4326
4539
|
cachedConfigs.set(rootDirectory, null);
|
|
4327
4540
|
return null;
|
|
4328
4541
|
}
|
|
4329
4542
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
4330
4543
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
4331
|
-
const
|
|
4332
|
-
if (
|
|
4333
|
-
cachedConfigs.set(rootDirectory,
|
|
4334
|
-
return
|
|
4544
|
+
const ancestorResult = loadConfigFromDirectory(ancestorDirectory);
|
|
4545
|
+
if (ancestorResult.status === "found") {
|
|
4546
|
+
cachedConfigs.set(rootDirectory, ancestorResult.loaded);
|
|
4547
|
+
return ancestorResult.loaded;
|
|
4335
4548
|
}
|
|
4336
4549
|
if (isProjectBoundary(ancestorDirectory)) {
|
|
4337
4550
|
cachedConfigs.set(rootDirectory, null);
|
|
@@ -4356,11 +4569,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
|
4356
4569
|
}
|
|
4357
4570
|
return resolvedRootDir;
|
|
4358
4571
|
};
|
|
4359
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
4572
|
+
const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
4360
4573
|
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
4361
4574
|
const reactSubprojects = discoverReactSubprojects(directory);
|
|
4362
4575
|
if (reactSubprojects.length === 0) return null;
|
|
4363
4576
|
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
4577
|
+
if (options.allowAmbiguous === true) return null;
|
|
4364
4578
|
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
4365
4579
|
};
|
|
4366
4580
|
/**
|
|
@@ -4374,7 +4588,8 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4374
4588
|
* project root, if configured.
|
|
4375
4589
|
* 4. Walk into a nested React subproject when the requested
|
|
4376
4590
|
* directory has no `package.json` of its own (raises
|
|
4377
|
-
* `AmbiguousProjectError` when multiple candidates exist
|
|
4591
|
+
* `AmbiguousProjectError` when multiple candidates exist unless
|
|
4592
|
+
* the caller opts into keeping the wrapper directory).
|
|
4378
4593
|
*
|
|
4379
4594
|
* Throws `ProjectNotFoundError` when neither the requested directory
|
|
4380
4595
|
* nor any discoverable nested project has a `package.json`.
|
|
@@ -4386,14 +4601,14 @@ const resolveDiagnoseTarget = (directory) => {
|
|
|
4386
4601
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
4387
4602
|
* shell in agreement on what "the scan directory" means.
|
|
4388
4603
|
*/
|
|
4389
|
-
const resolveScanTarget = (requestedDirectory) => {
|
|
4604
|
+
const resolveScanTarget = (requestedDirectory, options = {}) => {
|
|
4390
4605
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
4391
4606
|
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
4392
4607
|
const userConfig = loadedConfig?.config ?? null;
|
|
4393
4608
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
4394
4609
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
4395
4610
|
const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
|
|
4396
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
|
|
4611
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
|
|
4397
4612
|
if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
|
|
4398
4613
|
return {
|
|
4399
4614
|
resolvedDirectory,
|
|
@@ -4403,6 +4618,359 @@ const resolveScanTarget = (requestedDirectory) => {
|
|
|
4403
4618
|
didRedirectViaRootDir: redirectedDirectory !== null
|
|
4404
4619
|
};
|
|
4405
4620
|
};
|
|
4621
|
+
const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
|
|
4622
|
+
const buildExpoCheckContext = (rootDirectory, expoVersion) => {
|
|
4623
|
+
const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
|
|
4624
|
+
return {
|
|
4625
|
+
rootDirectory,
|
|
4626
|
+
packageJson,
|
|
4627
|
+
directDependencyNames: getDirectDependencyNames(packageJson),
|
|
4628
|
+
expoSdkMajor: getLowestDependencyMajor(expoVersion)
|
|
4629
|
+
};
|
|
4630
|
+
};
|
|
4631
|
+
const buildExpoDiagnostic = (input) => ({
|
|
4632
|
+
filePath: input.filePath ?? "package.json",
|
|
4633
|
+
plugin: "react-doctor",
|
|
4634
|
+
rule: input.rule,
|
|
4635
|
+
severity: input.severity ?? "warning",
|
|
4636
|
+
message: input.message,
|
|
4637
|
+
help: input.help,
|
|
4638
|
+
line: input.line ?? 0,
|
|
4639
|
+
column: input.column ?? 0,
|
|
4640
|
+
category: input.category ?? "Correctness"
|
|
4641
|
+
});
|
|
4642
|
+
const CRITICAL_OVERRIDE_NAMES = new Set([
|
|
4643
|
+
"@expo/cli",
|
|
4644
|
+
"@expo/config",
|
|
4645
|
+
"@expo/metro-config",
|
|
4646
|
+
"@expo/metro-runtime",
|
|
4647
|
+
"@expo/metro",
|
|
4648
|
+
"metro"
|
|
4649
|
+
]);
|
|
4650
|
+
const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
|
|
4651
|
+
const collectOverrideNames = (packageJson) => new Set([
|
|
4652
|
+
...Object.keys(packageJson.overrides ?? {}),
|
|
4653
|
+
...Object.keys(packageJson.resolutions ?? {}),
|
|
4654
|
+
...Object.keys(packageJson.pnpm?.overrides ?? {})
|
|
4655
|
+
]);
|
|
4656
|
+
const checkExpoDependencyOverrides = (context) => {
|
|
4657
|
+
const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
|
|
4658
|
+
if (overriddenCriticalNames.length === 0) return [];
|
|
4659
|
+
const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
|
|
4660
|
+
return [buildExpoDiagnostic({
|
|
4661
|
+
rule: "expo-no-conflicting-dependency-override",
|
|
4662
|
+
message: `package.json pins SDK-critical ${overriddenCriticalNames.length === 1 ? "package" : "packages"} via overrides/resolutions (${quotedNames}) — these versions are tied to the Expo SDK release and overriding them is unsupported and may break Metro or native builds`,
|
|
4663
|
+
help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
|
|
4664
|
+
})];
|
|
4665
|
+
};
|
|
4666
|
+
const isPathGitIgnored = (rootDirectory, absolutePath) => {
|
|
4667
|
+
const result = spawnSync("git", [
|
|
4668
|
+
"check-ignore",
|
|
4669
|
+
"-q",
|
|
4670
|
+
absolutePath
|
|
4671
|
+
], {
|
|
4672
|
+
cwd: rootDirectory,
|
|
4673
|
+
stdio: [
|
|
4674
|
+
"ignore",
|
|
4675
|
+
"ignore",
|
|
4676
|
+
"ignore"
|
|
4677
|
+
]
|
|
4678
|
+
});
|
|
4679
|
+
if (result.error) return null;
|
|
4680
|
+
if (result.status === 0) return true;
|
|
4681
|
+
if (result.status === 1) return false;
|
|
4682
|
+
return null;
|
|
4683
|
+
};
|
|
4684
|
+
const LOCAL_ENV_FILE_NAMES = [
|
|
4685
|
+
".env.local",
|
|
4686
|
+
".env.development.local",
|
|
4687
|
+
".env.production.local",
|
|
4688
|
+
".env.test.local"
|
|
4689
|
+
];
|
|
4690
|
+
const checkExpoEnvLocalFiles = (context) => {
|
|
4691
|
+
const { rootDirectory } = context;
|
|
4692
|
+
const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
|
|
4693
|
+
const filePath = path.join(rootDirectory, fileName);
|
|
4694
|
+
if (!isFile(filePath)) return false;
|
|
4695
|
+
return isPathGitIgnored(rootDirectory, filePath) === false;
|
|
4696
|
+
});
|
|
4697
|
+
if (committedEnvFiles.length === 0) return [];
|
|
4698
|
+
return [buildExpoDiagnostic({
|
|
4699
|
+
rule: "expo-env-local-not-gitignored",
|
|
4700
|
+
category: "Security",
|
|
4701
|
+
message: `Local environment ${committedEnvFiles.length === 1 ? "file" : "files"} (${committedEnvFiles.join(", ")}) ${committedEnvFiles.length === 1 ? "is" : "are"} not ignored by Git — committing \`.env*.local\` risks leaking secrets and overriding committed defaults for everyone who clones the project`,
|
|
4702
|
+
help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
|
|
4703
|
+
})];
|
|
4704
|
+
};
|
|
4705
|
+
const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
|
|
4706
|
+
const UNIMODULES_HELP = "Remove every `@unimodules/*` and `react-native-unimodules` package — their functionality now lives in `expo-modules-core`. See https://expo.fyi/r/sdk-44-remove-unimodules";
|
|
4707
|
+
const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
|
|
4708
|
+
const unimodulesEntry = (packageName) => ({
|
|
4709
|
+
packageName,
|
|
4710
|
+
rule: "expo-no-unimodules-packages",
|
|
4711
|
+
message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
|
|
4712
|
+
help: UNIMODULES_HELP
|
|
4713
|
+
});
|
|
4714
|
+
const FLAGGED_DEPENDENCIES = [
|
|
4715
|
+
unimodulesEntry("@unimodules/core"),
|
|
4716
|
+
unimodulesEntry("@unimodules/react-native-adapter"),
|
|
4717
|
+
unimodulesEntry("react-native-unimodules"),
|
|
4718
|
+
{
|
|
4719
|
+
packageName: "expo-cli",
|
|
4720
|
+
rule: "expo-no-cli-dependencies",
|
|
4721
|
+
message: "`expo-cli` (the legacy global CLI) is a project dependency — the CLI now ships inside the `expo` package, and keeping `expo-cli` causes failures such as `unknown option --fix` when running `npx expo install --fix`",
|
|
4722
|
+
help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
|
|
4723
|
+
},
|
|
4724
|
+
{
|
|
4725
|
+
packageName: "eas-cli",
|
|
4726
|
+
rule: "expo-no-cli-dependencies",
|
|
4727
|
+
message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
|
|
4728
|
+
help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
|
|
4729
|
+
},
|
|
4730
|
+
{
|
|
4731
|
+
packageName: "expo-modules-autolinking",
|
|
4732
|
+
rule: "expo-no-redundant-dependency",
|
|
4733
|
+
message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
|
|
4734
|
+
help: "Remove `expo-modules-autolinking` from your package.json"
|
|
4735
|
+
},
|
|
4736
|
+
{
|
|
4737
|
+
packageName: "expo-dev-launcher",
|
|
4738
|
+
rule: "expo-no-redundant-dependency",
|
|
4739
|
+
message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
4740
|
+
help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
|
|
4741
|
+
},
|
|
4742
|
+
{
|
|
4743
|
+
packageName: "expo-dev-menu",
|
|
4744
|
+
rule: "expo-no-redundant-dependency",
|
|
4745
|
+
message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
|
|
4746
|
+
help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
|
|
4747
|
+
},
|
|
4748
|
+
{
|
|
4749
|
+
packageName: "expo-modules-core",
|
|
4750
|
+
rule: "expo-no-redundant-dependency",
|
|
4751
|
+
message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
|
|
4752
|
+
help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
|
|
4753
|
+
},
|
|
4754
|
+
{
|
|
4755
|
+
packageName: "@expo/metro-config",
|
|
4756
|
+
rule: "expo-no-redundant-dependency",
|
|
4757
|
+
message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
|
|
4758
|
+
help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
|
|
4759
|
+
},
|
|
4760
|
+
{
|
|
4761
|
+
packageName: "@types/react-native",
|
|
4762
|
+
rule: "expo-no-redundant-dependency",
|
|
4763
|
+
message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
|
|
4764
|
+
help: "Remove `@types/react-native` from your package.json",
|
|
4765
|
+
minSdkMajor: 48
|
|
4766
|
+
},
|
|
4767
|
+
{
|
|
4768
|
+
packageName: "@expo/config-plugins",
|
|
4769
|
+
rule: "expo-no-redundant-dependency",
|
|
4770
|
+
message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
|
|
4771
|
+
help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
|
|
4772
|
+
minSdkMajor: 48
|
|
4773
|
+
},
|
|
4774
|
+
{
|
|
4775
|
+
packageName: "@expo/prebuild-config",
|
|
4776
|
+
rule: "expo-no-redundant-dependency",
|
|
4777
|
+
message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
|
|
4778
|
+
help: "Remove `@expo/prebuild-config` from your package.json",
|
|
4779
|
+
minSdkMajor: 53
|
|
4780
|
+
},
|
|
4781
|
+
{
|
|
4782
|
+
packageName: "expo-permissions",
|
|
4783
|
+
rule: "expo-no-redundant-dependency",
|
|
4784
|
+
message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
|
|
4785
|
+
help: "Remove `expo-permissions` and request permissions from the relevant module instead",
|
|
4786
|
+
minSdkMajor: 50
|
|
4787
|
+
},
|
|
4788
|
+
{
|
|
4789
|
+
packageName: "expo-app-loading",
|
|
4790
|
+
rule: "expo-no-redundant-dependency",
|
|
4791
|
+
message: "\"expo-app-loading\" was removed in SDK 49",
|
|
4792
|
+
help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
|
|
4793
|
+
minSdkMajor: 49
|
|
4794
|
+
},
|
|
4795
|
+
{
|
|
4796
|
+
packageName: "expo-firebase-analytics",
|
|
4797
|
+
rule: "expo-no-redundant-dependency",
|
|
4798
|
+
message: "\"expo-firebase-analytics\" was removed in SDK 48",
|
|
4799
|
+
help: FIREBASE_HELP,
|
|
4800
|
+
minSdkMajor: 48
|
|
4801
|
+
},
|
|
4802
|
+
{
|
|
4803
|
+
packageName: "expo-firebase-recaptcha",
|
|
4804
|
+
rule: "expo-no-redundant-dependency",
|
|
4805
|
+
message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
|
|
4806
|
+
help: FIREBASE_HELP,
|
|
4807
|
+
minSdkMajor: 48
|
|
4808
|
+
},
|
|
4809
|
+
{
|
|
4810
|
+
packageName: "expo-firebase-core",
|
|
4811
|
+
rule: "expo-no-redundant-dependency",
|
|
4812
|
+
message: "\"expo-firebase-core\" was removed in SDK 48",
|
|
4813
|
+
help: FIREBASE_HELP,
|
|
4814
|
+
minSdkMajor: 48
|
|
4815
|
+
}
|
|
4816
|
+
];
|
|
4817
|
+
const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
|
|
4818
|
+
if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
|
|
4819
|
+
if (flaggedDependency.minSdkMajor === void 0) return true;
|
|
4820
|
+
return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
|
|
4821
|
+
}).map((flaggedDependency) => buildExpoDiagnostic({
|
|
4822
|
+
rule: flaggedDependency.rule,
|
|
4823
|
+
message: flaggedDependency.message,
|
|
4824
|
+
help: flaggedDependency.help
|
|
4825
|
+
}));
|
|
4826
|
+
const findLocalModuleNativeFiles = (rootDirectory) => {
|
|
4827
|
+
const modulesDirectory = path.join(rootDirectory, "modules");
|
|
4828
|
+
if (!isDirectory(modulesDirectory)) return [];
|
|
4829
|
+
const nativeFilePaths = [];
|
|
4830
|
+
for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
|
|
4831
|
+
if (!moduleEntry.isDirectory()) continue;
|
|
4832
|
+
const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
|
|
4833
|
+
const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
|
|
4834
|
+
if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
|
|
4835
|
+
const iosDirectory = path.join(moduleDirectory, "ios");
|
|
4836
|
+
if (isDirectory(iosDirectory)) {
|
|
4837
|
+
for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
return nativeFilePaths;
|
|
4841
|
+
};
|
|
4842
|
+
const checkExpoGitignore = (context) => {
|
|
4843
|
+
const { rootDirectory } = context;
|
|
4844
|
+
const diagnostics = [];
|
|
4845
|
+
const expoStateDirectory = path.join(rootDirectory, ".expo");
|
|
4846
|
+
if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
|
|
4847
|
+
rule: "expo-gitignore",
|
|
4848
|
+
message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
|
|
4849
|
+
help: "Add `.expo/` to your .gitignore"
|
|
4850
|
+
}));
|
|
4851
|
+
if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
|
|
4852
|
+
rule: "expo-gitignore",
|
|
4853
|
+
message: "The native `ios`/`android` directories of a local Expo module under `modules/` are gitignored — usually caused by an overly broad `ios`/`android` ignore rule",
|
|
4854
|
+
help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
|
|
4855
|
+
}));
|
|
4856
|
+
return diagnostics;
|
|
4857
|
+
};
|
|
4858
|
+
const LOCKFILE_NAMES = [
|
|
4859
|
+
"pnpm-lock.yaml",
|
|
4860
|
+
"yarn.lock",
|
|
4861
|
+
"package-lock.json",
|
|
4862
|
+
"bun.lockb",
|
|
4863
|
+
"bun.lock"
|
|
4864
|
+
];
|
|
4865
|
+
const checkExpoLockfile = (context) => {
|
|
4866
|
+
const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
|
|
4867
|
+
const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
|
|
4868
|
+
if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
|
|
4869
|
+
rule: "expo-lockfile",
|
|
4870
|
+
message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
|
|
4871
|
+
help: "Install dependencies with your package manager to generate a lock file, then commit it"
|
|
4872
|
+
})];
|
|
4873
|
+
if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
|
|
4874
|
+
rule: "expo-lockfile",
|
|
4875
|
+
message: `Multiple lock files detected (${presentLockfiles.join(", ")}) — CI environments such as EAS Build infer the package manager from the lock file, so this is ambiguous`,
|
|
4876
|
+
help: "Delete the lock files for the package managers you are not using and keep only one"
|
|
4877
|
+
})];
|
|
4878
|
+
return [];
|
|
4879
|
+
};
|
|
4880
|
+
const METRO_CONFIG_FILE_NAMES = [
|
|
4881
|
+
"metro.config.js",
|
|
4882
|
+
"metro.config.cjs",
|
|
4883
|
+
"metro.config.mjs",
|
|
4884
|
+
"metro.config.ts"
|
|
4885
|
+
];
|
|
4886
|
+
const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
|
|
4887
|
+
"expo/metro-config",
|
|
4888
|
+
"@sentry/react-native/metro",
|
|
4889
|
+
"getSentryExpoConfig"
|
|
4890
|
+
];
|
|
4891
|
+
const checkExpoMetroConfig = (context) => {
|
|
4892
|
+
const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
|
|
4893
|
+
if (metroConfigPath === void 0) return [];
|
|
4894
|
+
let contents;
|
|
4895
|
+
try {
|
|
4896
|
+
contents = fs.readFileSync(metroConfigPath, "utf-8");
|
|
4897
|
+
} catch {
|
|
4898
|
+
return [];
|
|
4899
|
+
}
|
|
4900
|
+
if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
|
|
4901
|
+
return [buildExpoDiagnostic({
|
|
4902
|
+
rule: "expo-metro-config",
|
|
4903
|
+
filePath: path.basename(metroConfigPath),
|
|
4904
|
+
message: "Your metro.config does not extend `expo/metro-config` — a custom Metro config that doesn't extend Expo's leads to unexpected, hard-to-debug bundling issues",
|
|
4905
|
+
help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
|
|
4906
|
+
})];
|
|
4907
|
+
};
|
|
4908
|
+
const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
|
|
4909
|
+
const checkExpoPackageJsonConflicts = (context) => {
|
|
4910
|
+
const { packageJson } = context;
|
|
4911
|
+
const diagnostics = [];
|
|
4912
|
+
const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
|
|
4913
|
+
if (conflictingScriptNames.length > 0) {
|
|
4914
|
+
const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
|
|
4915
|
+
const shadowsExpoCli = conflictingScriptNames.includes("expo");
|
|
4916
|
+
diagnostics.push(buildExpoDiagnostic({
|
|
4917
|
+
rule: "expo-package-json-conflict",
|
|
4918
|
+
message: `package.json defines ${quotedNames} ${conflictingScriptNames.length === 1 ? "as a script that conflicts" : "as scripts that conflict"} with binaries in node_modules/.bin${shadowsExpoCli ? " — a `expo` script shadows the Expo CLI and will likely cause build failures" : ""}`,
|
|
4919
|
+
help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
|
|
4920
|
+
}));
|
|
4921
|
+
}
|
|
4922
|
+
const packageName = packageJson.name;
|
|
4923
|
+
if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
|
|
4924
|
+
rule: "expo-package-json-conflict",
|
|
4925
|
+
message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
|
|
4926
|
+
help: "Rename your package so it no longer matches one of its dependencies"
|
|
4927
|
+
}));
|
|
4928
|
+
return diagnostics;
|
|
4929
|
+
};
|
|
4930
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
|
|
4931
|
+
const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
|
|
4932
|
+
const checkExpoRouterReactNavigation = (context) => {
|
|
4933
|
+
const { expoSdkMajor } = context;
|
|
4934
|
+
if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
|
|
4935
|
+
if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
|
|
4936
|
+
if (!context.directDependencyNames.has("expo-router")) return [];
|
|
4937
|
+
const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
|
|
4938
|
+
if (reactNavigationNames.length === 0) return [];
|
|
4939
|
+
return [buildExpoDiagnostic({
|
|
4940
|
+
rule: "expo-router-no-react-navigation",
|
|
4941
|
+
message: `As of SDK 56, expo-router is no longer compatible with react-navigation, but ${reactNavigationNames.map((name) => `"${name}"`).join(", ")} ${reactNavigationNames.length === 1 ? "is" : "are"} installed as direct ${reactNavigationNames.length === 1 ? "dependency" : "dependencies"}`,
|
|
4942
|
+
help: "Remove these `@react-navigation/*` packages and replace direct imports with their expo-router equivalents. See https://docs.expo.dev/router/migrate/sdk-55-to-56/"
|
|
4943
|
+
})];
|
|
4944
|
+
};
|
|
4945
|
+
const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
|
|
4946
|
+
const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
|
|
4947
|
+
const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
|
|
4948
|
+
const checkExpoVectorIcons = (context) => {
|
|
4949
|
+
if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
|
|
4950
|
+
const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
|
|
4951
|
+
const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
|
|
4952
|
+
if (!hasScopedPackage || !hasConflictingPackage) return [];
|
|
4953
|
+
return [buildExpoDiagnostic({
|
|
4954
|
+
rule: "expo-vector-icons-conflict",
|
|
4955
|
+
message: "This project installs both the scoped `@react-native-vector-icons/*` packages and `@expo/vector-icons` (or the deprecated `react-native-vector-icons`) — mixing them causes icon-rendering conflicts",
|
|
4956
|
+
help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
|
|
4957
|
+
})];
|
|
4958
|
+
};
|
|
4959
|
+
const checkExpoProject = (rootDirectory, project) => {
|
|
4960
|
+
if (project.expoVersion === null) return [];
|
|
4961
|
+
const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
|
|
4962
|
+
return [
|
|
4963
|
+
...checkExpoFlaggedDependencies(context),
|
|
4964
|
+
...checkExpoDependencyOverrides(context),
|
|
4965
|
+
...checkExpoRouterReactNavigation(context),
|
|
4966
|
+
...checkExpoVectorIcons(context),
|
|
4967
|
+
...checkExpoPackageJsonConflicts(context),
|
|
4968
|
+
...checkExpoLockfile(context),
|
|
4969
|
+
...checkExpoGitignore(context),
|
|
4970
|
+
...checkExpoEnvLocalFiles(context),
|
|
4971
|
+
...checkExpoMetroConfig(context)
|
|
4972
|
+
];
|
|
4973
|
+
};
|
|
4406
4974
|
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
4407
4975
|
const PNPM_LOCKFILE = "pnpm-lock.yaml";
|
|
4408
4976
|
const PACKAGE_JSON_FILE = "package.json";
|
|
@@ -4572,99 +5140,6 @@ const checkReducedMotion = (rootDirectory) => {
|
|
|
4572
5140
|
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
4573
5141
|
};
|
|
4574
5142
|
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
5143
|
const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
|
|
4669
5144
|
const FALSY_VALUES = new Set([
|
|
4670
5145
|
"false",
|
|
@@ -4746,6 +5221,30 @@ const collectIgnorePatterns = (rootDirectory) => {
|
|
|
4746
5221
|
cachedPatternsByRoot.set(rootDirectory, patterns);
|
|
4747
5222
|
return patterns;
|
|
4748
5223
|
};
|
|
5224
|
+
/**
|
|
5225
|
+
* Resolves a path to its canonical, symlink-free form, falling back to
|
|
5226
|
+
* the input when it cannot be realpath'd (broken symlink, permission
|
|
5227
|
+
* error) so a best-effort normalization never throws.
|
|
5228
|
+
*
|
|
5229
|
+
* deslop's dead-code module graph is collected with `fast-glob` (which
|
|
5230
|
+
* keeps the scan root's symlinks intact) while imports are resolved
|
|
5231
|
+
* through `oxc-resolver` (which returns realpath'd targets). When the
|
|
5232
|
+
* project root sits behind a symlink — e.g. macOS iCloud-synced
|
|
5233
|
+
* `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
|
|
5234
|
+
* spaces diverge: every resolved import misses the graph and the files
|
|
5235
|
+
* they point at (commonly every `@/…` alias target) are mis-reported as
|
|
5236
|
+
* unreachable. Canonicalizing the root before the scan keeps both path
|
|
5237
|
+
* spaces in agreement.
|
|
5238
|
+
*/
|
|
5239
|
+
const toCanonicalPath = (filePath) => {
|
|
5240
|
+
try {
|
|
5241
|
+
return fs.realpathSync(filePath);
|
|
5242
|
+
} catch {
|
|
5243
|
+
return filePath;
|
|
5244
|
+
}
|
|
5245
|
+
};
|
|
5246
|
+
const DEAD_CODE_PLUGIN = "deslop";
|
|
5247
|
+
const DEAD_CODE_CATEGORY = "Maintainability";
|
|
4749
5248
|
const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
|
|
4750
5249
|
const DEAD_CODE_WORKER_SCRIPT = `
|
|
4751
5250
|
const inputChunks = [];
|
|
@@ -4921,7 +5420,11 @@ const buildDeadCodeWorkerError = (workerError) => {
|
|
|
4921
5420
|
return error;
|
|
4922
5421
|
};
|
|
4923
5422
|
const createDeadCodeWorker = (input) => {
|
|
4924
|
-
const child = spawn(process.execPath, [
|
|
5423
|
+
const child = spawn(process.execPath, [
|
|
5424
|
+
`--max-old-space-size=${DEAD_CODE_WORKER_MAX_OLD_SPACE_MB}`,
|
|
5425
|
+
"-e",
|
|
5426
|
+
DEAD_CODE_WORKER_SCRIPT
|
|
5427
|
+
], {
|
|
4925
5428
|
stdio: [
|
|
4926
5429
|
"pipe",
|
|
4927
5430
|
"pipe",
|
|
@@ -4996,7 +5499,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
|
|
|
4996
5499
|
});
|
|
4997
5500
|
});
|
|
4998
5501
|
const checkDeadCode = async (options) => {
|
|
4999
|
-
const {
|
|
5502
|
+
const { userConfig } = options;
|
|
5503
|
+
const rootDirectory = toCanonicalPath(options.rootDirectory);
|
|
5000
5504
|
if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
|
|
5001
5505
|
const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
|
|
5002
5506
|
const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
|
|
@@ -5009,59 +5513,162 @@ const checkDeadCode = async (options) => {
|
|
|
5009
5513
|
const diagnostics = [];
|
|
5010
5514
|
for (const unusedFile of result.unusedFiles) diagnostics.push({
|
|
5011
5515
|
filePath: toRelative(unusedFile.path),
|
|
5012
|
-
plugin:
|
|
5516
|
+
plugin: DEAD_CODE_PLUGIN,
|
|
5013
5517
|
rule: "unused-file",
|
|
5014
5518
|
severity: "warning",
|
|
5015
5519
|
message: "Unused file — not reachable from any entry point",
|
|
5016
5520
|
help: "Delete the file if it is truly unreachable, or import it from an entry point.",
|
|
5017
5521
|
line: 0,
|
|
5018
5522
|
column: 0,
|
|
5019
|
-
category:
|
|
5523
|
+
category: DEAD_CODE_CATEGORY
|
|
5020
5524
|
});
|
|
5021
5525
|
for (const unusedExport of result.unusedExports) {
|
|
5022
5526
|
const label = unusedExport.isTypeOnly ? "type export" : "export";
|
|
5023
5527
|
diagnostics.push({
|
|
5024
5528
|
filePath: toRelative(unusedExport.path),
|
|
5025
|
-
plugin:
|
|
5529
|
+
plugin: DEAD_CODE_PLUGIN,
|
|
5026
5530
|
rule: unusedExport.isTypeOnly ? "unused-type" : "unused-export",
|
|
5027
5531
|
severity: "warning",
|
|
5028
5532
|
message: `Unused ${label}: \`${unusedExport.name}\``,
|
|
5029
5533
|
help: "Drop the `export` keyword (or remove the declaration) if no other module uses this symbol.",
|
|
5030
5534
|
line: unusedExport.line,
|
|
5031
5535
|
column: unusedExport.column,
|
|
5032
|
-
category:
|
|
5536
|
+
category: DEAD_CODE_CATEGORY
|
|
5033
5537
|
});
|
|
5034
5538
|
}
|
|
5035
5539
|
for (const unusedDependency of result.unusedDependencies) {
|
|
5036
5540
|
const label = unusedDependency.isDevDependency ? "devDependency" : "dependency";
|
|
5037
5541
|
diagnostics.push({
|
|
5038
5542
|
filePath: "package.json",
|
|
5039
|
-
plugin:
|
|
5543
|
+
plugin: DEAD_CODE_PLUGIN,
|
|
5040
5544
|
rule: unusedDependency.isDevDependency ? "unused-dev-dependency" : "unused-dependency",
|
|
5041
5545
|
severity: "warning",
|
|
5042
5546
|
message: `Unused ${label}: \`${unusedDependency.name}\``,
|
|
5043
5547
|
help: "Remove the dependency from package.json if it is genuinely unused.",
|
|
5044
5548
|
line: 0,
|
|
5045
5549
|
column: 0,
|
|
5046
|
-
category:
|
|
5550
|
+
category: DEAD_CODE_CATEGORY
|
|
5047
5551
|
});
|
|
5048
5552
|
}
|
|
5049
5553
|
for (const cycle of result.circularDependencies) {
|
|
5050
5554
|
if (cycle.files.length === 0) continue;
|
|
5051
5555
|
diagnostics.push({
|
|
5052
5556
|
filePath: toRelative(cycle.files[0]),
|
|
5053
|
-
plugin:
|
|
5557
|
+
plugin: DEAD_CODE_PLUGIN,
|
|
5054
5558
|
rule: "circular-dependency",
|
|
5055
5559
|
severity: "warning",
|
|
5056
5560
|
message: `Circular import cycle: ${cycle.files.map(toRelative).join(" → ")}`,
|
|
5057
5561
|
help: "Break the cycle by extracting the shared code into a third module that both files import.",
|
|
5058
5562
|
line: 0,
|
|
5059
5563
|
column: 0,
|
|
5060
|
-
category:
|
|
5564
|
+
category: DEAD_CODE_CATEGORY
|
|
5061
5565
|
});
|
|
5062
5566
|
}
|
|
5063
5567
|
return diagnostics;
|
|
5064
5568
|
};
|
|
5569
|
+
const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
|
|
5570
|
+
const isSurfacingOverride = (override) => override === "warn" || override === "error";
|
|
5571
|
+
const deadCodeMaySurfaceWhenWarningsHidden = (userConfig) => {
|
|
5572
|
+
const severityControls = buildRuleSeverityControls(userConfig);
|
|
5573
|
+
if (!severityControls) return false;
|
|
5574
|
+
if (isSurfacingOverride(severityControls.categories?.["Maintainability"])) return true;
|
|
5575
|
+
for (const [ruleKey, override] of Object.entries(severityControls.rules ?? {})) if (ruleKey.startsWith(DEAD_CODE_RULE_KEY_PREFIX) && isSurfacingOverride(override)) return true;
|
|
5576
|
+
return false;
|
|
5577
|
+
};
|
|
5578
|
+
const toStringSet = (values) => {
|
|
5579
|
+
if (!values || values.length === 0) return /* @__PURE__ */ new Set();
|
|
5580
|
+
return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
|
|
5581
|
+
};
|
|
5582
|
+
const buildResolvedControls = (surface, userControls) => {
|
|
5583
|
+
const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
|
|
5584
|
+
const includeTags = toStringSet(userControls?.includeTags);
|
|
5585
|
+
for (const tag of includeTags) excludeTags.delete(tag);
|
|
5586
|
+
for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
|
|
5587
|
+
return {
|
|
5588
|
+
includeTags,
|
|
5589
|
+
excludeTags,
|
|
5590
|
+
includeCategories: toStringSet(userControls?.includeCategories),
|
|
5591
|
+
excludeCategories: toStringSet(userControls?.excludeCategories),
|
|
5592
|
+
includeRuleKeys: toStringSet(userControls?.includeRules),
|
|
5593
|
+
excludeRuleKeys: toStringSet(userControls?.excludeRules)
|
|
5594
|
+
};
|
|
5595
|
+
};
|
|
5596
|
+
const intersects = (values, candidates) => values.some((value) => candidates.has(value));
|
|
5597
|
+
const isDiagnosticOnSurface = (diagnostic, surface, config) => {
|
|
5598
|
+
const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
|
|
5599
|
+
const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
|
|
5600
|
+
if (resolved.includeRuleKeys.has(ruleKey)) return true;
|
|
5601
|
+
if (resolved.includeCategories.has(category)) return true;
|
|
5602
|
+
if (intersects(tags, resolved.includeTags)) return true;
|
|
5603
|
+
if (resolved.excludeRuleKeys.has(ruleKey)) return false;
|
|
5604
|
+
if (resolved.excludeCategories.has(category)) return false;
|
|
5605
|
+
if (intersects(tags, resolved.excludeTags)) return false;
|
|
5606
|
+
return true;
|
|
5607
|
+
};
|
|
5608
|
+
const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
|
|
5609
|
+
const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(path.resolve(rootDirectory, relativePath)));
|
|
5610
|
+
const listSourceFilesViaGit = (rootDirectory) => {
|
|
5611
|
+
const result = spawnSync("git", [
|
|
5612
|
+
"ls-files",
|
|
5613
|
+
"-z",
|
|
5614
|
+
"--cached",
|
|
5615
|
+
"--others",
|
|
5616
|
+
"--exclude-standard"
|
|
5617
|
+
], {
|
|
5618
|
+
cwd: rootDirectory,
|
|
5619
|
+
encoding: "utf-8",
|
|
5620
|
+
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
5621
|
+
});
|
|
5622
|
+
if (result.error || result.status !== 0) return null;
|
|
5623
|
+
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
|
|
5624
|
+
};
|
|
5625
|
+
const listSourceFilesViaFilesystem = (rootDirectory) => {
|
|
5626
|
+
const filePaths = [];
|
|
5627
|
+
const stack = [rootDirectory];
|
|
5628
|
+
while (stack.length > 0) {
|
|
5629
|
+
const currentDirectory = stack.pop();
|
|
5630
|
+
const entries = readDirectoryEntries(currentDirectory);
|
|
5631
|
+
for (const entry of entries) {
|
|
5632
|
+
const absolutePath = path.join(currentDirectory, entry.name);
|
|
5633
|
+
if (entry.isDirectory()) {
|
|
5634
|
+
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
|
|
5635
|
+
continue;
|
|
5636
|
+
}
|
|
5637
|
+
if (entry.isFile() && isLintableSourceFile(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
|
|
5638
|
+
}
|
|
5639
|
+
}
|
|
5640
|
+
return filePaths;
|
|
5641
|
+
};
|
|
5642
|
+
const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
|
|
5643
|
+
const resolveLintIncludePaths = (rootDirectory, userConfig) => {
|
|
5644
|
+
if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
|
|
5645
|
+
const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
|
|
5646
|
+
return listSourceFiles(rootDirectory).filter((filePath) => {
|
|
5647
|
+
if (!JSX_FILE_PATTERN.test(filePath)) return false;
|
|
5648
|
+
return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
|
|
5649
|
+
});
|
|
5650
|
+
};
|
|
5651
|
+
var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
5652
|
+
static layerNode = Layer.effect(Config, Effect.gen(function* () {
|
|
5653
|
+
const cache = yield* Cache.make({
|
|
5654
|
+
capacity: 16,
|
|
5655
|
+
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
5656
|
+
lookup: (directory) => Effect.sync(() => {
|
|
5657
|
+
const loaded = loadConfigWithSource(directory);
|
|
5658
|
+
const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
|
|
5659
|
+
return {
|
|
5660
|
+
config: loaded?.config ?? null,
|
|
5661
|
+
resolvedDirectory: redirected ?? directory,
|
|
5662
|
+
configSourceDirectory: loaded?.sourceDirectory ?? null
|
|
5663
|
+
};
|
|
5664
|
+
})
|
|
5665
|
+
});
|
|
5666
|
+
return Config.of({ resolve: Effect.fn("Config.resolve")(function* (directory) {
|
|
5667
|
+
return yield* Cache.get(cache, directory);
|
|
5668
|
+
}) });
|
|
5669
|
+
}));
|
|
5670
|
+
static layerOf = (resolved) => Layer.succeed(Config, Config.of({ resolve: () => Effect.succeed(resolved) }));
|
|
5671
|
+
};
|
|
5065
5672
|
/**
|
|
5066
5673
|
* `DeadCode` runs whole-project reachability analysis and streams
|
|
5067
5674
|
* diagnostics. Reachability is a whole-project property — the
|
|
@@ -5567,12 +6174,12 @@ const findFilesWithDisableDirectivesViaGit = async (rootDirectory, includePaths)
|
|
|
5567
6174
|
return null;
|
|
5568
6175
|
}
|
|
5569
6176
|
if (grepResult === null) return null;
|
|
5570
|
-
return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 &&
|
|
6177
|
+
return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
|
|
5571
6178
|
};
|
|
5572
6179
|
const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
|
|
5573
6180
|
const matches = [];
|
|
5574
6181
|
const checkFile = (relativePath) => {
|
|
5575
|
-
if (!
|
|
6182
|
+
if (!isLintableSourceFile(relativePath)) return;
|
|
5576
6183
|
const absolutePath = path.join(rootDirectory, relativePath);
|
|
5577
6184
|
let content;
|
|
5578
6185
|
try {
|
|
@@ -5644,6 +6251,7 @@ const buildCapabilities = (project) => {
|
|
|
5644
6251
|
const capabilities = /* @__PURE__ */ new Set();
|
|
5645
6252
|
capabilities.add(project.framework);
|
|
5646
6253
|
if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
|
|
6254
|
+
if (project.expoVersion !== null) capabilities.add("expo");
|
|
5647
6255
|
const reactMajor = project.reactMajorVersion;
|
|
5648
6256
|
if (reactMajor !== null) {
|
|
5649
6257
|
const cappedReactMajor = Math.min(reactMajor, 30);
|
|
@@ -5815,10 +6423,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
|
|
|
5815
6423
|
if (!fs.existsSync(rootDirectory)) return rootDirectory;
|
|
5816
6424
|
return fs.realpathSync(rootDirectory);
|
|
5817
6425
|
};
|
|
6426
|
+
const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
|
|
6427
|
+
if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
|
|
6428
|
+
return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
|
|
6429
|
+
};
|
|
5818
6430
|
const applyRuleSeverityControls = (rules, severityControls) => {
|
|
5819
6431
|
const enabledRules = {};
|
|
5820
6432
|
for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
|
|
5821
|
-
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
|
|
6433
|
+
const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
|
|
5822
6434
|
if (severity === "off") continue;
|
|
5823
6435
|
enabledRules[ruleKey] = severity;
|
|
5824
6436
|
}
|
|
@@ -5860,7 +6472,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
|
|
|
5860
6472
|
category: rule.category
|
|
5861
6473
|
}, severityControls);
|
|
5862
6474
|
if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
|
|
5863
|
-
const severity = explicitSeverity ?? rule.severity;
|
|
6475
|
+
const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
|
|
5864
6476
|
if (severity === "off") continue;
|
|
5865
6477
|
enabledReactDoctorRules[registryEntry.key] = severity;
|
|
5866
6478
|
}
|
|
@@ -5917,6 +6529,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
5917
6529
|
}
|
|
5918
6530
|
return uniqueDiagnostics;
|
|
5919
6531
|
};
|
|
6532
|
+
/**
|
|
6533
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
6534
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
6535
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
6536
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
6537
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
6538
|
+
* batches do).
|
|
6539
|
+
*
|
|
6540
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
6541
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
6542
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
6543
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
6544
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
6545
|
+
* top of a still-running first one.
|
|
6546
|
+
*/
|
|
6547
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
6548
|
+
const results = new Array(items.length);
|
|
6549
|
+
if (items.length === 0) return results;
|
|
6550
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
6551
|
+
let nextIndex = 0;
|
|
6552
|
+
const errors = [];
|
|
6553
|
+
const runWorker = async () => {
|
|
6554
|
+
while (errors.length === 0) {
|
|
6555
|
+
const index = nextIndex;
|
|
6556
|
+
nextIndex += 1;
|
|
6557
|
+
if (index >= items.length) return;
|
|
6558
|
+
try {
|
|
6559
|
+
results[index] = await task(items[index], index);
|
|
6560
|
+
} catch (error) {
|
|
6561
|
+
errors.push(error);
|
|
6562
|
+
return;
|
|
6563
|
+
}
|
|
6564
|
+
}
|
|
6565
|
+
};
|
|
6566
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
6567
|
+
if (errors.length > 0) throw errors[0];
|
|
6568
|
+
return results;
|
|
6569
|
+
};
|
|
5920
6570
|
const getPublicEnvPrefix = (framework) => {
|
|
5921
6571
|
switch (framework) {
|
|
5922
6572
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -5939,6 +6589,149 @@ const appendReanimatedSharedValueHint = (help, rule, project) => {
|
|
|
5939
6589
|
if (!help) return REANIMATED_SHARED_VALUE_HINT;
|
|
5940
6590
|
return `${help}\n\n${REANIMATED_SHARED_VALUE_HINT}`;
|
|
5941
6591
|
};
|
|
6592
|
+
const REDACTED_PLACEHOLDER = "<redacted>";
|
|
6593
|
+
const KEEP_PREFIX = `$1${REDACTED_PLACEHOLDER}`;
|
|
6594
|
+
const KNOWN_SECRET_RULES = [
|
|
6595
|
+
{
|
|
6596
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
6597
|
+
replacement: REDACTED_PLACEHOLDER
|
|
6598
|
+
},
|
|
6599
|
+
{
|
|
6600
|
+
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
|
|
6601
|
+
replacement: REDACTED_PLACEHOLDER
|
|
6602
|
+
},
|
|
6603
|
+
{
|
|
6604
|
+
pattern: /(?<=:\/\/)[^\s/:@]+:[^\s/@]+(?=@)/g,
|
|
6605
|
+
replacement: REDACTED_PLACEHOLDER
|
|
6606
|
+
},
|
|
6607
|
+
{
|
|
6608
|
+
pattern: /\b(AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|A3T[A-Z0-9])[0-9A-Z]{16,}/g,
|
|
6609
|
+
replacement: KEEP_PREFIX
|
|
6610
|
+
},
|
|
6611
|
+
{
|
|
6612
|
+
pattern: /\b(gh[pousr]_)[A-Za-z0-9]{36,}/g,
|
|
6613
|
+
replacement: KEEP_PREFIX
|
|
6614
|
+
},
|
|
6615
|
+
{
|
|
6616
|
+
pattern: /\b(github_pat_)[A-Za-z0-9_]{22,}/g,
|
|
6617
|
+
replacement: KEEP_PREFIX
|
|
6618
|
+
},
|
|
6619
|
+
{
|
|
6620
|
+
pattern: /\b(glpat-)[A-Za-z0-9_-]{20,}/g,
|
|
6621
|
+
replacement: KEEP_PREFIX
|
|
6622
|
+
},
|
|
6623
|
+
{
|
|
6624
|
+
pattern: /\b(xox[baprs]-)[A-Za-z0-9-]{10,}/g,
|
|
6625
|
+
replacement: KEEP_PREFIX
|
|
6626
|
+
},
|
|
6627
|
+
{
|
|
6628
|
+
pattern: /(?<=hooks\.slack\.com\/services\/)[A-Za-z0-9/+_-]{20,}/g,
|
|
6629
|
+
replacement: REDACTED_PLACEHOLDER
|
|
6630
|
+
},
|
|
6631
|
+
{
|
|
6632
|
+
pattern: /\b((?:sk|rk)_(?:live|test)_)[0-9A-Za-z]{10,}/g,
|
|
6633
|
+
replacement: KEEP_PREFIX
|
|
6634
|
+
},
|
|
6635
|
+
{
|
|
6636
|
+
pattern: /\b(sk-(?:proj-|ant-)?)[A-Za-z0-9_-]{20,}/g,
|
|
6637
|
+
replacement: KEEP_PREFIX
|
|
6638
|
+
},
|
|
6639
|
+
{
|
|
6640
|
+
pattern: /\b(AIza)[0-9A-Za-z_-]{35,}/g,
|
|
6641
|
+
replacement: KEEP_PREFIX
|
|
6642
|
+
},
|
|
6643
|
+
{
|
|
6644
|
+
pattern: /\b(ya29\.)[0-9A-Za-z_-]{20,}/g,
|
|
6645
|
+
replacement: KEEP_PREFIX
|
|
6646
|
+
},
|
|
6647
|
+
{
|
|
6648
|
+
pattern: /\b(npm_)[A-Za-z0-9]{36,}/g,
|
|
6649
|
+
replacement: KEEP_PREFIX
|
|
6650
|
+
},
|
|
6651
|
+
{
|
|
6652
|
+
pattern: /\b(SG\.)[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{43,}/g,
|
|
6653
|
+
replacement: KEEP_PREFIX
|
|
6654
|
+
},
|
|
6655
|
+
{
|
|
6656
|
+
pattern: /\b(SK)[0-9a-fA-F]{32,}/g,
|
|
6657
|
+
replacement: KEEP_PREFIX
|
|
6658
|
+
},
|
|
6659
|
+
{
|
|
6660
|
+
pattern: /\b(dop_v1_)[a-f0-9]{64,}/g,
|
|
6661
|
+
replacement: KEEP_PREFIX
|
|
6662
|
+
},
|
|
6663
|
+
{
|
|
6664
|
+
pattern: /\b(shp(?:at|ca|pa|ss)_)[a-fA-F0-9]{32,}/g,
|
|
6665
|
+
replacement: KEEP_PREFIX
|
|
6666
|
+
},
|
|
6667
|
+
{
|
|
6668
|
+
pattern: /\b(sq0[a-z]{3}-)[0-9A-Za-z_-]{22,}/g,
|
|
6669
|
+
replacement: KEEP_PREFIX
|
|
6670
|
+
},
|
|
6671
|
+
{
|
|
6672
|
+
pattern: /\b([0-9]{8,10}:AA)[0-9A-Za-z_-]{32,}/g,
|
|
6673
|
+
replacement: KEEP_PREFIX
|
|
6674
|
+
},
|
|
6675
|
+
{
|
|
6676
|
+
pattern: /(?<=\bBearer\s)[A-Za-z0-9._~+/=-]{16,}/g,
|
|
6677
|
+
replacement: REDACTED_PLACEHOLDER
|
|
6678
|
+
},
|
|
6679
|
+
{
|
|
6680
|
+
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
6681
|
+
replacement: REDACTED_PLACEHOLDER
|
|
6682
|
+
}
|
|
6683
|
+
];
|
|
6684
|
+
const CANDIDATE_TOKEN_PATTERN = /[A-Za-z0-9_][A-Za-z0-9_-]*/g;
|
|
6685
|
+
const HEX_DIGEST_PATTERN = /^(?:[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
6686
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6687
|
+
const HAS_LETTER_PATTERN = /[A-Za-z]/;
|
|
6688
|
+
const HAS_DIGIT_PATTERN = /[0-9]/;
|
|
6689
|
+
const shannonEntropyBits = (value) => {
|
|
6690
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6691
|
+
for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
6692
|
+
let bits = 0;
|
|
6693
|
+
for (const count of counts.values()) {
|
|
6694
|
+
const probability = count / value.length;
|
|
6695
|
+
bits -= probability * Math.log2(probability);
|
|
6696
|
+
}
|
|
6697
|
+
return bits;
|
|
6698
|
+
};
|
|
6699
|
+
const looksLikeHighEntropySecret = (token) => {
|
|
6700
|
+
if (token.length < 32) return false;
|
|
6701
|
+
if (!HAS_LETTER_PATTERN.test(token) || !HAS_DIGIT_PATTERN.test(token)) return false;
|
|
6702
|
+
if (HEX_DIGEST_PATTERN.test(token) || UUID_PATTERN.test(token)) return false;
|
|
6703
|
+
return shannonEntropyBits(token) >= 3;
|
|
6704
|
+
};
|
|
6705
|
+
const redactHighEntropyTokens = (text) => text.replace(CANDIDATE_TOKEN_PATTERN, (token) => looksLikeHighEntropySecret(token) ? REDACTED_PLACEHOLDER : token);
|
|
6706
|
+
/**
|
|
6707
|
+
* Masks API keys, tokens, private keys, credentialed URLs, and emails
|
|
6708
|
+
* found anywhere inside a free-text string, returning the scrubbed text.
|
|
6709
|
+
* Applied to every diagnostic's `message` / `help` at construction time
|
|
6710
|
+
* so secrets never reach the terminal, the JSON report, or the score
|
|
6711
|
+
* API — react-doctor must never echo or transmit a user's secrets.
|
|
6712
|
+
*
|
|
6713
|
+
* Provider tokens keep their non-secret, type-identifying prefix (e.g.
|
|
6714
|
+
* `sk_live_<redacted>`, `ghp_<redacted>`, `AKIA<redacted>`) so the leaked
|
|
6715
|
+
* credential's type stays visible; structural or unknown-format secrets
|
|
6716
|
+
* with no meaningful prefix are masked whole.
|
|
6717
|
+
*
|
|
6718
|
+
* Runs the high-precision known-shape detectors first, then a generic
|
|
6719
|
+
* entropy-gated sweep for unknown-format secrets. Idempotent: the inert
|
|
6720
|
+
* `<redacted>` placeholder matches none of the detectors and is too
|
|
6721
|
+
* short for the generic sweep, so re-running leaves the text unchanged.
|
|
6722
|
+
*
|
|
6723
|
+
* Accepts `unknown` on purpose: callers feed it diagnostic `message` /
|
|
6724
|
+
* `help` that originate from oxlint JSON, which is only shape-checked at
|
|
6725
|
+
* the top level (the per-field `string` types are assumed, not validated).
|
|
6726
|
+
* A malformed non-string value returns `""` instead of throwing on
|
|
6727
|
+
* `.replace`, so one bad diagnostic can't abort parsing the whole batch.
|
|
6728
|
+
*/
|
|
6729
|
+
const redactSensitiveText = (text) => {
|
|
6730
|
+
if (typeof text !== "string" || text === "") return "";
|
|
6731
|
+
let redacted = text;
|
|
6732
|
+
for (const rule of KNOWN_SECRET_RULES) redacted = redacted.replace(rule.pattern, rule.replacement);
|
|
6733
|
+
return redactHighEntropyTokens(redacted);
|
|
6734
|
+
};
|
|
5942
6735
|
const REACT_MODULE_SOURCE = "react";
|
|
5943
6736
|
const REQUIRE_IDENTIFIER = "require";
|
|
5944
6737
|
const USE_IDENTIFIER = "use";
|
|
@@ -6233,25 +7026,26 @@ const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
|
|
|
6233
7026
|
return bindingResolution !== null && !bindingResolution.isReactUseBinding;
|
|
6234
7027
|
};
|
|
6235
7028
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
6236
|
-
const
|
|
7029
|
+
const REACT_COMPILER_TITLE = "React Compiler can't optimize this";
|
|
7030
|
+
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.";
|
|
6237
7031
|
const PLUGIN_CATEGORY_MAP = {
|
|
6238
|
-
react: "
|
|
6239
|
-
"react-hooks": "
|
|
6240
|
-
"react-hooks-js": "
|
|
6241
|
-
"react-doctor": "
|
|
7032
|
+
react: "Bugs",
|
|
7033
|
+
"react-hooks": "Bugs",
|
|
7034
|
+
"react-hooks-js": "Performance",
|
|
7035
|
+
"react-doctor": "Bugs",
|
|
6242
7036
|
"jsx-a11y": "Accessibility",
|
|
6243
|
-
effect: "
|
|
6244
|
-
eslint: "
|
|
6245
|
-
oxc: "
|
|
6246
|
-
typescript: "
|
|
6247
|
-
unicorn: "
|
|
6248
|
-
import: "
|
|
6249
|
-
promise: "
|
|
6250
|
-
n: "
|
|
6251
|
-
node: "
|
|
6252
|
-
vitest: "
|
|
6253
|
-
jest: "
|
|
6254
|
-
nextjs: "
|
|
7037
|
+
effect: "Bugs",
|
|
7038
|
+
eslint: "Bugs",
|
|
7039
|
+
oxc: "Bugs",
|
|
7040
|
+
typescript: "Bugs",
|
|
7041
|
+
unicorn: "Bugs",
|
|
7042
|
+
import: "Performance",
|
|
7043
|
+
promise: "Bugs",
|
|
7044
|
+
n: "Bugs",
|
|
7045
|
+
node: "Bugs",
|
|
7046
|
+
vitest: "Bugs",
|
|
7047
|
+
jest: "Bugs",
|
|
7048
|
+
nextjs: "Bugs"
|
|
6255
7049
|
};
|
|
6256
7050
|
const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
6257
7051
|
const getRuleRecommendation = (ruleName, project) => {
|
|
@@ -6259,7 +7053,16 @@ const getRuleRecommendation = (ruleName, project) => {
|
|
|
6259
7053
|
return reactDoctorPlugin.rules[ruleName]?.recommendation;
|
|
6260
7054
|
};
|
|
6261
7055
|
const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
|
|
7056
|
+
const getRuleTitle = (ruleName) => reactDoctorPlugin.rules[ruleName]?.title;
|
|
7057
|
+
const resolveDiagnosticTitle = (plugin, rule) => plugin === "react-hooks-js" ? REACT_COMPILER_TITLE : getRuleTitle(rule);
|
|
6262
7058
|
const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
|
|
7059
|
+
const cleaned = resolveCleanedDiagnostic(typeof message === "string" ? message : "", typeof help === "string" ? help : "", plugin, rule, project);
|
|
7060
|
+
return {
|
|
7061
|
+
message: redactSensitiveText(cleaned.message),
|
|
7062
|
+
help: redactSensitiveText(cleaned.help)
|
|
7063
|
+
};
|
|
7064
|
+
};
|
|
7065
|
+
const resolveCleanedDiagnostic = (message, help, plugin, rule, project) => {
|
|
6263
7066
|
if (plugin === "react-hooks-js") return {
|
|
6264
7067
|
message: REACT_COMPILER_MESSAGE,
|
|
6265
7068
|
help: appendReanimatedSharedValueHint(message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help, rule, project)
|
|
@@ -6280,7 +7083,7 @@ const parseRuleCode = (code) => {
|
|
|
6280
7083
|
rule: match[2]
|
|
6281
7084
|
};
|
|
6282
7085
|
};
|
|
6283
|
-
const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "
|
|
7086
|
+
const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Bugs";
|
|
6284
7087
|
const isOxlintOutput = (value) => {
|
|
6285
7088
|
if (typeof value !== "object" || value === null) return false;
|
|
6286
7089
|
const candidate = value;
|
|
@@ -6304,7 +7107,16 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
|
|
|
6304
7107
|
throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
|
|
6305
7108
|
}
|
|
6306
7109
|
if (!isOxlintOutput(parsed)) throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
|
|
6307
|
-
|
|
7110
|
+
const minifiedFileCache = /* @__PURE__ */ new Map();
|
|
7111
|
+
const isMinifiedDiagnosticFile = (filename) => {
|
|
7112
|
+
const absolutePath = path.isAbsolute(filename) ? filename : path.resolve(rootDirectory || ".", filename);
|
|
7113
|
+
const cached = minifiedFileCache.get(absolutePath);
|
|
7114
|
+
if (cached !== void 0) return cached;
|
|
7115
|
+
const minified = isMinifiedSource(absolutePath);
|
|
7116
|
+
minifiedFileCache.set(absolutePath, minified);
|
|
7117
|
+
return minified;
|
|
7118
|
+
};
|
|
7119
|
+
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && isLintableSourceFile(diagnostic.filename) && !isMinifiedDiagnosticFile(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
|
|
6308
7120
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
6309
7121
|
const primaryLabel = diagnostic.labels[0];
|
|
6310
7122
|
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule, project);
|
|
@@ -6313,6 +7125,7 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
|
|
|
6313
7125
|
plugin,
|
|
6314
7126
|
rule,
|
|
6315
7127
|
severity: diagnostic.severity,
|
|
7128
|
+
title: resolveDiagnosticTitle(plugin, rule),
|
|
6316
7129
|
message: cleaned.message,
|
|
6317
7130
|
help: cleaned.help,
|
|
6318
7131
|
url: diagnostic.url,
|
|
@@ -6436,6 +7249,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
6436
7249
|
*/
|
|
6437
7250
|
const spawnLintBatches = async (input) => {
|
|
6438
7251
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
7252
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
6439
7253
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
6440
7254
|
const allDiagnostics = [];
|
|
6441
7255
|
const droppedFiles = [];
|
|
@@ -6455,20 +7269,31 @@ const spawnLintBatches = async (input) => {
|
|
|
6455
7269
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
6456
7270
|
}
|
|
6457
7271
|
};
|
|
7272
|
+
let startedFileCount = 0;
|
|
6458
7273
|
let scannedFileCount = 0;
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
const
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
7274
|
+
let displayedFileCount = 0;
|
|
7275
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
7276
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
7277
|
+
if (displayedFileCount < ceiling) {
|
|
7278
|
+
displayedFileCount += 1;
|
|
7279
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
7280
|
+
}
|
|
7281
|
+
}, 50) : null;
|
|
7282
|
+
progressTimer?.unref?.();
|
|
7283
|
+
try {
|
|
7284
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
7285
|
+
startedFileCount += batch.length;
|
|
7286
|
+
const batchDiagnostics = await spawnLintBatch(batch);
|
|
7287
|
+
scannedFileCount += batch.length;
|
|
7288
|
+
if (onFileProgress) {
|
|
7289
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
7290
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
6465
7291
|
}
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
onFileProgress?.(scannedFileCount, totalFileCount);
|
|
7292
|
+
return batchDiagnostics;
|
|
7293
|
+
});
|
|
7294
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
7295
|
+
} finally {
|
|
7296
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
6472
7297
|
}
|
|
6473
7298
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
6474
7299
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -6595,7 +7420,8 @@ const runOxlint = async (options) => {
|
|
|
6595
7420
|
onPartialFailure,
|
|
6596
7421
|
onFileProgress: options.onFileProgress,
|
|
6597
7422
|
spawnTimeoutMs,
|
|
6598
|
-
outputMaxBytes
|
|
7423
|
+
outputMaxBytes,
|
|
7424
|
+
concurrency: options.concurrency
|
|
6599
7425
|
});
|
|
6600
7426
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
6601
7427
|
try {
|
|
@@ -6663,6 +7489,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6663
7489
|
const partialFailures = yield* LintPartialFailures;
|
|
6664
7490
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
6665
7491
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
7492
|
+
const concurrency = yield* OxlintConcurrency;
|
|
6666
7493
|
const collectedFailures = [];
|
|
6667
7494
|
const diagnostics = yield* Effect.tryPromise({
|
|
6668
7495
|
try: () => runOxlint({
|
|
@@ -6681,7 +7508,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
6681
7508
|
},
|
|
6682
7509
|
onFileProgress: input.onFileProgress,
|
|
6683
7510
|
spawnTimeoutMs,
|
|
6684
|
-
outputMaxBytes
|
|
7511
|
+
outputMaxBytes,
|
|
7512
|
+
concurrency
|
|
6685
7513
|
}),
|
|
6686
7514
|
catch: ensureReactDoctorError
|
|
6687
7515
|
});
|
|
@@ -6727,7 +7555,8 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
|
|
|
6727
7555
|
static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
|
|
6728
7556
|
update: () => Effect.void,
|
|
6729
7557
|
succeed: () => Effect.void,
|
|
6730
|
-
fail: () => Effect.void
|
|
7558
|
+
fail: () => Effect.void,
|
|
7559
|
+
stop: () => Effect.void
|
|
6731
7560
|
}) }));
|
|
6732
7561
|
static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
|
|
6733
7562
|
yield* Ref.update(events, (existing) => [...existing, {
|
|
@@ -6746,6 +7575,10 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
|
|
|
6746
7575
|
fail: (displayText) => Ref.update(events, (existing) => [...existing, {
|
|
6747
7576
|
_tag: "Failed",
|
|
6748
7577
|
text: displayText
|
|
7578
|
+
}]),
|
|
7579
|
+
stop: () => Ref.update(events, (existing) => [...existing, {
|
|
7580
|
+
_tag: "Stopped",
|
|
7581
|
+
text
|
|
6749
7582
|
}])
|
|
6750
7583
|
};
|
|
6751
7584
|
}) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
|
|
@@ -6817,17 +7650,21 @@ var Reporter = class Reporter extends Context.Service()("react-doctor/Reporter")
|
|
|
6817
7650
|
});
|
|
6818
7651
|
}));
|
|
6819
7652
|
};
|
|
6820
|
-
const
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
7653
|
+
const RulePrioritySchema = Schema.Struct({
|
|
7654
|
+
priority: Schema.NullOr(Schema.Number),
|
|
7655
|
+
tier: Schema.Literals([
|
|
7656
|
+
"P0",
|
|
7657
|
+
"P1",
|
|
7658
|
+
"P2",
|
|
7659
|
+
"P3"
|
|
7660
|
+
])
|
|
7661
|
+
});
|
|
7662
|
+
const ScoreApiResponseSchema = Schema.Struct({
|
|
7663
|
+
score: Schema.Number,
|
|
7664
|
+
label: Schema.String,
|
|
7665
|
+
rules: Schema.optional(Schema.Record(Schema.String, RulePrioritySchema))
|
|
7666
|
+
});
|
|
7667
|
+
const parseScoreResult = (value) => Option.getOrNull(Schema.decodeUnknownOption(ScoreApiResponseSchema)(value));
|
|
6831
7668
|
const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
|
|
6832
7669
|
const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
6833
7670
|
const describeFailure = (error) => {
|
|
@@ -6991,18 +7828,25 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
6991
7828
|
repo
|
|
6992
7829
|
}).pipe(Effect.orElseSucceed(() => null)) : Effect.succeed(null));
|
|
6993
7830
|
const lintIncludePaths = computeJsxIncludePaths([...input.includePaths]) ?? resolveLintIncludePaths(scanDirectory, resolvedConfig.config);
|
|
7831
|
+
const scannedFilePaths = input.suppressScanSummary ? (lintIncludePaths ?? (yield* filesService.listSourceFiles(scanDirectory))).map((relativePath) => path.resolve(scanDirectory, relativePath)) : [];
|
|
6994
7832
|
const beforeLint = hooks.beforeLint ?? NO_HOOKS.beforeLint;
|
|
6995
7833
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
6996
7834
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
6997
7835
|
const isDiffMode = input.includePaths.length > 0;
|
|
7836
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
6998
7837
|
const transform = buildDiagnosticPipeline({
|
|
6999
7838
|
rootDirectory: scanDirectory,
|
|
7000
7839
|
userConfig: resolvedConfig.config,
|
|
7001
7840
|
readFileLinesSync: fileReader(filesService, scanDirectory),
|
|
7002
|
-
respectInlineDisables: input.respectInlineDisables
|
|
7841
|
+
respectInlineDisables: input.respectInlineDisables,
|
|
7842
|
+
showWarnings
|
|
7003
7843
|
});
|
|
7004
7844
|
const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
|
|
7005
|
-
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7845
|
+
const environmentDiagnostics = isDiffMode ? [] : [
|
|
7846
|
+
...checkReducedMotion(scanDirectory),
|
|
7847
|
+
...checkPnpmHardening(scanDirectory),
|
|
7848
|
+
...checkExpoProject(scanDirectory, project)
|
|
7849
|
+
];
|
|
7006
7850
|
const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
|
|
7007
7851
|
const lintFailure = yield* Ref.make({
|
|
7008
7852
|
didFail: false,
|
|
@@ -7014,6 +7858,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7014
7858
|
didFail: false,
|
|
7015
7859
|
reason: null
|
|
7016
7860
|
});
|
|
7861
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
7862
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
7017
7863
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
7018
7864
|
const scanStartTime = Date.now();
|
|
7019
7865
|
let lastReportedTotalFileCount = 0;
|
|
@@ -7030,7 +7876,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7030
7876
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
7031
7877
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
7032
7878
|
lastReportedTotalFileCount = totalFileCount;
|
|
7033
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
7879
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
7034
7880
|
}
|
|
7035
7881
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
7036
7882
|
yield* Ref.set(lintFailure, {
|
|
@@ -7045,7 +7891,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7045
7891
|
const lintFailureState = yield* Ref.get(lintFailure);
|
|
7046
7892
|
yield* afterLint(lintFailureState.didFail);
|
|
7047
7893
|
if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
|
|
7048
|
-
const shouldRunDeadCode = input.runDeadCode && !isDiffMode;
|
|
7894
|
+
const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
|
|
7049
7895
|
const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update("Analyzing dead code...").pipe(Effect.andThen(Stream.runCollect(applyPerElementPipeline(deadCodeService.run({
|
|
7050
7896
|
rootDirectory: scanDirectory,
|
|
7051
7897
|
userConfig: resolvedConfig.config
|
|
@@ -7057,10 +7903,12 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7057
7903
|
return Stream.empty;
|
|
7058
7904
|
}))))))));
|
|
7059
7905
|
const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
|
|
7060
|
-
const
|
|
7906
|
+
const scanElapsedMilliseconds = Date.now() - scanStartTime;
|
|
7907
|
+
const scanElapsedSeconds = (scanElapsedMilliseconds / 1e3).toFixed(1);
|
|
7061
7908
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
7062
7909
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
7063
|
-
else yield* scanProgress.
|
|
7910
|
+
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
7911
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
7064
7912
|
yield* reporterService.finalize;
|
|
7065
7913
|
const finalDiagnostics = [
|
|
7066
7914
|
...envCollected,
|
|
@@ -7100,7 +7948,10 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7100
7948
|
lintFailureReasonKind: lintFailureState.reasonKind,
|
|
7101
7949
|
lintPartialFailures,
|
|
7102
7950
|
didDeadCodeFail: deadCodeFailureState.didFail,
|
|
7103
|
-
deadCodeFailureReason: deadCodeFailureState.reason
|
|
7951
|
+
deadCodeFailureReason: deadCodeFailureState.reason,
|
|
7952
|
+
scannedFileCount: totalFileCount,
|
|
7953
|
+
scannedFilePaths,
|
|
7954
|
+
scanElapsedMilliseconds
|
|
7104
7955
|
};
|
|
7105
7956
|
}).pipe(Effect.withSpan("runInspect", { attributes: {
|
|
7106
7957
|
"inspect.directory": input.directory,
|
|
@@ -7109,7 +7960,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7109
7960
|
"inspect.isCi": input.isCi,
|
|
7110
7961
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
7111
7962
|
} }));
|
|
7112
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
7113
7963
|
const parseNodeVersion = (versionString) => {
|
|
7114
7964
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
7115
7965
|
return {
|
|
@@ -7226,7 +8076,7 @@ const isPathInsideDirectory = (childAbsolutePath, parentAbsolutePath) => {
|
|
|
7226
8076
|
static layerNode = Layer.effect(StagedFiles, Effect.gen(function* () {
|
|
7227
8077
|
const git = yield* Git;
|
|
7228
8078
|
return StagedFiles.of({
|
|
7229
|
-
discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter(
|
|
8079
|
+
discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter(isLintableSourceFile))),
|
|
7230
8080
|
materialize: ({ directory, stagedFiles, tempDirectory }) => Effect.gen(function* () {
|
|
7231
8081
|
const materializedFiles = [];
|
|
7232
8082
|
const resolvedTempDirectory = path.resolve(tempDirectory);
|
|
@@ -7408,6 +8258,26 @@ const buildJsonReport = (input) => {
|
|
|
7408
8258
|
};
|
|
7409
8259
|
};
|
|
7410
8260
|
/**
|
|
8261
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
8262
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
8263
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
8264
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
8265
|
+
* branch logic lives here once.
|
|
8266
|
+
*/
|
|
8267
|
+
const buildSkippedChecks = (input) => {
|
|
8268
|
+
const skippedChecks = [];
|
|
8269
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
8270
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
8271
|
+
const skippedCheckReasons = {};
|
|
8272
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
8273
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
8274
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
8275
|
+
return {
|
|
8276
|
+
skippedChecks,
|
|
8277
|
+
skippedCheckReasons
|
|
8278
|
+
};
|
|
8279
|
+
};
|
|
8280
|
+
/**
|
|
7411
8281
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
7412
8282
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
7413
8283
|
* spawn, not `spawnSync`).
|
|
@@ -7435,7 +8305,7 @@ const getDiffInfo = (directory, explicitBaseBranch) => Effect.runPromise(Effect.
|
|
|
7435
8305
|
GitBaseBranchInvalid: (reason) => Effect.die(new Error(reason.detail)),
|
|
7436
8306
|
GitBaseBranchMissing: (reason) => Effect.die(new Error(reason.message))
|
|
7437
8307
|
})));
|
|
7438
|
-
const filterSourceFiles = (filePaths) => filePaths.filter(
|
|
8308
|
+
const filterSourceFiles = (filePaths) => filePaths.filter(isLintableSourceFile);
|
|
7439
8309
|
var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
7440
8310
|
let p = process || {}, argv = p.argv || [], env = p.env || {};
|
|
7441
8311
|
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);
|
|
@@ -7513,7 +8383,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
|
|
|
7513
8383
|
const clearAutoSuppressionCaches = () => {};
|
|
7514
8384
|
//#endregion
|
|
7515
8385
|
//#region ../api/dist/index.js
|
|
7516
|
-
const
|
|
8386
|
+
const buildDiagnoseLayer = (configLayer = Config.layerNode) => Layer.mergeAll(Project.layerNode, configLayer, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
7517
8387
|
const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
7518
8388
|
const effectiveConfig = configOverride ?? scanTarget.userConfig;
|
|
7519
8389
|
const includePaths = options.includePaths ?? [];
|
|
@@ -7522,6 +8392,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7522
8392
|
includePaths,
|
|
7523
8393
|
customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
|
|
7524
8394
|
respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
|
|
8395
|
+
warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
|
|
7525
8396
|
adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
|
|
7526
8397
|
ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
|
|
7527
8398
|
runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
|
|
@@ -7531,13 +8402,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
7531
8402
|
};
|
|
7532
8403
|
const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
7533
8404
|
if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
|
|
7534
|
-
const skippedChecks =
|
|
7535
|
-
if (output.didLintFail) skippedChecks.push("lint");
|
|
7536
|
-
if (output.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
7537
|
-
const skippedCheckReasons = {};
|
|
7538
|
-
if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
|
|
7539
|
-
else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
|
|
7540
|
-
if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
|
|
8405
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
|
|
7541
8406
|
return {
|
|
7542
8407
|
diagnostics: [...output.diagnostics],
|
|
7543
8408
|
score: output.score,
|
|
@@ -7550,7 +8415,7 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
|
7550
8415
|
const diagnose = async (directory, options = {}) => {
|
|
7551
8416
|
const startTime = globalThis.performance.now();
|
|
7552
8417
|
const program = buildInspectProgram(resolveScanTarget(directory), options);
|
|
7553
|
-
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(
|
|
8418
|
+
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
|
|
7554
8419
|
};
|
|
7555
8420
|
//#endregion
|
|
7556
8421
|
//#region src/index.ts
|
|
@@ -7559,6 +8424,7 @@ const clearCaches = () => {
|
|
|
7559
8424
|
clearConfigCache();
|
|
7560
8425
|
clearPackageJsonCache();
|
|
7561
8426
|
clearIgnorePatternsCache();
|
|
8427
|
+
clearPackageRoleCache();
|
|
7562
8428
|
clearAutoSuppressionCaches();
|
|
7563
8429
|
};
|
|
7564
8430
|
const toJsonReport = (result, options) => buildJsonReport({
|