react-doctor 0.2.14-dev.b612664 → 0.2.14-dev.bb15252

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/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() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
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 && SOURCE_FILE_PATTERN.test(filePath)).length;
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 someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
2847
- if (predicate(rootPackageJson)) return true;
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 false;
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
- if (predicate(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
2888
+ const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
2889
+ if (value !== null) return value;
2857
2890
  }
2858
2891
  }
2859
- return false;
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
- "expo",
2865
- "expo-router",
2866
- "@expo/cli",
2867
- "@expo/metro-config",
2868
- "@expo/metro-runtime",
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) return 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. ignore filters (rules / file patterns / per-file overrides)
3866
- * 4. `rn-no-raw-text` suppression via configured `textComponents` and
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
- * 5. inline suppressions (`// react-doctor-disable-next-line ...`)
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
- const override = resolveRuleSeverityOverride({
4078
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
4079
+ explicitSeverityOverride = resolveRuleSeverityOverride({
3931
4080
  ruleKey,
3932
4081
  category
3933
4082
  }, severityControls);
3934
- if (override === "off") return null;
3935
- if (override !== void 0) current = restampSeverity(current, override);
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 validated = {};
4416
+ const validatedSurfaceControls = {};
4221
4417
  for (const fieldName of SURFACE_CONTROL_FIELD_NAMES) {
4222
4418
  if (rawControls[fieldName] === void 0) continue;
4223
- const result = validateStringArrayField(`surfaces.${surface}.${fieldName}`, rawControls[fieldName]);
4224
- if (result !== void 0) validated[fieldName] = result;
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 validated;
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
  };
@@ -4356,11 +4558,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
4356
4558
  }
4357
4559
  return resolvedRootDir;
4358
4560
  };
4359
- const resolveDiagnoseTarget = (directory) => {
4561
+ const resolveDiagnoseTarget = (directory, options = {}) => {
4360
4562
  if (isFile(path.join(directory, "package.json"))) return directory;
4361
4563
  const reactSubprojects = discoverReactSubprojects(directory);
4362
4564
  if (reactSubprojects.length === 0) return null;
4363
4565
  if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
4566
+ if (options.allowAmbiguous === true) return null;
4364
4567
  throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
4365
4568
  };
4366
4569
  /**
@@ -4374,7 +4577,8 @@ const resolveDiagnoseTarget = (directory) => {
4374
4577
  * project root, if configured.
4375
4578
  * 4. Walk into a nested React subproject when the requested
4376
4579
  * directory has no `package.json` of its own (raises
4377
- * `AmbiguousProjectError` when multiple candidates exist).
4580
+ * `AmbiguousProjectError` when multiple candidates exist unless
4581
+ * the caller opts into keeping the wrapper directory).
4378
4582
  *
4379
4583
  * Throws `ProjectNotFoundError` when neither the requested directory
4380
4584
  * nor any discoverable nested project has a `package.json`.
@@ -4386,14 +4590,14 @@ const resolveDiagnoseTarget = (directory) => {
4386
4590
  * via its own cache). Routing through `resolveScanTarget` keeps every
4387
4591
  * shell in agreement on what "the scan directory" means.
4388
4592
  */
4389
- const resolveScanTarget = (requestedDirectory) => {
4593
+ const resolveScanTarget = (requestedDirectory, options = {}) => {
4390
4594
  const absoluteRequested = path.resolve(requestedDirectory);
4391
4595
  const loadedConfig = loadConfigWithSource(absoluteRequested);
4392
4596
  const userConfig = loadedConfig?.config ?? null;
4393
4597
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4394
4598
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
4395
4599
  const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
4396
- const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
4600
+ const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
4397
4601
  if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
4398
4602
  return {
4399
4603
  resolvedDirectory,
@@ -4403,6 +4607,359 @@ const resolveScanTarget = (requestedDirectory) => {
4403
4607
  didRedirectViaRootDir: redirectedDirectory !== null
4404
4608
  };
4405
4609
  };
4610
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
4611
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
4612
+ const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
4613
+ return {
4614
+ rootDirectory,
4615
+ packageJson,
4616
+ directDependencyNames: getDirectDependencyNames(packageJson),
4617
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
4618
+ };
4619
+ };
4620
+ const buildExpoDiagnostic = (input) => ({
4621
+ filePath: input.filePath ?? "package.json",
4622
+ plugin: "react-doctor",
4623
+ rule: input.rule,
4624
+ severity: input.severity ?? "warning",
4625
+ message: input.message,
4626
+ help: input.help,
4627
+ line: input.line ?? 0,
4628
+ column: input.column ?? 0,
4629
+ category: input.category ?? "Correctness"
4630
+ });
4631
+ const CRITICAL_OVERRIDE_NAMES = new Set([
4632
+ "@expo/cli",
4633
+ "@expo/config",
4634
+ "@expo/metro-config",
4635
+ "@expo/metro-runtime",
4636
+ "@expo/metro",
4637
+ "metro"
4638
+ ]);
4639
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
4640
+ const collectOverrideNames = (packageJson) => new Set([
4641
+ ...Object.keys(packageJson.overrides ?? {}),
4642
+ ...Object.keys(packageJson.resolutions ?? {}),
4643
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
4644
+ ]);
4645
+ const checkExpoDependencyOverrides = (context) => {
4646
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
4647
+ if (overriddenCriticalNames.length === 0) return [];
4648
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
4649
+ return [buildExpoDiagnostic({
4650
+ rule: "expo-no-conflicting-dependency-override",
4651
+ 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`,
4652
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
4653
+ })];
4654
+ };
4655
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
4656
+ const result = spawnSync("git", [
4657
+ "check-ignore",
4658
+ "-q",
4659
+ absolutePath
4660
+ ], {
4661
+ cwd: rootDirectory,
4662
+ stdio: [
4663
+ "ignore",
4664
+ "ignore",
4665
+ "ignore"
4666
+ ]
4667
+ });
4668
+ if (result.error) return null;
4669
+ if (result.status === 0) return true;
4670
+ if (result.status === 1) return false;
4671
+ return null;
4672
+ };
4673
+ const LOCAL_ENV_FILE_NAMES = [
4674
+ ".env.local",
4675
+ ".env.development.local",
4676
+ ".env.production.local",
4677
+ ".env.test.local"
4678
+ ];
4679
+ const checkExpoEnvLocalFiles = (context) => {
4680
+ const { rootDirectory } = context;
4681
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
4682
+ const filePath = path.join(rootDirectory, fileName);
4683
+ if (!isFile(filePath)) return false;
4684
+ return isPathGitIgnored(rootDirectory, filePath) === false;
4685
+ });
4686
+ if (committedEnvFiles.length === 0) return [];
4687
+ return [buildExpoDiagnostic({
4688
+ rule: "expo-env-local-not-gitignored",
4689
+ category: "Security",
4690
+ 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`,
4691
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
4692
+ })];
4693
+ };
4694
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
4695
+ 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";
4696
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
4697
+ const unimodulesEntry = (packageName) => ({
4698
+ packageName,
4699
+ rule: "expo-no-unimodules-packages",
4700
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
4701
+ help: UNIMODULES_HELP
4702
+ });
4703
+ const FLAGGED_DEPENDENCIES = [
4704
+ unimodulesEntry("@unimodules/core"),
4705
+ unimodulesEntry("@unimodules/react-native-adapter"),
4706
+ unimodulesEntry("react-native-unimodules"),
4707
+ {
4708
+ packageName: "expo-cli",
4709
+ rule: "expo-no-cli-dependencies",
4710
+ 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`",
4711
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
4712
+ },
4713
+ {
4714
+ packageName: "eas-cli",
4715
+ rule: "expo-no-cli-dependencies",
4716
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
4717
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
4718
+ },
4719
+ {
4720
+ packageName: "expo-modules-autolinking",
4721
+ rule: "expo-no-redundant-dependency",
4722
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
4723
+ help: "Remove `expo-modules-autolinking` from your package.json"
4724
+ },
4725
+ {
4726
+ packageName: "expo-dev-launcher",
4727
+ rule: "expo-no-redundant-dependency",
4728
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4729
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
4730
+ },
4731
+ {
4732
+ packageName: "expo-dev-menu",
4733
+ rule: "expo-no-redundant-dependency",
4734
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4735
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
4736
+ },
4737
+ {
4738
+ packageName: "expo-modules-core",
4739
+ rule: "expo-no-redundant-dependency",
4740
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
4741
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
4742
+ },
4743
+ {
4744
+ packageName: "@expo/metro-config",
4745
+ rule: "expo-no-redundant-dependency",
4746
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
4747
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
4748
+ },
4749
+ {
4750
+ packageName: "@types/react-native",
4751
+ rule: "expo-no-redundant-dependency",
4752
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
4753
+ help: "Remove `@types/react-native` from your package.json",
4754
+ minSdkMajor: 48
4755
+ },
4756
+ {
4757
+ packageName: "@expo/config-plugins",
4758
+ rule: "expo-no-redundant-dependency",
4759
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
4760
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
4761
+ minSdkMajor: 48
4762
+ },
4763
+ {
4764
+ packageName: "@expo/prebuild-config",
4765
+ rule: "expo-no-redundant-dependency",
4766
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
4767
+ help: "Remove `@expo/prebuild-config` from your package.json",
4768
+ minSdkMajor: 53
4769
+ },
4770
+ {
4771
+ packageName: "expo-permissions",
4772
+ rule: "expo-no-redundant-dependency",
4773
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
4774
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
4775
+ minSdkMajor: 50
4776
+ },
4777
+ {
4778
+ packageName: "expo-app-loading",
4779
+ rule: "expo-no-redundant-dependency",
4780
+ message: "\"expo-app-loading\" was removed in SDK 49",
4781
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
4782
+ minSdkMajor: 49
4783
+ },
4784
+ {
4785
+ packageName: "expo-firebase-analytics",
4786
+ rule: "expo-no-redundant-dependency",
4787
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
4788
+ help: FIREBASE_HELP,
4789
+ minSdkMajor: 48
4790
+ },
4791
+ {
4792
+ packageName: "expo-firebase-recaptcha",
4793
+ rule: "expo-no-redundant-dependency",
4794
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
4795
+ help: FIREBASE_HELP,
4796
+ minSdkMajor: 48
4797
+ },
4798
+ {
4799
+ packageName: "expo-firebase-core",
4800
+ rule: "expo-no-redundant-dependency",
4801
+ message: "\"expo-firebase-core\" was removed in SDK 48",
4802
+ help: FIREBASE_HELP,
4803
+ minSdkMajor: 48
4804
+ }
4805
+ ];
4806
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
4807
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
4808
+ if (flaggedDependency.minSdkMajor === void 0) return true;
4809
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
4810
+ }).map((flaggedDependency) => buildExpoDiagnostic({
4811
+ rule: flaggedDependency.rule,
4812
+ message: flaggedDependency.message,
4813
+ help: flaggedDependency.help
4814
+ }));
4815
+ const findLocalModuleNativeFiles = (rootDirectory) => {
4816
+ const modulesDirectory = path.join(rootDirectory, "modules");
4817
+ if (!isDirectory(modulesDirectory)) return [];
4818
+ const nativeFilePaths = [];
4819
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
4820
+ if (!moduleEntry.isDirectory()) continue;
4821
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
4822
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
4823
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
4824
+ const iosDirectory = path.join(moduleDirectory, "ios");
4825
+ if (isDirectory(iosDirectory)) {
4826
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
4827
+ }
4828
+ }
4829
+ return nativeFilePaths;
4830
+ };
4831
+ const checkExpoGitignore = (context) => {
4832
+ const { rootDirectory } = context;
4833
+ const diagnostics = [];
4834
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
4835
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
4836
+ rule: "expo-gitignore",
4837
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
4838
+ help: "Add `.expo/` to your .gitignore"
4839
+ }));
4840
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
4841
+ rule: "expo-gitignore",
4842
+ 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",
4843
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
4844
+ }));
4845
+ return diagnostics;
4846
+ };
4847
+ const LOCKFILE_NAMES = [
4848
+ "pnpm-lock.yaml",
4849
+ "yarn.lock",
4850
+ "package-lock.json",
4851
+ "bun.lockb",
4852
+ "bun.lock"
4853
+ ];
4854
+ const checkExpoLockfile = (context) => {
4855
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
4856
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
4857
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
4858
+ rule: "expo-lockfile",
4859
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
4860
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
4861
+ })];
4862
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
4863
+ rule: "expo-lockfile",
4864
+ 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`,
4865
+ help: "Delete the lock files for the package managers you are not using and keep only one"
4866
+ })];
4867
+ return [];
4868
+ };
4869
+ const METRO_CONFIG_FILE_NAMES = [
4870
+ "metro.config.js",
4871
+ "metro.config.cjs",
4872
+ "metro.config.mjs",
4873
+ "metro.config.ts"
4874
+ ];
4875
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
4876
+ "expo/metro-config",
4877
+ "@sentry/react-native/metro",
4878
+ "getSentryExpoConfig"
4879
+ ];
4880
+ const checkExpoMetroConfig = (context) => {
4881
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
4882
+ if (metroConfigPath === void 0) return [];
4883
+ let contents;
4884
+ try {
4885
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
4886
+ } catch {
4887
+ return [];
4888
+ }
4889
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
4890
+ return [buildExpoDiagnostic({
4891
+ rule: "expo-metro-config",
4892
+ filePath: path.basename(metroConfigPath),
4893
+ 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",
4894
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
4895
+ })];
4896
+ };
4897
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
4898
+ const checkExpoPackageJsonConflicts = (context) => {
4899
+ const { packageJson } = context;
4900
+ const diagnostics = [];
4901
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
4902
+ if (conflictingScriptNames.length > 0) {
4903
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
4904
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
4905
+ diagnostics.push(buildExpoDiagnostic({
4906
+ rule: "expo-package-json-conflict",
4907
+ 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" : ""}`,
4908
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
4909
+ }));
4910
+ }
4911
+ const packageName = packageJson.name;
4912
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
4913
+ rule: "expo-package-json-conflict",
4914
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
4915
+ help: "Rename your package so it no longer matches one of its dependencies"
4916
+ }));
4917
+ return diagnostics;
4918
+ };
4919
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
4920
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
4921
+ const checkExpoRouterReactNavigation = (context) => {
4922
+ const { expoSdkMajor } = context;
4923
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
4924
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
4925
+ if (!context.directDependencyNames.has("expo-router")) return [];
4926
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
4927
+ if (reactNavigationNames.length === 0) return [];
4928
+ return [buildExpoDiagnostic({
4929
+ rule: "expo-router-no-react-navigation",
4930
+ 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"}`,
4931
+ 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/"
4932
+ })];
4933
+ };
4934
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
4935
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
4936
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
4937
+ const checkExpoVectorIcons = (context) => {
4938
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
4939
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
4940
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
4941
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
4942
+ return [buildExpoDiagnostic({
4943
+ rule: "expo-vector-icons-conflict",
4944
+ 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",
4945
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
4946
+ })];
4947
+ };
4948
+ const checkExpoProject = (rootDirectory, project) => {
4949
+ if (project.expoVersion === null) return [];
4950
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
4951
+ return [
4952
+ ...checkExpoFlaggedDependencies(context),
4953
+ ...checkExpoDependencyOverrides(context),
4954
+ ...checkExpoRouterReactNavigation(context),
4955
+ ...checkExpoVectorIcons(context),
4956
+ ...checkExpoPackageJsonConflicts(context),
4957
+ ...checkExpoLockfile(context),
4958
+ ...checkExpoGitignore(context),
4959
+ ...checkExpoEnvLocalFiles(context),
4960
+ ...checkExpoMetroConfig(context)
4961
+ ];
4962
+ };
4406
4963
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4407
4964
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
4408
4965
  const PACKAGE_JSON_FILE = "package.json";
@@ -4572,99 +5129,6 @@ const checkReducedMotion = (rootDirectory) => {
4572
5129
  return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
4573
5130
  };
4574
5131
  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
5132
  const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
4669
5133
  const FALSY_VALUES = new Set([
4670
5134
  "false",
@@ -4746,6 +5210,30 @@ const collectIgnorePatterns = (rootDirectory) => {
4746
5210
  cachedPatternsByRoot.set(rootDirectory, patterns);
4747
5211
  return patterns;
4748
5212
  };
5213
+ /**
5214
+ * Resolves a path to its canonical, symlink-free form, falling back to
5215
+ * the input when it cannot be realpath'd (broken symlink, permission
5216
+ * error) so a best-effort normalization never throws.
5217
+ *
5218
+ * deslop's dead-code module graph is collected with `fast-glob` (which
5219
+ * keeps the scan root's symlinks intact) while imports are resolved
5220
+ * through `oxc-resolver` (which returns realpath'd targets). When the
5221
+ * project root sits behind a symlink — e.g. macOS iCloud-synced
5222
+ * `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
5223
+ * spaces diverge: every resolved import misses the graph and the files
5224
+ * they point at (commonly every `@/…` alias target) are mis-reported as
5225
+ * unreachable. Canonicalizing the root before the scan keeps both path
5226
+ * spaces in agreement.
5227
+ */
5228
+ const toCanonicalPath = (filePath) => {
5229
+ try {
5230
+ return fs.realpathSync(filePath);
5231
+ } catch {
5232
+ return filePath;
5233
+ }
5234
+ };
5235
+ const DEAD_CODE_PLUGIN = "deslop";
5236
+ const DEAD_CODE_CATEGORY = "Maintainability";
4749
5237
  const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
4750
5238
  const DEAD_CODE_WORKER_SCRIPT = `
4751
5239
  const inputChunks = [];
@@ -4921,7 +5409,11 @@ const buildDeadCodeWorkerError = (workerError) => {
4921
5409
  return error;
4922
5410
  };
4923
5411
  const createDeadCodeWorker = (input) => {
4924
- const child = spawn(process.execPath, ["-e", DEAD_CODE_WORKER_SCRIPT], {
5412
+ const child = spawn(process.execPath, [
5413
+ `--max-old-space-size=${DEAD_CODE_WORKER_MAX_OLD_SPACE_MB}`,
5414
+ "-e",
5415
+ DEAD_CODE_WORKER_SCRIPT
5416
+ ], {
4925
5417
  stdio: [
4926
5418
  "pipe",
4927
5419
  "pipe",
@@ -4996,7 +5488,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
4996
5488
  });
4997
5489
  });
4998
5490
  const checkDeadCode = async (options) => {
4999
- const { rootDirectory, userConfig } = options;
5491
+ const { userConfig } = options;
5492
+ const rootDirectory = toCanonicalPath(options.rootDirectory);
5000
5493
  if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
5001
5494
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
5002
5495
  const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
@@ -5009,59 +5502,162 @@ const checkDeadCode = async (options) => {
5009
5502
  const diagnostics = [];
5010
5503
  for (const unusedFile of result.unusedFiles) diagnostics.push({
5011
5504
  filePath: toRelative(unusedFile.path),
5012
- plugin: "deslop",
5505
+ plugin: DEAD_CODE_PLUGIN,
5013
5506
  rule: "unused-file",
5014
5507
  severity: "warning",
5015
5508
  message: "Unused file — not reachable from any entry point",
5016
5509
  help: "Delete the file if it is truly unreachable, or import it from an entry point.",
5017
5510
  line: 0,
5018
5511
  column: 0,
5019
- category: "Dead Code"
5512
+ category: DEAD_CODE_CATEGORY
5020
5513
  });
5021
5514
  for (const unusedExport of result.unusedExports) {
5022
5515
  const label = unusedExport.isTypeOnly ? "type export" : "export";
5023
5516
  diagnostics.push({
5024
5517
  filePath: toRelative(unusedExport.path),
5025
- plugin: "deslop",
5518
+ plugin: DEAD_CODE_PLUGIN,
5026
5519
  rule: unusedExport.isTypeOnly ? "unused-type" : "unused-export",
5027
5520
  severity: "warning",
5028
5521
  message: `Unused ${label}: \`${unusedExport.name}\``,
5029
5522
  help: "Drop the `export` keyword (or remove the declaration) if no other module uses this symbol.",
5030
5523
  line: unusedExport.line,
5031
5524
  column: unusedExport.column,
5032
- category: "Dead Code"
5525
+ category: DEAD_CODE_CATEGORY
5033
5526
  });
5034
5527
  }
5035
5528
  for (const unusedDependency of result.unusedDependencies) {
5036
5529
  const label = unusedDependency.isDevDependency ? "devDependency" : "dependency";
5037
5530
  diagnostics.push({
5038
5531
  filePath: "package.json",
5039
- plugin: "deslop",
5532
+ plugin: DEAD_CODE_PLUGIN,
5040
5533
  rule: unusedDependency.isDevDependency ? "unused-dev-dependency" : "unused-dependency",
5041
5534
  severity: "warning",
5042
5535
  message: `Unused ${label}: \`${unusedDependency.name}\``,
5043
5536
  help: "Remove the dependency from package.json if it is genuinely unused.",
5044
5537
  line: 0,
5045
5538
  column: 0,
5046
- category: "Dead Code"
5539
+ category: DEAD_CODE_CATEGORY
5047
5540
  });
5048
5541
  }
5049
5542
  for (const cycle of result.circularDependencies) {
5050
5543
  if (cycle.files.length === 0) continue;
5051
5544
  diagnostics.push({
5052
5545
  filePath: toRelative(cycle.files[0]),
5053
- plugin: "deslop",
5546
+ plugin: DEAD_CODE_PLUGIN,
5054
5547
  rule: "circular-dependency",
5055
5548
  severity: "warning",
5056
5549
  message: `Circular import cycle: ${cycle.files.map(toRelative).join(" → ")}`,
5057
5550
  help: "Break the cycle by extracting the shared code into a third module that both files import.",
5058
5551
  line: 0,
5059
5552
  column: 0,
5060
- category: "Dead Code"
5553
+ category: DEAD_CODE_CATEGORY
5061
5554
  });
5062
5555
  }
5063
5556
  return diagnostics;
5064
5557
  };
5558
+ const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
5559
+ const isSurfacingOverride = (override) => override === "warn" || override === "error";
5560
+ const deadCodeMaySurfaceWhenWarningsHidden = (userConfig) => {
5561
+ const severityControls = buildRuleSeverityControls(userConfig);
5562
+ if (!severityControls) return false;
5563
+ if (isSurfacingOverride(severityControls.categories?.["Maintainability"])) return true;
5564
+ for (const [ruleKey, override] of Object.entries(severityControls.rules ?? {})) if (ruleKey.startsWith(DEAD_CODE_RULE_KEY_PREFIX) && isSurfacingOverride(override)) return true;
5565
+ return false;
5566
+ };
5567
+ const toStringSet = (values) => {
5568
+ if (!values || values.length === 0) return /* @__PURE__ */ new Set();
5569
+ return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
5570
+ };
5571
+ const buildResolvedControls = (surface, userControls) => {
5572
+ const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
5573
+ const includeTags = toStringSet(userControls?.includeTags);
5574
+ for (const tag of includeTags) excludeTags.delete(tag);
5575
+ for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
5576
+ return {
5577
+ includeTags,
5578
+ excludeTags,
5579
+ includeCategories: toStringSet(userControls?.includeCategories),
5580
+ excludeCategories: toStringSet(userControls?.excludeCategories),
5581
+ includeRuleKeys: toStringSet(userControls?.includeRules),
5582
+ excludeRuleKeys: toStringSet(userControls?.excludeRules)
5583
+ };
5584
+ };
5585
+ const intersects = (values, candidates) => values.some((value) => candidates.has(value));
5586
+ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
5587
+ const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
5588
+ const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
5589
+ if (resolved.includeRuleKeys.has(ruleKey)) return true;
5590
+ if (resolved.includeCategories.has(category)) return true;
5591
+ if (intersects(tags, resolved.includeTags)) return true;
5592
+ if (resolved.excludeRuleKeys.has(ruleKey)) return false;
5593
+ if (resolved.excludeCategories.has(category)) return false;
5594
+ if (intersects(tags, resolved.excludeTags)) return false;
5595
+ return true;
5596
+ };
5597
+ const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
5598
+ const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(path.resolve(rootDirectory, relativePath)));
5599
+ const listSourceFilesViaGit = (rootDirectory) => {
5600
+ const result = spawnSync("git", [
5601
+ "ls-files",
5602
+ "-z",
5603
+ "--cached",
5604
+ "--others",
5605
+ "--exclude-standard"
5606
+ ], {
5607
+ cwd: rootDirectory,
5608
+ encoding: "utf-8",
5609
+ maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
5610
+ });
5611
+ if (result.error || result.status !== 0) return null;
5612
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
5613
+ };
5614
+ const listSourceFilesViaFilesystem = (rootDirectory) => {
5615
+ const filePaths = [];
5616
+ const stack = [rootDirectory];
5617
+ while (stack.length > 0) {
5618
+ const currentDirectory = stack.pop();
5619
+ const entries = readDirectoryEntries(currentDirectory);
5620
+ for (const entry of entries) {
5621
+ const absolutePath = path.join(currentDirectory, entry.name);
5622
+ if (entry.isDirectory()) {
5623
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
5624
+ continue;
5625
+ }
5626
+ if (entry.isFile() && isLintableSourceFile(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
5627
+ }
5628
+ }
5629
+ return filePaths;
5630
+ };
5631
+ const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
5632
+ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
5633
+ if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
5634
+ const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
5635
+ return listSourceFiles(rootDirectory).filter((filePath) => {
5636
+ if (!JSX_FILE_PATTERN.test(filePath)) return false;
5637
+ return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
5638
+ });
5639
+ };
5640
+ var Config = class Config extends Context.Service()("react-doctor/Config") {
5641
+ static layerNode = Layer.effect(Config, Effect.gen(function* () {
5642
+ const cache = yield* Cache.make({
5643
+ capacity: 16,
5644
+ timeToLive: CONFIG_CACHE_TTL_MS,
5645
+ lookup: (directory) => Effect.sync(() => {
5646
+ const loaded = loadConfigWithSource(directory);
5647
+ const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
5648
+ return {
5649
+ config: loaded?.config ?? null,
5650
+ resolvedDirectory: redirected ?? directory,
5651
+ configSourceDirectory: loaded?.sourceDirectory ?? null
5652
+ };
5653
+ })
5654
+ });
5655
+ return Config.of({ resolve: Effect.fn("Config.resolve")(function* (directory) {
5656
+ return yield* Cache.get(cache, directory);
5657
+ }) });
5658
+ }));
5659
+ static layerOf = (resolved) => Layer.succeed(Config, Config.of({ resolve: () => Effect.succeed(resolved) }));
5660
+ };
5065
5661
  /**
5066
5662
  * `DeadCode` runs whole-project reachability analysis and streams
5067
5663
  * diagnostics. Reachability is a whole-project property — the
@@ -5567,12 +6163,12 @@ const findFilesWithDisableDirectivesViaGit = async (rootDirectory, includePaths)
5567
6163
  return null;
5568
6164
  }
5569
6165
  if (grepResult === null) return null;
5570
- return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
6166
+ return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
5571
6167
  };
5572
6168
  const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
5573
6169
  const matches = [];
5574
6170
  const checkFile = (relativePath) => {
5575
- if (!SOURCE_FILE_PATTERN.test(relativePath)) return;
6171
+ if (!isLintableSourceFile(relativePath)) return;
5576
6172
  const absolutePath = path.join(rootDirectory, relativePath);
5577
6173
  let content;
5578
6174
  try {
@@ -5644,6 +6240,7 @@ const buildCapabilities = (project) => {
5644
6240
  const capabilities = /* @__PURE__ */ new Set();
5645
6241
  capabilities.add(project.framework);
5646
6242
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
6243
+ if (project.expoVersion !== null) capabilities.add("expo");
5647
6244
  const reactMajor = project.reactMajorVersion;
5648
6245
  if (reactMajor !== null) {
5649
6246
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -5815,10 +6412,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
5815
6412
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
5816
6413
  return fs.realpathSync(rootDirectory);
5817
6414
  };
6415
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
6416
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
6417
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
6418
+ };
5818
6419
  const applyRuleSeverityControls = (rules, severityControls) => {
5819
6420
  const enabledRules = {};
5820
6421
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
5821
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
6422
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
5822
6423
  if (severity === "off") continue;
5823
6424
  enabledRules[ruleKey] = severity;
5824
6425
  }
@@ -5860,7 +6461,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
5860
6461
  category: rule.category
5861
6462
  }, severityControls);
5862
6463
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
5863
- const severity = explicitSeverity ?? rule.severity;
6464
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
5864
6465
  if (severity === "off") continue;
5865
6466
  enabledReactDoctorRules[registryEntry.key] = severity;
5866
6467
  }
@@ -5917,6 +6518,44 @@ const dedupeDiagnostics = (diagnostics) => {
5917
6518
  }
5918
6519
  return uniqueDiagnostics;
5919
6520
  };
6521
+ /**
6522
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
6523
+ * once, returning results in input order. A pool of workers each pulls the
6524
+ * next not-yet-started index until the list drains — so a worker that
6525
+ * finishes a fast task immediately picks up the next one (greedy load
6526
+ * balancing), which matters when tasks have uneven durations (oxlint
6527
+ * batches do).
6528
+ *
6529
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
6530
+ * no further tasks are started, the already-in-flight tasks are awaited to
6531
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
6532
+ * rejects with that first error. This keeps the caller's fail-fast retry
6533
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
6534
+ * top of a still-running first one.
6535
+ */
6536
+ const mapWithConcurrency = async (items, concurrency, task) => {
6537
+ const results = new Array(items.length);
6538
+ if (items.length === 0) return results;
6539
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
6540
+ let nextIndex = 0;
6541
+ const errors = [];
6542
+ const runWorker = async () => {
6543
+ while (errors.length === 0) {
6544
+ const index = nextIndex;
6545
+ nextIndex += 1;
6546
+ if (index >= items.length) return;
6547
+ try {
6548
+ results[index] = await task(items[index], index);
6549
+ } catch (error) {
6550
+ errors.push(error);
6551
+ return;
6552
+ }
6553
+ }
6554
+ };
6555
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
6556
+ if (errors.length > 0) throw errors[0];
6557
+ return results;
6558
+ };
5920
6559
  const getPublicEnvPrefix = (framework) => {
5921
6560
  switch (framework) {
5922
6561
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -6032,7 +6671,7 @@ const KNOWN_SECRET_RULES = [
6032
6671
  }
6033
6672
  ];
6034
6673
  const CANDIDATE_TOKEN_PATTERN = /[A-Za-z0-9_][A-Za-z0-9_-]*/g;
6035
- const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6674
+ const HEX_DIGEST_PATTERN = /^(?:[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/;
6036
6675
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6037
6676
  const HAS_LETTER_PATTERN = /[A-Za-z]/;
6038
6677
  const HAS_DIGIT_PATTERN = /[0-9]/;
@@ -6049,7 +6688,7 @@ const shannonEntropyBits = (value) => {
6049
6688
  const looksLikeHighEntropySecret = (token) => {
6050
6689
  if (token.length < 32) return false;
6051
6690
  if (!HAS_LETTER_PATTERN.test(token) || !HAS_DIGIT_PATTERN.test(token)) return false;
6052
- if (GIT_OBJECT_ID_PATTERN.test(token) || UUID_PATTERN.test(token)) return false;
6691
+ if (HEX_DIGEST_PATTERN.test(token) || UUID_PATTERN.test(token)) return false;
6053
6692
  return shannonEntropyBits(token) >= 3;
6054
6693
  };
6055
6694
  const redactHighEntropyTokens = (text) => text.replace(CANDIDATE_TOKEN_PATTERN, (token) => looksLikeHighEntropySecret(token) ? REDACTED_PLACEHOLDER : token);
@@ -6376,25 +7015,26 @@ const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
6376
7015
  return bindingResolution !== null && !bindingResolution.isReactUseBinding;
6377
7016
  };
6378
7017
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
6379
- const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
7018
+ const REACT_COMPILER_TITLE = "React Compiler can't optimize this";
7019
+ const REACT_COMPILER_MESSAGE = "This component misses React Compiler's automatic memoization & re-renders more than it should. Rewrite the flagged code so the compiler can optimize it.";
6380
7020
  const PLUGIN_CATEGORY_MAP = {
6381
- react: "Correctness",
6382
- "react-hooks": "Correctness",
6383
- "react-hooks-js": "React Compiler",
6384
- "react-doctor": "Other",
7021
+ react: "Bugs",
7022
+ "react-hooks": "Bugs",
7023
+ "react-hooks-js": "Performance",
7024
+ "react-doctor": "Bugs",
6385
7025
  "jsx-a11y": "Accessibility",
6386
- effect: "State & Effects",
6387
- eslint: "Correctness",
6388
- oxc: "Correctness",
6389
- typescript: "Correctness",
6390
- unicorn: "Correctness",
6391
- import: "Bundle Size",
6392
- promise: "Correctness",
6393
- n: "Correctness",
6394
- node: "Correctness",
6395
- vitest: "Correctness",
6396
- jest: "Correctness",
6397
- nextjs: "Next.js"
7026
+ effect: "Bugs",
7027
+ eslint: "Bugs",
7028
+ oxc: "Bugs",
7029
+ typescript: "Bugs",
7030
+ unicorn: "Bugs",
7031
+ import: "Performance",
7032
+ promise: "Bugs",
7033
+ n: "Bugs",
7034
+ node: "Bugs",
7035
+ vitest: "Bugs",
7036
+ jest: "Bugs",
7037
+ nextjs: "Bugs"
6398
7038
  };
6399
7039
  const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
6400
7040
  const getRuleRecommendation = (ruleName, project) => {
@@ -6402,6 +7042,8 @@ const getRuleRecommendation = (ruleName, project) => {
6402
7042
  return reactDoctorPlugin.rules[ruleName]?.recommendation;
6403
7043
  };
6404
7044
  const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
7045
+ const getRuleTitle = (ruleName) => reactDoctorPlugin.rules[ruleName]?.title;
7046
+ const resolveDiagnosticTitle = (plugin, rule) => plugin === "react-hooks-js" ? REACT_COMPILER_TITLE : getRuleTitle(rule);
6405
7047
  const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
6406
7048
  const cleaned = resolveCleanedDiagnostic(typeof message === "string" ? message : "", typeof help === "string" ? help : "", plugin, rule, project);
6407
7049
  return {
@@ -6430,7 +7072,7 @@ const parseRuleCode = (code) => {
6430
7072
  rule: match[2]
6431
7073
  };
6432
7074
  };
6433
- const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
7075
+ const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Bugs";
6434
7076
  const isOxlintOutput = (value) => {
6435
7077
  if (typeof value !== "object" || value === null) return false;
6436
7078
  const candidate = value;
@@ -6454,7 +7096,16 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
6454
7096
  throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
6455
7097
  }
6456
7098
  if (!isOxlintOutput(parsed)) throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
6457
- return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
7099
+ const minifiedFileCache = /* @__PURE__ */ new Map();
7100
+ const isMinifiedDiagnosticFile = (filename) => {
7101
+ const absolutePath = path.isAbsolute(filename) ? filename : path.resolve(rootDirectory || ".", filename);
7102
+ const cached = minifiedFileCache.get(absolutePath);
7103
+ if (cached !== void 0) return cached;
7104
+ const minified = isMinifiedSource(absolutePath);
7105
+ minifiedFileCache.set(absolutePath, minified);
7106
+ return minified;
7107
+ };
7108
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && isLintableSourceFile(diagnostic.filename) && !isMinifiedDiagnosticFile(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
6458
7109
  const { plugin, rule } = parseRuleCode(diagnostic.code);
6459
7110
  const primaryLabel = diagnostic.labels[0];
6460
7111
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule, project);
@@ -6463,6 +7114,7 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
6463
7114
  plugin,
6464
7115
  rule,
6465
7116
  severity: diagnostic.severity,
7117
+ title: resolveDiagnosticTitle(plugin, rule),
6466
7118
  message: cleaned.message,
6467
7119
  help: cleaned.help,
6468
7120
  url: diagnostic.url,
@@ -6586,6 +7238,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
6586
7238
  */
6587
7239
  const spawnLintBatches = async (input) => {
6588
7240
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
7241
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
6589
7242
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
6590
7243
  const allDiagnostics = [];
6591
7244
  const droppedFiles = [];
@@ -6605,23 +7258,31 @@ const spawnLintBatches = async (input) => {
6605
7258
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
6606
7259
  }
6607
7260
  };
7261
+ let startedFileCount = 0;
6608
7262
  let scannedFileCount = 0;
6609
- for (const batch of fileBatches) {
6610
- let batchFileIndex = 0;
6611
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
6612
- if (batchFileIndex < batch.length) {
6613
- batchFileIndex += 1;
6614
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
6615
- }
6616
- }, 50) : null;
6617
- try {
7263
+ let displayedFileCount = 0;
7264
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
7265
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
7266
+ if (displayedFileCount < ceiling) {
7267
+ displayedFileCount += 1;
7268
+ onFileProgress(displayedFileCount, totalFileCount);
7269
+ }
7270
+ }, 50) : null;
7271
+ progressTimer?.unref?.();
7272
+ try {
7273
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
7274
+ startedFileCount += batch.length;
6618
7275
  const batchDiagnostics = await spawnLintBatch(batch);
6619
- allDiagnostics.push(...batchDiagnostics);
6620
7276
  scannedFileCount += batch.length;
6621
- onFileProgress?.(scannedFileCount, totalFileCount);
6622
- } finally {
6623
- if (progressInterval !== null) clearInterval(progressInterval);
6624
- }
7277
+ if (onFileProgress) {
7278
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
7279
+ onFileProgress(displayedFileCount, totalFileCount);
7280
+ }
7281
+ return batchDiagnostics;
7282
+ });
7283
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
7284
+ } finally {
7285
+ if (progressTimer !== null) clearInterval(progressTimer);
6625
7286
  }
6626
7287
  if (droppedFiles.length > 0 && onPartialFailure) {
6627
7288
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6748,7 +7409,8 @@ const runOxlint = async (options) => {
6748
7409
  onPartialFailure,
6749
7410
  onFileProgress: options.onFileProgress,
6750
7411
  spawnTimeoutMs,
6751
- outputMaxBytes
7412
+ outputMaxBytes,
7413
+ concurrency: options.concurrency
6752
7414
  });
6753
7415
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
6754
7416
  try {
@@ -6816,6 +7478,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6816
7478
  const partialFailures = yield* LintPartialFailures;
6817
7479
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
6818
7480
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
7481
+ const concurrency = yield* OxlintConcurrency;
6819
7482
  const collectedFailures = [];
6820
7483
  const diagnostics = yield* Effect.tryPromise({
6821
7484
  try: () => runOxlint({
@@ -6834,7 +7497,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6834
7497
  },
6835
7498
  onFileProgress: input.onFileProgress,
6836
7499
  spawnTimeoutMs,
6837
- outputMaxBytes
7500
+ outputMaxBytes,
7501
+ concurrency
6838
7502
  }),
6839
7503
  catch: ensureReactDoctorError
6840
7504
  });
@@ -6880,7 +7544,8 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
6880
7544
  static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
6881
7545
  update: () => Effect.void,
6882
7546
  succeed: () => Effect.void,
6883
- fail: () => Effect.void
7547
+ fail: () => Effect.void,
7548
+ stop: () => Effect.void
6884
7549
  }) }));
6885
7550
  static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
6886
7551
  yield* Ref.update(events, (existing) => [...existing, {
@@ -6899,6 +7564,10 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
6899
7564
  fail: (displayText) => Ref.update(events, (existing) => [...existing, {
6900
7565
  _tag: "Failed",
6901
7566
  text: displayText
7567
+ }]),
7568
+ stop: () => Ref.update(events, (existing) => [...existing, {
7569
+ _tag: "Stopped",
7570
+ text
6902
7571
  }])
6903
7572
  };
6904
7573
  }) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
@@ -7148,18 +7817,25 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7148
7817
  repo
7149
7818
  }).pipe(Effect.orElseSucceed(() => null)) : Effect.succeed(null));
7150
7819
  const lintIncludePaths = computeJsxIncludePaths([...input.includePaths]) ?? resolveLintIncludePaths(scanDirectory, resolvedConfig.config);
7820
+ const scannedFilePaths = input.suppressScanSummary ? (lintIncludePaths ?? (yield* filesService.listSourceFiles(scanDirectory))).map((relativePath) => path.resolve(scanDirectory, relativePath)) : [];
7151
7821
  const beforeLint = hooks.beforeLint ?? NO_HOOKS.beforeLint;
7152
7822
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
7153
7823
  yield* beforeLint(project, lintIncludePaths ?? void 0);
7154
7824
  const isDiffMode = input.includePaths.length > 0;
7825
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
7155
7826
  const transform = buildDiagnosticPipeline({
7156
7827
  rootDirectory: scanDirectory,
7157
7828
  userConfig: resolvedConfig.config,
7158
7829
  readFileLinesSync: fileReader(filesService, scanDirectory),
7159
- respectInlineDisables: input.respectInlineDisables
7830
+ respectInlineDisables: input.respectInlineDisables,
7831
+ showWarnings
7160
7832
  });
7161
7833
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
7162
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
7834
+ const environmentDiagnostics = isDiffMode ? [] : [
7835
+ ...checkReducedMotion(scanDirectory),
7836
+ ...checkPnpmHardening(scanDirectory),
7837
+ ...checkExpoProject(scanDirectory, project)
7838
+ ];
7163
7839
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7164
7840
  const lintFailure = yield* Ref.make({
7165
7841
  didFail: false,
@@ -7171,6 +7847,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7171
7847
  didFail: false,
7172
7848
  reason: null
7173
7849
  });
7850
+ const scanConcurrency = yield* OxlintConcurrency;
7851
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
7174
7852
  const scanProgress = yield* progressService.start("Scanning...");
7175
7853
  const scanStartTime = Date.now();
7176
7854
  let lastReportedTotalFileCount = 0;
@@ -7187,7 +7865,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7187
7865
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
7188
7866
  onFileProgress: (scannedFileCount, totalFileCount) => {
7189
7867
  lastReportedTotalFileCount = totalFileCount;
7190
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
7868
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
7191
7869
  }
7192
7870
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
7193
7871
  yield* Ref.set(lintFailure, {
@@ -7202,7 +7880,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7202
7880
  const lintFailureState = yield* Ref.get(lintFailure);
7203
7881
  yield* afterLint(lintFailureState.didFail);
7204
7882
  if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
7205
- const shouldRunDeadCode = input.runDeadCode && !isDiffMode;
7883
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
7206
7884
  const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update("Analyzing dead code...").pipe(Effect.andThen(Stream.runCollect(applyPerElementPipeline(deadCodeService.run({
7207
7885
  rootDirectory: scanDirectory,
7208
7886
  userConfig: resolvedConfig.config
@@ -7214,10 +7892,12 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7214
7892
  return Stream.empty;
7215
7893
  }))))))));
7216
7894
  const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
7217
- const scanElapsedSeconds = ((Date.now() - scanStartTime) / 1e3).toFixed(1);
7895
+ const scanElapsedMilliseconds = Date.now() - scanStartTime;
7896
+ const scanElapsedSeconds = (scanElapsedMilliseconds / 1e3).toFixed(1);
7218
7897
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
7219
7898
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
7220
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
7899
+ else if (input.suppressScanSummary) yield* scanProgress.stop();
7900
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
7221
7901
  yield* reporterService.finalize;
7222
7902
  const finalDiagnostics = [
7223
7903
  ...envCollected,
@@ -7257,7 +7937,10 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7257
7937
  lintFailureReasonKind: lintFailureState.reasonKind,
7258
7938
  lintPartialFailures,
7259
7939
  didDeadCodeFail: deadCodeFailureState.didFail,
7260
- deadCodeFailureReason: deadCodeFailureState.reason
7940
+ deadCodeFailureReason: deadCodeFailureState.reason,
7941
+ scannedFileCount: totalFileCount,
7942
+ scannedFilePaths,
7943
+ scanElapsedMilliseconds
7261
7944
  };
7262
7945
  }).pipe(Effect.withSpan("runInspect", { attributes: {
7263
7946
  "inspect.directory": input.directory,
@@ -7383,7 +8066,7 @@ const isPathInsideDirectory = (childAbsolutePath, parentAbsolutePath) => {
7383
8066
  static layerNode = Layer.effect(StagedFiles, Effect.gen(function* () {
7384
8067
  const git = yield* Git;
7385
8068
  return StagedFiles.of({
7386
- discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter((entry) => SOURCE_FILE_PATTERN.test(entry)))),
8069
+ discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter(isLintableSourceFile))),
7387
8070
  materialize: ({ directory, stagedFiles, tempDirectory }) => Effect.gen(function* () {
7388
8071
  const materializedFiles = [];
7389
8072
  const resolvedTempDirectory = path.resolve(tempDirectory);
@@ -7592,7 +8275,7 @@ const getDiffInfo = (directory, explicitBaseBranch) => Effect.runPromise(Effect.
7592
8275
  GitBaseBranchInvalid: (reason) => Effect.die(new Error(reason.detail)),
7593
8276
  GitBaseBranchMissing: (reason) => Effect.die(new Error(reason.message))
7594
8277
  })));
7595
- const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
8278
+ const filterSourceFiles = (filePaths) => filePaths.filter(isLintableSourceFile);
7596
8279
  var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
7597
8280
  let p = process || {}, argv = p.argv || [], env = p.env || {};
7598
8281
  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);
@@ -7679,6 +8362,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7679
8362
  includePaths,
7680
8363
  customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
7681
8364
  respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
8365
+ warnings: options.warnings ?? effectiveConfig?.warnings ?? false,
7682
8366
  adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
7683
8367
  ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
7684
8368
  runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
@@ -7716,6 +8400,7 @@ const clearCaches = () => {
7716
8400
  clearConfigCache();
7717
8401
  clearPackageJsonCache();
7718
8402
  clearIgnorePatternsCache();
8403
+ clearPackageRoleCache();
7719
8404
  clearAutoSuppressionCaches();
7720
8405
  };
7721
8406
  const toJsonReport = (result, options) => buildJsonReport({