react-doctor 0.2.14-dev.6d53182 → 0.2.14-dev.75c1f99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="fce73b02-d297-5132-af08-817f37e1467c")}catch(e){}}();
1
3
  import { createRequire } from "node:module";
2
4
  import * as Schema from "effect/Schema";
3
5
  import * as fs$1 from "node:fs";
@@ -14,7 +16,10 @@ import * as Redacted from "effect/Redacted";
14
16
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
15
17
  import * as Otlp from "effect/unstable/observability/Otlp";
16
18
  import * as Context from "effect/Context";
19
+ import os from "node:os";
17
20
  import * as Console from "effect/Console";
21
+ import { parseJSON5 } from "confbox";
22
+ import { createJiti } from "jiti";
18
23
  import * as Fiber from "effect/Fiber";
19
24
  import * as Filter from "effect/Filter";
20
25
  import * as Option from "effect/Option";
@@ -26,7 +31,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
26
31
  import * as NodePath from "@effect/platform-node-shared/NodePath";
27
32
  import * as ChildProcess from "effect/unstable/process/ChildProcess";
28
33
  import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
29
- import os from "node:os";
30
34
  import * as ts from "typescript";
31
35
  import { gzipSync } from "node:zlib";
32
36
  //#region \0rolldown/runtime.js
@@ -59,6 +63,7 @@ var Diagnostic = class extends Schema.Class("Diagnostic")({
59
63
  plugin: Schema.String,
60
64
  rule: Schema.String,
61
65
  severity: Severity,
66
+ title: Schema.optional(Schema.String),
62
67
  message: Schema.String,
63
68
  help: Schema.String,
64
69
  url: Schema.optional(Schema.String),
@@ -2094,6 +2099,8 @@ const isFile = (filePath) => {
2094
2099
  }
2095
2100
  };
2096
2101
  const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
2102
+ const GENERATED_BUNDLE_FILE_PATTERN = /\.(iife|umd|global|min)\.js$/i;
2103
+ const MINIFIED_SNIFF_BYTES = 65536;
2097
2104
  const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
2098
2105
  const IGNORED_DIRECTORIES = new Set([
2099
2106
  ".git",
@@ -2109,6 +2116,34 @@ const IGNORED_DIRECTORIES = new Set([
2109
2116
  "out",
2110
2117
  "storybook-static"
2111
2118
  ]);
2119
+ const isLintableSourceFile = (filePath) => SOURCE_FILE_PATTERN.test(filePath) && !GENERATED_BUNDLE_FILE_PATTERN.test(filePath);
2120
+ const isMinifiedSource = (absolutePath) => {
2121
+ let fileDescriptor;
2122
+ try {
2123
+ fileDescriptor = fs.openSync(absolutePath, "r");
2124
+ const buffer = Buffer.alloc(MINIFIED_SNIFF_BYTES);
2125
+ const bytesRead = fs.readSync(fileDescriptor, buffer, 0, MINIFIED_SNIFF_BYTES, 0);
2126
+ const prefix = buffer.toString("utf8", 0, bytesRead);
2127
+ const lines = prefix.split("\n");
2128
+ const longestLineLength = lines.reduce((longest, line) => Math.max(longest, line.length), 0);
2129
+ const averageLineLength = prefix.length / lines.length;
2130
+ return longestLineLength > 1e3 && averageLineLength > 500;
2131
+ } catch {
2132
+ return false;
2133
+ } finally {
2134
+ if (fileDescriptor !== void 0) fs.closeSync(fileDescriptor);
2135
+ }
2136
+ };
2137
+ const isLargeMinifiedFile = (absolutePath) => {
2138
+ let sizeBytes;
2139
+ try {
2140
+ sizeBytes = fs.statSync(absolutePath).size;
2141
+ } catch {
2142
+ return false;
2143
+ }
2144
+ if (sizeBytes < 2e4) return false;
2145
+ return isMinifiedSource(absolutePath);
2146
+ };
2112
2147
  const IGNORABLE_READDIR_ERROR_CODES = new Set([
2113
2148
  "EACCES",
2114
2149
  "EPERM",
@@ -2139,7 +2174,7 @@ const countSourceFilesViaFilesystem = (rootDirectory) => {
2139
2174
  if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
2140
2175
  continue;
2141
2176
  }
2142
- if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
2177
+ if (entry.isFile() && isLintableSourceFile(entry.name) && !isLargeMinifiedFile(path.join(currentDirectory, entry.name))) count++;
2143
2178
  }
2144
2179
  }
2145
2180
  return count;
@@ -2157,7 +2192,7 @@ const countSourceFilesViaGit = (rootDirectory) => {
2157
2192
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
2158
2193
  });
2159
2194
  if (result.error || result.status !== 0) return null;
2160
- return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
2195
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath) && !isLargeMinifiedFile(path.resolve(rootDirectory, filePath))).length;
2161
2196
  };
2162
2197
  const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
2163
2198
  const cachedPackageJsons = /* @__PURE__ */ new Map();
@@ -2843,29 +2878,34 @@ const findDependencyInfoFromMonorepoRoot = (directory) => {
2843
2878
  framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
2844
2879
  };
2845
2880
  };
2846
- const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => {
2847
- if (predicate(rootPackageJson)) return true;
2881
+ const findInWorkspacePackageJsons = (rootDirectory, rootPackageJson, select) => {
2882
+ const rootValue = select(rootPackageJson);
2883
+ if (rootValue !== null) return rootValue;
2848
2884
  const patterns = getWorkspacePatterns(rootDirectory, rootPackageJson);
2849
- if (patterns.length === 0) return false;
2885
+ if (patterns.length === 0) return null;
2850
2886
  const visitedDirectories = /* @__PURE__ */ new Set();
2851
2887
  for (const pattern of patterns) {
2852
- const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
2888
+ const directories = [...resolveWorkspaceDirectories(rootDirectory, pattern)].sort();
2853
2889
  for (const workspaceDirectory of directories) {
2854
2890
  if (visitedDirectories.has(workspaceDirectory)) continue;
2855
2891
  visitedDirectories.add(workspaceDirectory);
2856
- if (predicate(readPackageJson(path.join(workspaceDirectory, "package.json")))) return true;
2892
+ const value = select(readPackageJson(path.join(workspaceDirectory, "package.json")));
2893
+ if (value !== null) return value;
2857
2894
  }
2858
2895
  }
2859
- return false;
2896
+ return null;
2860
2897
  };
2898
+ const someWorkspacePackageJson = (rootDirectory, rootPackageJson, predicate) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => predicate(packageJson) ? true : null) !== null;
2861
2899
  const NAMES = new Set([
2862
2900
  "react-native",
2863
2901
  "react-native-tvos",
2864
- "expo",
2865
- "expo-router",
2866
- "@expo/cli",
2867
- "@expo/metro-config",
2868
- "@expo/metro-runtime",
2902
+ ...new Set([
2903
+ "expo",
2904
+ "expo-router",
2905
+ "@expo/cli",
2906
+ "@expo/metro-config",
2907
+ "@expo/metro-runtime"
2908
+ ]),
2869
2909
  "react-native-windows",
2870
2910
  "react-native-macos"
2871
2911
  ]);
@@ -2889,6 +2929,11 @@ const isPackageJsonReactNativeAware = (packageJson) => {
2889
2929
  return false;
2890
2930
  };
2891
2931
  const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
2932
+ const getExpoDependencySpec = (packageJson) => {
2933
+ const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
2934
+ return typeof spec === "string" ? spec : null;
2935
+ };
2936
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
2892
2937
  const getPreactVersion = (packageJson) => {
2893
2938
  return {
2894
2939
  ...packageJson.peerDependencies,
@@ -3128,6 +3173,19 @@ const discoverProject = (directory) => {
3128
3173
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
3129
3174
  const sourceFileCount = countSourceFiles(directory);
3130
3175
  const hasReactNativeWorkspace = framework === "expo" || framework === "react-native" || hasReactNativeWorkspaceAnywhere(directory, packageJson);
3176
+ let expoVersion = hasReactNativeWorkspace ? findExpoVersion(directory, packageJson) : null;
3177
+ if (expoVersion !== null && isCatalogReference(expoVersion)) {
3178
+ const catalogName = extractCatalogName(expoVersion);
3179
+ let resolvedExpoVersion = resolveCatalogVersion(packageJson, "expo", directory, catalogName);
3180
+ if (!resolvedExpoVersion) {
3181
+ const monorepoRoot = findMonorepoRoot(directory);
3182
+ if (monorepoRoot) {
3183
+ const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
3184
+ if (isFile(monorepoPackageJsonPath)) resolvedExpoVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "expo", monorepoRoot, catalogName);
3185
+ }
3186
+ }
3187
+ expoVersion = resolvedExpoVersion ?? expoVersion;
3188
+ }
3131
3189
  const hasReanimated = hasReactNativeWorkspace && someWorkspacePackageJson(directory, packageJson, isPackageJsonReanimatedAware);
3132
3190
  const preactVersion = getPreactVersion(packageJson);
3133
3191
  const projectInfo = {
@@ -3145,6 +3203,7 @@ const discoverProject = (directory) => {
3145
3203
  preactVersion,
3146
3204
  preactMajorVersion: parseReactMajor(preactVersion),
3147
3205
  hasReactNativeWorkspace,
3206
+ expoVersion,
3148
3207
  hasReanimated,
3149
3208
  sourceFileCount
3150
3209
  };
@@ -3234,13 +3293,31 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
3234
3293
  "tsconfig.json",
3235
3294
  "tsconfig.base.json",
3236
3295
  "package.json",
3237
- "react-doctor.config.json",
3296
+ "doctor.config.ts",
3297
+ "doctor.config.mts",
3298
+ "doctor.config.cts",
3299
+ "doctor.config.js",
3300
+ "doctor.config.mjs",
3301
+ "doctor.config.cjs",
3302
+ "doctor.config.json",
3303
+ "doctor.config.jsonc",
3238
3304
  "oxlint.json",
3239
3305
  ".oxlintrc.json"
3240
3306
  ];
3241
3307
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
3242
3308
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
3309
+ const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192;
3243
3310
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
3311
+ const DIAGNOSTIC_CATEGORY_BUCKETS = [
3312
+ "Security",
3313
+ "Bugs",
3314
+ "Performance",
3315
+ "Accessibility",
3316
+ "Maintainability"
3317
+ ];
3318
+ const APP_ONLY_RULE_KEYS = new Set(["react-hooks-js/static-components", "react-doctor/no-render-prop-children"]);
3319
+ const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
3320
+ const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
3244
3321
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
3245
3322
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
3246
3323
  var InvalidGlobPatternError = class extends Error {
@@ -3360,10 +3437,11 @@ const restampSeverity = (diagnostic, override) => {
3360
3437
  */
3361
3438
  const buildRuleSeverityControls = (config) => {
3362
3439
  if (!config) return void 0;
3363
- if (config.rules === void 0 && config.categories === void 0) return void 0;
3440
+ if (config.rules === void 0 && config.categories === void 0 && config.buckets === void 0) return;
3364
3441
  return {
3365
3442
  ...config.rules !== void 0 ? { rules: config.rules } : {},
3366
- ...config.categories !== void 0 ? { categories: config.categories } : {}
3443
+ ...config.categories !== void 0 ? { categories: config.categories } : {},
3444
+ ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
3367
3445
  };
3368
3446
  };
3369
3447
  const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
@@ -3727,6 +3805,69 @@ const resolveRuleSeverityOverride = (input, controls) => {
3727
3805
  }
3728
3806
  return input.category !== void 0 ? controls.categories?.[input.category] : void 0;
3729
3807
  };
3808
+ const cachedRoleByPackageDirectory = /* @__PURE__ */ new Map();
3809
+ const cachedPackageDirectoryByFilename = /* @__PURE__ */ new Map();
3810
+ const findNearestPackageDirectory = (filename) => {
3811
+ if (!filename) return null;
3812
+ const fromCache = cachedPackageDirectoryByFilename.get(filename);
3813
+ if (fromCache !== void 0) return fromCache;
3814
+ let currentDirectory = path.dirname(filename);
3815
+ while (true) {
3816
+ const candidatePackageJsonPath = path.join(currentDirectory, "package.json");
3817
+ let hasPackageJson = false;
3818
+ try {
3819
+ hasPackageJson = fs.statSync(candidatePackageJsonPath).isFile();
3820
+ } catch {
3821
+ hasPackageJson = false;
3822
+ }
3823
+ if (hasPackageJson) {
3824
+ cachedPackageDirectoryByFilename.set(filename, currentDirectory);
3825
+ return currentDirectory;
3826
+ }
3827
+ const parentDirectory = path.dirname(currentDirectory);
3828
+ if (parentDirectory === currentDirectory) {
3829
+ cachedPackageDirectoryByFilename.set(filename, null);
3830
+ return null;
3831
+ }
3832
+ currentDirectory = parentDirectory;
3833
+ }
3834
+ };
3835
+ const readManifest = (packageJsonPath) => {
3836
+ try {
3837
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
3838
+ if (typeof parsed === "object" && parsed !== null) return parsed;
3839
+ return null;
3840
+ } catch {
3841
+ return null;
3842
+ }
3843
+ };
3844
+ const hasPublishContract = (manifest) => typeof manifest.name === "string" && manifest.name.length > 0 && manifest.exports !== void 0 && manifest.exports !== null && manifest.private !== true;
3845
+ const classifyByDirectoryCohort = (packageDirectory) => {
3846
+ let current = packageDirectory;
3847
+ while (true) {
3848
+ if (path.basename(current) === "apps") return "app";
3849
+ const parent = path.dirname(current);
3850
+ if (parent === current) return null;
3851
+ current = parent;
3852
+ }
3853
+ };
3854
+ const clearPackageRoleCache = () => {
3855
+ cachedRoleByPackageDirectory.clear();
3856
+ cachedPackageDirectoryByFilename.clear();
3857
+ };
3858
+ const classifyPackageRole = (filename) => {
3859
+ if (!filename) return "unknown";
3860
+ const packageDirectory = findNearestPackageDirectory(filename);
3861
+ if (!packageDirectory) return "unknown";
3862
+ const cached = cachedRoleByPackageDirectory.get(packageDirectory);
3863
+ if (cached !== void 0) return cached;
3864
+ const manifest = readManifest(path.join(packageDirectory, "package.json"));
3865
+ let result;
3866
+ if (manifest && hasPublishContract(manifest)) result = "library";
3867
+ else result = classifyByDirectoryCohort(path.dirname(packageDirectory)) ?? "unknown";
3868
+ cachedRoleByPackageDirectory.set(packageDirectory, result);
3869
+ return result;
3870
+ };
3730
3871
  /**
3731
3872
  * Resolves the absolute path to read for a diagnostic's `filePath`,
3732
3873
  * accounting for the various shapes oxlint emits:
@@ -3862,10 +4003,13 @@ const collectStringSet = (values) => {
3862
4003
  * wins over `test-noise`)
3863
4004
  * 2. severity overrides (top-level `rules` / `categories`, with
3864
4005
  * `"off"` dropping)
3865
- * 3. ignore filters (rules / file patterns / per-file overrides)
3866
- * 4. `rn-no-raw-text` suppression via configured `textComponents` and
4006
+ * 3. warning suppression (only when `showWarnings` is false: drops every
4007
+ * `"warning"`-severity diagnostic unless a severity override opts a
4008
+ * specific rule / category back in)
4009
+ * 4. ignore filters (rules / file patterns / per-file overrides)
4010
+ * 5. `rn-no-raw-text` suppression via configured `textComponents` and
3867
4011
  * `rawTextWrapperComponents` (config-driven JSX enclosure checks)
3868
- * 5. inline suppressions (`// react-doctor-disable-next-line ...`)
4012
+ * 6. inline suppressions (`// react-doctor-disable-next-line ...`)
3869
4013
  *
3870
4014
  * Returns `null` when the diagnostic is dropped, the (possibly
3871
4015
  * severity-restamped) diagnostic otherwise.
@@ -3875,7 +4019,7 @@ const collectStringSet = (values) => {
3875
4019
  * `mergeAndFilterDiagnostics` wrapper apply this closure per element.
3876
4020
  */
3877
4021
  const buildDiagnosticPipeline = (input) => {
3878
- const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables } = input;
4022
+ const { rootDirectory, userConfig, readFileLinesSync, respectInlineDisables, showWarnings } = input;
3879
4023
  const severityControls = buildRuleSeverityControls(userConfig);
3880
4024
  const ignoredRules = new Set(Array.isArray(userConfig?.ignore?.rules) ? userConfig.ignore.rules.filter((rule) => typeof rule === "string") : []);
3881
4025
  const ignoredFilePatterns = compileIgnoredFilePatterns(userConfig);
@@ -3886,6 +4030,15 @@ const buildDiagnosticPipeline = (input) => {
3886
4030
  const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
3887
4031
  const fileLinesCache = /* @__PURE__ */ new Map();
3888
4032
  const testFileCache = /* @__PURE__ */ new Map();
4033
+ const libraryFileCache = /* @__PURE__ */ new Map();
4034
+ const isLibraryFile = (filePath) => {
4035
+ let cached = libraryFileCache.get(filePath);
4036
+ if (cached === void 0) {
4037
+ cached = classifyPackageRole(resolveCandidateReadPath(rootDirectory, filePath)) === "library";
4038
+ libraryFileCache.set(filePath, cached);
4039
+ }
4040
+ return cached;
4041
+ };
3889
4042
  const getFileLines = (filePath) => {
3890
4043
  const cached = fileLinesCache.get(filePath);
3891
4044
  if (cached !== void 0) return cached;
@@ -3912,6 +4065,10 @@ const buildDiagnosticPipeline = (input) => {
3912
4065
  for (const ignored of ignoredRules) if (isSameRuleKey(ignored, ruleIdentifier)) return true;
3913
4066
  return false;
3914
4067
  };
4068
+ const isAppOnlyRule = (ruleIdentifier) => {
4069
+ for (const appOnlyRuleKey of APP_ONLY_RULE_KEYS) if (isSameRuleKey(appOnlyRuleKey, ruleIdentifier)) return true;
4070
+ return false;
4071
+ };
3915
4072
  const isRnRawTextSuppressedByConfig = (diagnostic) => {
3916
4073
  if (diagnostic.rule !== "rn-no-raw-text") return false;
3917
4074
  if (diagnostic.line <= 0) return false;
@@ -3925,15 +4082,22 @@ const buildDiagnosticPipeline = (input) => {
3925
4082
  return { apply: (diagnostic) => {
3926
4083
  if (shouldAutoSuppress(diagnostic)) return null;
3927
4084
  let current = diagnostic;
4085
+ let explicitSeverityOverride;
4086
+ let explicitRuleOverride;
3928
4087
  if (severityControls) {
3929
4088
  const { ruleKey, category } = getDiagnosticRuleIdentity(current);
3930
- const override = resolveRuleSeverityOverride({
4089
+ explicitRuleOverride = resolveRuleSeverityOverride({ ruleKey }, severityControls);
4090
+ explicitSeverityOverride = resolveRuleSeverityOverride({
3931
4091
  ruleKey,
3932
4092
  category
3933
4093
  }, severityControls);
3934
- if (override === "off") return null;
3935
- if (override !== void 0) current = restampSeverity(current, override);
4094
+ if (explicitSeverityOverride === "off") return null;
4095
+ if (explicitSeverityOverride !== void 0) current = restampSeverity(current, explicitSeverityOverride);
4096
+ }
4097
+ if (explicitRuleOverride === void 0) {
4098
+ if (isAppOnlyRule(`${current.plugin}/${current.rule}`) && isLibraryFile(current.filePath)) return null;
3936
4099
  }
4100
+ if (!showWarnings && current.severity === "warning" && explicitSeverityOverride !== "warn") return null;
3937
4101
  if (userConfig) {
3938
4102
  if (isRuleIgnored(`${current.plugin}/${current.rule}`)) return null;
3939
4103
  if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) return null;
@@ -4118,6 +4282,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
4118
4282
  }).pipe(Layer.provide(FetchHttpClient.layer));
4119
4283
  }).pipe(Effect.orDie));
4120
4284
  /**
4285
+ * Resolves a requested lint worker count to a clamped integer within
4286
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
4287
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
4288
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
4289
+ */
4290
+ const resolveScanConcurrency = (requested) => {
4291
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
4292
+ if (!Number.isFinite(desired) || desired < 1) return 1;
4293
+ return Math.max(1, Math.min(Math.floor(desired), 16));
4294
+ };
4295
+ /**
4121
4296
  * Per-batch oxlint wall-clock budget. Reads from the env var on
4122
4297
  * startup so the eval harness can raise the budget under sandbox
4123
4298
  * microVMs without recompiling react-doctor. Tests override via
@@ -4137,6 +4312,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
4137
4312
  * tests that exercise the cap behavior.
4138
4313
  */
4139
4314
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
4315
+ /**
4316
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
4317
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
4318
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
4319
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
4320
+ * CI callers that never touch the flag:
4321
+ *
4322
+ * - unset / `0` / `false` / `off` → `1` (serial)
4323
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
4324
+ * - a positive integer → that many workers (clamped)
4325
+ *
4326
+ * The resolved value is always within
4327
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
4328
+ */
4329
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
4330
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
4331
+ if (raw === void 0) return 1;
4332
+ const normalized = raw.trim().toLowerCase();
4333
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
4334
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
4335
+ const parsed = Number.parseInt(normalized, 10);
4336
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
4337
+ return resolveScanConcurrency(parsed);
4338
+ } }) {};
4140
4339
  const DIAGNOSTIC_SURFACES = [
4141
4340
  "cli",
4142
4341
  "prComment",
@@ -4165,10 +4364,18 @@ const VALID_RULE_SEVERITIES = [
4165
4364
  "warn",
4166
4365
  "off"
4167
4366
  ];
4367
+ const KNOWN_CATEGORY_LABEL = DIAGNOSTIC_CATEGORY_BUCKETS.join(", ");
4368
+ const isDiagnosticCategoryBucket = (value) => DIAGNOSTIC_CATEGORY_BUCKETS.includes(value);
4369
+ const filterKnownCategories = (fieldName, categories) => categories.filter((category) => {
4370
+ if (isDiagnosticCategoryBucket(category)) return true;
4371
+ warnConfigIssue(`config field "${fieldName}" lists "${category}", which is not a known category (expected one of: ${KNOWN_CATEGORY_LABEL}); ignoring the entry.`);
4372
+ return false;
4373
+ });
4168
4374
  const BOOLEAN_FIELD_NAMES = [
4169
4375
  "lint",
4170
4376
  "deadCode",
4171
4377
  "verbose",
4378
+ "warnings",
4172
4379
  "customRulesOnly",
4173
4380
  "share",
4174
4381
  "noScore",
@@ -4217,13 +4424,15 @@ const validateSurfaceControls = (surface, rawControls) => {
4217
4424
  warnConfigIssue(`config field "surfaces.${surface}" must be an object (got ${typeof rawControls}); ignoring this surface.`);
4218
4425
  return;
4219
4426
  }
4220
- const validated = {};
4427
+ const validatedSurfaceControls = {};
4221
4428
  for (const fieldName of SURFACE_CONTROL_FIELD_NAMES) {
4222
4429
  if (rawControls[fieldName] === void 0) continue;
4223
- const result = validateStringArrayField(`surfaces.${surface}.${fieldName}`, rawControls[fieldName]);
4224
- if (result !== void 0) validated[fieldName] = result;
4430
+ const qualifiedName = `surfaces.${surface}.${fieldName}`;
4431
+ const result = validateStringArrayField(qualifiedName, rawControls[fieldName]);
4432
+ if (result === void 0) continue;
4433
+ validatedSurfaceControls[fieldName] = fieldName === "includeCategories" || fieldName === "excludeCategories" ? filterKnownCategories(qualifiedName, result) : result;
4225
4434
  }
4226
- return validated;
4435
+ return validatedSurfaceControls;
4227
4436
  };
4228
4437
  const validateSurfacesField = (rawSurfaces) => {
4229
4438
  if (!isPlainObject$1(rawSurfaces)) {
@@ -4241,7 +4450,7 @@ const validateSurfacesField = (rawSurfaces) => {
4241
4450
  }
4242
4451
  return validated;
4243
4452
  };
4244
- const validateSeverityMap = (fieldName, rawMap) => {
4453
+ const validateSeverityMap = (fieldName, rawMap, keysAreCategories = false) => {
4245
4454
  if (!isPlainObject$1(rawMap)) {
4246
4455
  warnConfigIssue(`config field "${fieldName}" must be an object (got ${typeof rawMap}); ignoring this field.`);
4247
4456
  return;
@@ -4252,6 +4461,10 @@ const validateSeverityMap = (fieldName, rawMap) => {
4252
4461
  warnConfigIssue(`config field "${fieldName}" has an empty key; ignoring the entry.`);
4253
4462
  continue;
4254
4463
  }
4464
+ if (keysAreCategories && !isDiagnosticCategoryBucket(key)) {
4465
+ warnConfigIssue(`config field "${fieldName}.${key}" is not a known category (expected one of: ${KNOWN_CATEGORY_LABEL}); ignoring the entry.`);
4466
+ continue;
4467
+ }
4255
4468
  if (!isRuleSeverity(value)) {
4256
4469
  warnConfigIssue(`config field "${fieldName}.${key}" must be one of: ${VALID_RULE_SEVERITIES.join(", ")} (got ${formatType(value)}); ignoring the entry.`);
4257
4470
  continue;
@@ -4272,76 +4485,116 @@ const validateConfigTypes = (config) => {
4272
4485
  for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
4273
4486
  for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
4274
4487
  applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
4275
- for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value));
4488
+ for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
4276
4489
  applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
4277
4490
  return validated;
4278
4491
  };
4279
4492
  const warn = (message) => {
4280
4493
  Effect.runSync(Console.warn(message));
4281
4494
  };
4282
- const CONFIG_FILENAME = "react-doctor.config.json";
4495
+ const CONFIG_BASENAME = "doctor.config";
4496
+ const CONFIG_EXTENSIONS = [
4497
+ "ts",
4498
+ "mts",
4499
+ "cts",
4500
+ "js",
4501
+ "mjs",
4502
+ "cjs",
4503
+ "json",
4504
+ "jsonc"
4505
+ ];
4506
+ const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
4507
+ const PACKAGE_JSON_FILENAME = "package.json";
4283
4508
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
4284
- const loadConfigFromDirectory = (directory) => {
4285
- const configFilePath = path.join(directory, CONFIG_FILENAME);
4286
- if (isFile(configFilePath)) try {
4287
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
4288
- const parsed = JSON.parse(fileContent);
4289
- if (isPlainObject(parsed)) return {
4290
- config: validateConfigTypes(parsed),
4291
- sourceDirectory: directory
4292
- };
4293
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4294
- } catch (error) {
4295
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
4296
- }
4297
- const packageJsonPath = path.join(directory, "package.json");
4298
- if (isFile(packageJsonPath)) try {
4299
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
4300
- const packageJson = JSON.parse(fileContent);
4509
+ const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
4510
+ const jiti = createJiti(import.meta.url);
4511
+ const formatError = (error) => error instanceof Error ? error.message : String(error);
4512
+ const loadModuleConfig = async (filePath) => {
4513
+ const imported = await jiti.import(filePath);
4514
+ return imported?.default ?? imported;
4515
+ };
4516
+ const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
4517
+ const readEmbeddedPackageJsonConfig = (directory) => {
4518
+ const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
4519
+ if (!isFile(packageJsonPath)) return null;
4520
+ try {
4521
+ const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
4301
4522
  if (isPlainObject(packageJson)) {
4302
4523
  const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
4303
- if (isPlainObject(embeddedConfig)) return {
4304
- config: validateConfigTypes(embeddedConfig),
4305
- sourceDirectory: directory
4524
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
4525
+ }
4526
+ } catch {}
4527
+ return null;
4528
+ };
4529
+ const loadPackageJsonConfig = (directory) => {
4530
+ const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
4531
+ if (!embeddedConfig) return null;
4532
+ return {
4533
+ config: validateConfigTypes(embeddedConfig),
4534
+ sourceDirectory: directory,
4535
+ configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
4536
+ format: "package-json"
4537
+ };
4538
+ };
4539
+ const loadConfigFromDirectory = async (directory) => {
4540
+ let sawBrokenConfigFile = false;
4541
+ for (const extension of CONFIG_EXTENSIONS) {
4542
+ const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
4543
+ if (!isFile(filePath)) continue;
4544
+ const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
4545
+ try {
4546
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
4547
+ if (isPlainObject(parsed)) return {
4548
+ status: "found",
4549
+ loaded: {
4550
+ config: validateConfigTypes(parsed),
4551
+ sourceDirectory: directory,
4552
+ configFilePath: filePath,
4553
+ format: isDataFile ? "json" : "module"
4554
+ }
4306
4555
  };
4556
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
4557
+ sawBrokenConfigFile = true;
4558
+ } catch (error) {
4559
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
4560
+ sawBrokenConfigFile = true;
4307
4561
  }
4308
- } catch {
4309
- return null;
4310
4562
  }
4311
- return null;
4563
+ const packageJsonConfig = loadPackageJsonConfig(directory);
4564
+ if (packageJsonConfig) return {
4565
+ status: "found",
4566
+ loaded: packageJsonConfig
4567
+ };
4568
+ if (isFile(path.join(directory, LEGACY_CONFIG_FILENAME))) warn(`${LEGACY_CONFIG_FILENAME} is no longer read — rename it to ${CONFIG_BASENAME}.json (or author a ${CONFIG_BASENAME}.ts).`);
4569
+ return {
4570
+ status: sawBrokenConfigFile ? "invalid" : "absent",
4571
+ loaded: null
4572
+ };
4312
4573
  };
4313
4574
  const cachedConfigs = /* @__PURE__ */ new Map();
4314
4575
  const clearConfigCache = () => {
4315
4576
  cachedConfigs.clear();
4316
4577
  };
4317
- const loadConfigWithSource = (rootDirectory) => {
4318
- const cached = cachedConfigs.get(rootDirectory);
4319
- if (cached !== void 0) return cached;
4320
- const localConfig = loadConfigFromDirectory(rootDirectory);
4321
- if (localConfig) {
4322
- cachedConfigs.set(rootDirectory, localConfig);
4323
- return localConfig;
4324
- }
4325
- if (isProjectBoundary(rootDirectory)) {
4326
- cachedConfigs.set(rootDirectory, null);
4327
- return null;
4328
- }
4578
+ const loadConfigWalkingUp = async (rootDirectory) => {
4579
+ const localResult = await loadConfigFromDirectory(rootDirectory);
4580
+ if (localResult.status === "found") return localResult.loaded;
4581
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
4329
4582
  let ancestorDirectory = path.dirname(rootDirectory);
4330
4583
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
4331
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4332
- if (ancestorConfig) {
4333
- cachedConfigs.set(rootDirectory, ancestorConfig);
4334
- return ancestorConfig;
4335
- }
4336
- if (isProjectBoundary(ancestorDirectory)) {
4337
- cachedConfigs.set(rootDirectory, null);
4338
- return null;
4339
- }
4584
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
4585
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
4586
+ if (isProjectBoundary(ancestorDirectory)) return null;
4340
4587
  ancestorDirectory = path.dirname(ancestorDirectory);
4341
4588
  }
4342
- cachedConfigs.set(rootDirectory, null);
4343
4589
  return null;
4344
4590
  };
4591
+ const loadConfigWithSource = (rootDirectory) => {
4592
+ const cached = cachedConfigs.get(rootDirectory);
4593
+ if (cached !== void 0) return cached;
4594
+ const loadPromise = loadConfigWalkingUp(rootDirectory);
4595
+ cachedConfigs.set(rootDirectory, loadPromise);
4596
+ return loadPromise;
4597
+ };
4345
4598
  const resolveConfigRootDir = (config, configSourceDirectory) => {
4346
4599
  if (!config || !configSourceDirectory) return null;
4347
4600
  const rawRootDir = config.rootDir;
@@ -4356,11 +4609,12 @@ const resolveConfigRootDir = (config, configSourceDirectory) => {
4356
4609
  }
4357
4610
  return resolvedRootDir;
4358
4611
  };
4359
- const resolveDiagnoseTarget = (directory) => {
4612
+ const resolveDiagnoseTarget = (directory, options = {}) => {
4360
4613
  if (isFile(path.join(directory, "package.json"))) return directory;
4361
4614
  const reactSubprojects = discoverReactSubprojects(directory);
4362
4615
  if (reactSubprojects.length === 0) return null;
4363
4616
  if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
4617
+ if (options.allowAmbiguous === true) return null;
4364
4618
  throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
4365
4619
  };
4366
4620
  /**
@@ -4368,13 +4622,13 @@ const resolveDiagnoseTarget = (directory) => {
4368
4622
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
4369
4623
  *
4370
4624
  * 1. Resolve the requested directory to absolute.
4371
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
4372
- * if present.
4625
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
4373
4626
  * 3. Honor `config.rootDir` to redirect the scan to a nested
4374
4627
  * project root, if configured.
4375
4628
  * 4. Walk into a nested React subproject when the requested
4376
4629
  * directory has no `package.json` of its own (raises
4377
- * `AmbiguousProjectError` when multiple candidates exist).
4630
+ * `AmbiguousProjectError` when multiple candidates exist unless
4631
+ * the caller opts into keeping the wrapper directory).
4378
4632
  *
4379
4633
  * Throws `ProjectNotFoundError` when neither the requested directory
4380
4634
  * nor any discoverable nested project has a `package.json`.
@@ -4386,14 +4640,14 @@ const resolveDiagnoseTarget = (directory) => {
4386
4640
  * via its own cache). Routing through `resolveScanTarget` keeps every
4387
4641
  * shell in agreement on what "the scan directory" means.
4388
4642
  */
4389
- const resolveScanTarget = (requestedDirectory) => {
4643
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
4390
4644
  const absoluteRequested = path.resolve(requestedDirectory);
4391
- const loadedConfig = loadConfigWithSource(absoluteRequested);
4645
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
4392
4646
  const userConfig = loadedConfig?.config ?? null;
4393
4647
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
4394
4648
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
4395
4649
  const directoryAfterRedirect = redirectedDirectory ?? absoluteRequested;
4396
- const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect) ?? directoryAfterRedirect;
4650
+ const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect, options) ?? directoryAfterRedirect;
4397
4651
  if (!isDirectory(resolvedDirectory)) throw existsSync(resolvedDirectory) ? new NotADirectoryError(resolvedDirectory) : new ProjectNotFoundError(resolvedDirectory);
4398
4652
  return {
4399
4653
  resolvedDirectory,
@@ -4403,6 +4657,359 @@ const resolveScanTarget = (requestedDirectory) => {
4403
4657
  didRedirectViaRootDir: redirectedDirectory !== null
4404
4658
  };
4405
4659
  };
4660
+ const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
4661
+ const buildExpoCheckContext = (rootDirectory, expoVersion) => {
4662
+ const packageJson = readPackageJson(path.join(rootDirectory, "package.json"));
4663
+ return {
4664
+ rootDirectory,
4665
+ packageJson,
4666
+ directDependencyNames: getDirectDependencyNames(packageJson),
4667
+ expoSdkMajor: getLowestDependencyMajor(expoVersion)
4668
+ };
4669
+ };
4670
+ const buildExpoDiagnostic = (input) => ({
4671
+ filePath: input.filePath ?? "package.json",
4672
+ plugin: "react-doctor",
4673
+ rule: input.rule,
4674
+ severity: input.severity ?? "warning",
4675
+ message: input.message,
4676
+ help: input.help,
4677
+ line: input.line ?? 0,
4678
+ column: input.column ?? 0,
4679
+ category: input.category ?? "Correctness"
4680
+ });
4681
+ const CRITICAL_OVERRIDE_NAMES = new Set([
4682
+ "@expo/cli",
4683
+ "@expo/config",
4684
+ "@expo/metro-config",
4685
+ "@expo/metro-runtime",
4686
+ "@expo/metro",
4687
+ "metro"
4688
+ ]);
4689
+ const isCriticalOverrideName = (packageName) => CRITICAL_OVERRIDE_NAMES.has(packageName) || packageName.startsWith("metro-");
4690
+ const collectOverrideNames = (packageJson) => new Set([
4691
+ ...Object.keys(packageJson.overrides ?? {}),
4692
+ ...Object.keys(packageJson.resolutions ?? {}),
4693
+ ...Object.keys(packageJson.pnpm?.overrides ?? {})
4694
+ ]);
4695
+ const checkExpoDependencyOverrides = (context) => {
4696
+ const overriddenCriticalNames = [...collectOverrideNames(context.packageJson)].filter(isCriticalOverrideName).sort();
4697
+ if (overriddenCriticalNames.length === 0) return [];
4698
+ const quotedNames = overriddenCriticalNames.map((name) => `"${name}"`).join(", ");
4699
+ return [buildExpoDiagnostic({
4700
+ rule: "expo-no-conflicting-dependency-override",
4701
+ message: `package.json pins SDK-critical ${overriddenCriticalNames.length === 1 ? "package" : "packages"} via overrides/resolutions (${quotedNames}) — these versions are tied to the Expo SDK release and overriding them is unsupported and may break Metro or native builds`,
4702
+ help: `Remove the override/resolution for ${quotedNames} and reinstall so the Expo-pinned versions are used`
4703
+ })];
4704
+ };
4705
+ const isPathGitIgnored = (rootDirectory, absolutePath) => {
4706
+ const result = spawnSync("git", [
4707
+ "check-ignore",
4708
+ "-q",
4709
+ absolutePath
4710
+ ], {
4711
+ cwd: rootDirectory,
4712
+ stdio: [
4713
+ "ignore",
4714
+ "ignore",
4715
+ "ignore"
4716
+ ]
4717
+ });
4718
+ if (result.error) return null;
4719
+ if (result.status === 0) return true;
4720
+ if (result.status === 1) return false;
4721
+ return null;
4722
+ };
4723
+ const LOCAL_ENV_FILE_NAMES = [
4724
+ ".env.local",
4725
+ ".env.development.local",
4726
+ ".env.production.local",
4727
+ ".env.test.local"
4728
+ ];
4729
+ const checkExpoEnvLocalFiles = (context) => {
4730
+ const { rootDirectory } = context;
4731
+ const committedEnvFiles = LOCAL_ENV_FILE_NAMES.filter((fileName) => {
4732
+ const filePath = path.join(rootDirectory, fileName);
4733
+ if (!isFile(filePath)) return false;
4734
+ return isPathGitIgnored(rootDirectory, filePath) === false;
4735
+ });
4736
+ if (committedEnvFiles.length === 0) return [];
4737
+ return [buildExpoDiagnostic({
4738
+ rule: "expo-env-local-not-gitignored",
4739
+ category: "Security",
4740
+ message: `Local environment ${committedEnvFiles.length === 1 ? "file" : "files"} (${committedEnvFiles.join(", ")}) ${committedEnvFiles.length === 1 ? "is" : "are"} not ignored by Git — committing \`.env*.local\` risks leaking secrets and overriding committed defaults for everyone who clones the project`,
4741
+ help: `Add \`.env*.local\` to your .gitignore. If already committed, untrack with \`git rm --cached ${committedEnvFiles.join(" ")}\``
4742
+ })];
4743
+ };
4744
+ const isExpoSdkAtLeast = (expoSdkMajor, minMajor) => expoSdkMajor !== null && expoSdkMajor >= minMajor;
4745
+ const UNIMODULES_HELP = "Remove every `@unimodules/*` and `react-native-unimodules` package — their functionality now lives in `expo-modules-core`. See https://expo.fyi/r/sdk-44-remove-unimodules";
4746
+ const FIREBASE_HELP = "Use the Firebase JS SDK or React Native Firebase directly. See https://expo.fyi/firebase-migration-guide";
4747
+ const unimodulesEntry = (packageName) => ({
4748
+ packageName,
4749
+ rule: "expo-no-unimodules-packages",
4750
+ message: `"${packageName}" is a legacy unimodules package that is incompatible with Expo SDK 44+ and will break native builds`,
4751
+ help: UNIMODULES_HELP
4752
+ });
4753
+ const FLAGGED_DEPENDENCIES = [
4754
+ unimodulesEntry("@unimodules/core"),
4755
+ unimodulesEntry("@unimodules/react-native-adapter"),
4756
+ unimodulesEntry("react-native-unimodules"),
4757
+ {
4758
+ packageName: "expo-cli",
4759
+ rule: "expo-no-cli-dependencies",
4760
+ message: "`expo-cli` (the legacy global CLI) is a project dependency — the CLI now ships inside the `expo` package, and keeping `expo-cli` causes failures such as `unknown option --fix` when running `npx expo install --fix`",
4761
+ help: "Remove `expo-cli` from your dependencies and use the bundled CLI via `npx expo`"
4762
+ },
4763
+ {
4764
+ packageName: "eas-cli",
4765
+ rule: "expo-no-cli-dependencies",
4766
+ message: "`eas-cli` is a project dependency — pinning it in package.json drifts from the latest EAS CLI and bloats installs",
4767
+ help: "Remove `eas-cli` from your dependencies and run it on demand with `npx eas-cli` (or install it globally)"
4768
+ },
4769
+ {
4770
+ packageName: "expo-modules-autolinking",
4771
+ rule: "expo-no-redundant-dependency",
4772
+ message: "\"expo-modules-autolinking\" should not be a direct dependency — Expo installs it transitively as needed",
4773
+ help: "Remove `expo-modules-autolinking` from your package.json"
4774
+ },
4775
+ {
4776
+ packageName: "expo-dev-launcher",
4777
+ rule: "expo-no-redundant-dependency",
4778
+ message: "\"expo-dev-launcher\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4779
+ help: "Remove `expo-dev-launcher` and depend on `expo-dev-client` instead"
4780
+ },
4781
+ {
4782
+ packageName: "expo-dev-menu",
4783
+ rule: "expo-no-redundant-dependency",
4784
+ message: "\"expo-dev-menu\" should not be a direct dependency — it is pulled in by `expo-dev-client`",
4785
+ help: "Remove `expo-dev-menu` and depend on `expo-dev-client` instead"
4786
+ },
4787
+ {
4788
+ packageName: "expo-modules-core",
4789
+ rule: "expo-no-redundant-dependency",
4790
+ message: "\"expo-modules-core\" should not be a direct dependency — use the API re-exported from the `expo` package",
4791
+ help: "Remove `expo-modules-core` from your package.json and import from `expo` instead"
4792
+ },
4793
+ {
4794
+ packageName: "@expo/metro-config",
4795
+ rule: "expo-no-redundant-dependency",
4796
+ message: "\"@expo/metro-config\" should not be a direct dependency — use the `expo/metro-config` sub-export of the `expo` package",
4797
+ help: "Remove `@expo/metro-config` and import `expo/metro-config` in your metro.config.js"
4798
+ },
4799
+ {
4800
+ packageName: "@types/react-native",
4801
+ rule: "expo-no-redundant-dependency",
4802
+ message: "\"@types/react-native\" should not be installed — React Native ships its own types since SDK 48",
4803
+ help: "Remove `@types/react-native` from your package.json",
4804
+ minSdkMajor: 48
4805
+ },
4806
+ {
4807
+ packageName: "@expo/config-plugins",
4808
+ rule: "expo-no-redundant-dependency",
4809
+ message: "\"@expo/config-plugins\" should not be a direct dependency — use the `expo/config-plugins` sub-export of the `expo` package",
4810
+ help: "Remove `@expo/config-plugins`; config-plugin authors should import from `expo/config-plugins`. See https://github.com/expo/expo/pull/18855",
4811
+ minSdkMajor: 48
4812
+ },
4813
+ {
4814
+ packageName: "@expo/prebuild-config",
4815
+ rule: "expo-no-redundant-dependency",
4816
+ message: "\"@expo/prebuild-config\" should not be a direct dependency — Expo installs it transitively",
4817
+ help: "Remove `@expo/prebuild-config` from your package.json",
4818
+ minSdkMajor: 53
4819
+ },
4820
+ {
4821
+ packageName: "expo-permissions",
4822
+ rule: "expo-no-redundant-dependency",
4823
+ message: "\"expo-permissions\" was deprecated in SDK 41 and may no longer compile — permissions moved onto each module (e.g. `MediaLibrary.requestPermissionsAsync()`)",
4824
+ help: "Remove `expo-permissions` and request permissions from the relevant module instead",
4825
+ minSdkMajor: 50
4826
+ },
4827
+ {
4828
+ packageName: "expo-app-loading",
4829
+ rule: "expo-no-redundant-dependency",
4830
+ message: "\"expo-app-loading\" was removed in SDK 49",
4831
+ help: "Remove `expo-app-loading` and use `expo-splash-screen` instead. See https://docs.expo.dev/versions/latest/sdk/splash-screen/",
4832
+ minSdkMajor: 49
4833
+ },
4834
+ {
4835
+ packageName: "expo-firebase-analytics",
4836
+ rule: "expo-no-redundant-dependency",
4837
+ message: "\"expo-firebase-analytics\" was removed in SDK 48",
4838
+ help: FIREBASE_HELP,
4839
+ minSdkMajor: 48
4840
+ },
4841
+ {
4842
+ packageName: "expo-firebase-recaptcha",
4843
+ rule: "expo-no-redundant-dependency",
4844
+ message: "\"expo-firebase-recaptcha\" was removed in SDK 48",
4845
+ help: FIREBASE_HELP,
4846
+ minSdkMajor: 48
4847
+ },
4848
+ {
4849
+ packageName: "expo-firebase-core",
4850
+ rule: "expo-no-redundant-dependency",
4851
+ message: "\"expo-firebase-core\" was removed in SDK 48",
4852
+ help: FIREBASE_HELP,
4853
+ minSdkMajor: 48
4854
+ }
4855
+ ];
4856
+ const checkExpoFlaggedDependencies = (context) => FLAGGED_DEPENDENCIES.filter((flaggedDependency) => {
4857
+ if (!context.directDependencyNames.has(flaggedDependency.packageName)) return false;
4858
+ if (flaggedDependency.minSdkMajor === void 0) return true;
4859
+ return isExpoSdkAtLeast(context.expoSdkMajor, flaggedDependency.minSdkMajor);
4860
+ }).map((flaggedDependency) => buildExpoDiagnostic({
4861
+ rule: flaggedDependency.rule,
4862
+ message: flaggedDependency.message,
4863
+ help: flaggedDependency.help
4864
+ }));
4865
+ const findLocalModuleNativeFiles = (rootDirectory) => {
4866
+ const modulesDirectory = path.join(rootDirectory, "modules");
4867
+ if (!isDirectory(modulesDirectory)) return [];
4868
+ const nativeFilePaths = [];
4869
+ for (const moduleEntry of readDirectoryEntries(modulesDirectory)) {
4870
+ if (!moduleEntry.isDirectory()) continue;
4871
+ const moduleDirectory = path.join(modulesDirectory, moduleEntry.name);
4872
+ const gradlePath = path.join(moduleDirectory, "android", "build.gradle");
4873
+ if (isFile(gradlePath)) nativeFilePaths.push(gradlePath);
4874
+ const iosDirectory = path.join(moduleDirectory, "ios");
4875
+ if (isDirectory(iosDirectory)) {
4876
+ for (const iosEntry of readDirectoryEntries(iosDirectory)) if (iosEntry.isFile() && iosEntry.name.endsWith(".podspec")) nativeFilePaths.push(path.join(iosDirectory, iosEntry.name));
4877
+ }
4878
+ }
4879
+ return nativeFilePaths;
4880
+ };
4881
+ const checkExpoGitignore = (context) => {
4882
+ const { rootDirectory } = context;
4883
+ const diagnostics = [];
4884
+ const expoStateDirectory = path.join(rootDirectory, ".expo");
4885
+ if (isDirectory(expoStateDirectory) && isPathGitIgnored(rootDirectory, expoStateDirectory) === false) diagnostics.push(buildExpoDiagnostic({
4886
+ rule: "expo-gitignore",
4887
+ message: "The `.expo` directory is not ignored by Git — it holds machine-specific device history and dev-server settings that should not be committed",
4888
+ help: "Add `.expo/` to your .gitignore"
4889
+ }));
4890
+ if (findLocalModuleNativeFiles(rootDirectory).find((nativeFilePath) => isPathGitIgnored(rootDirectory, nativeFilePath) === true) !== void 0) diagnostics.push(buildExpoDiagnostic({
4891
+ rule: "expo-gitignore",
4892
+ message: "The native `ios`/`android` directories of a local Expo module under `modules/` are gitignored — usually caused by an overly broad `ios`/`android` ignore rule",
4893
+ help: "Use anchored patterns like `/ios` and `/android` in .gitignore so only the top-level native directories are excluded, not those inside `modules/`"
4894
+ }));
4895
+ return diagnostics;
4896
+ };
4897
+ const LOCKFILE_NAMES = [
4898
+ "pnpm-lock.yaml",
4899
+ "yarn.lock",
4900
+ "package-lock.json",
4901
+ "bun.lockb",
4902
+ "bun.lock"
4903
+ ];
4904
+ const checkExpoLockfile = (context) => {
4905
+ const workspaceRoot = isMonorepoRoot(context.rootDirectory) ? context.rootDirectory : findMonorepoRoot(context.rootDirectory) ?? context.rootDirectory;
4906
+ const presentLockfiles = LOCKFILE_NAMES.filter((lockfileName) => isFile(path.join(workspaceRoot, lockfileName)));
4907
+ if (presentLockfiles.length === 0) return [buildExpoDiagnostic({
4908
+ rule: "expo-lockfile",
4909
+ message: "No lock file detected at the project root — installs are not reproducible, and EAS Build cannot infer your package manager",
4910
+ help: "Install dependencies with your package manager to generate a lock file, then commit it"
4911
+ })];
4912
+ if (presentLockfiles.length > 1) return [buildExpoDiagnostic({
4913
+ rule: "expo-lockfile",
4914
+ message: `Multiple lock files detected (${presentLockfiles.join(", ")}) — CI environments such as EAS Build infer the package manager from the lock file, so this is ambiguous`,
4915
+ help: "Delete the lock files for the package managers you are not using and keep only one"
4916
+ })];
4917
+ return [];
4918
+ };
4919
+ const METRO_CONFIG_FILE_NAMES = [
4920
+ "metro.config.js",
4921
+ "metro.config.cjs",
4922
+ "metro.config.mjs",
4923
+ "metro.config.ts"
4924
+ ];
4925
+ const EXPO_METRO_CONFIG_EXTEND_SIGNALS = [
4926
+ "expo/metro-config",
4927
+ "@sentry/react-native/metro",
4928
+ "getSentryExpoConfig"
4929
+ ];
4930
+ const checkExpoMetroConfig = (context) => {
4931
+ const metroConfigPath = METRO_CONFIG_FILE_NAMES.map((fileName) => path.join(context.rootDirectory, fileName)).find((candidatePath) => isFile(candidatePath));
4932
+ if (metroConfigPath === void 0) return [];
4933
+ let contents;
4934
+ try {
4935
+ contents = fs.readFileSync(metroConfigPath, "utf-8");
4936
+ } catch {
4937
+ return [];
4938
+ }
4939
+ if (EXPO_METRO_CONFIG_EXTEND_SIGNALS.some((signal) => contents.includes(signal))) return [];
4940
+ return [buildExpoDiagnostic({
4941
+ rule: "expo-metro-config",
4942
+ filePath: path.basename(metroConfigPath),
4943
+ message: "Your metro.config does not extend `expo/metro-config` — a custom Metro config that doesn't extend Expo's leads to unexpected, hard-to-debug bundling issues",
4944
+ help: "Update your metro config to extend `expo/metro-config`. See https://docs.expo.dev/guides/customizing-metro/"
4945
+ })];
4946
+ };
4947
+ const CONFLICTING_SCRIPT_NAMES = ["expo", "react-native"];
4948
+ const checkExpoPackageJsonConflicts = (context) => {
4949
+ const { packageJson } = context;
4950
+ const diagnostics = [];
4951
+ const conflictingScriptNames = CONFLICTING_SCRIPT_NAMES.filter((scriptName) => Boolean(packageJson.scripts?.[scriptName]));
4952
+ if (conflictingScriptNames.length > 0) {
4953
+ const quotedNames = conflictingScriptNames.map((name) => `"${name}"`).join(", ");
4954
+ const shadowsExpoCli = conflictingScriptNames.includes("expo");
4955
+ diagnostics.push(buildExpoDiagnostic({
4956
+ rule: "expo-package-json-conflict",
4957
+ message: `package.json defines ${quotedNames} ${conflictingScriptNames.length === 1 ? "as a script that conflicts" : "as scripts that conflict"} with binaries in node_modules/.bin${shadowsExpoCli ? " — a `expo` script shadows the Expo CLI and will likely cause build failures" : ""}`,
4958
+ help: "Rename these scripts so they don't collide with the `expo` / `react-native` binaries"
4959
+ }));
4960
+ }
4961
+ const packageName = packageJson.name;
4962
+ if (typeof packageName === "string" && context.directDependencyNames.has(packageName)) diagnostics.push(buildExpoDiagnostic({
4963
+ rule: "expo-package-json-conflict",
4964
+ message: `package.json "name" is "${packageName}", which collides with a dependency of the same name`,
4965
+ help: "Rename your package so it no longer matches one of its dependencies"
4966
+ }));
4967
+ return diagnostics;
4968
+ };
4969
+ const EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR = 56;
4970
+ const EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE = 57;
4971
+ const checkExpoRouterReactNavigation = (context) => {
4972
+ const { expoSdkMajor } = context;
4973
+ if (!isExpoSdkAtLeast(expoSdkMajor, EXPO_ROUTER_REACT_NAVIGATION_MIN_SDK_MAJOR)) return [];
4974
+ if (expoSdkMajor !== null && expoSdkMajor >= EXPO_ROUTER_REACT_NAVIGATION_MAX_SDK_MAJOR_EXCLUSIVE) return [];
4975
+ if (!context.directDependencyNames.has("expo-router")) return [];
4976
+ const reactNavigationNames = [...context.directDependencyNames].filter((packageName) => packageName.startsWith("@react-navigation/")).sort();
4977
+ if (reactNavigationNames.length === 0) return [];
4978
+ return [buildExpoDiagnostic({
4979
+ rule: "expo-router-no-react-navigation",
4980
+ message: `As of SDK 56, expo-router is no longer compatible with react-navigation, but ${reactNavigationNames.map((name) => `"${name}"`).join(", ")} ${reactNavigationNames.length === 1 ? "is" : "are"} installed as direct ${reactNavigationNames.length === 1 ? "dependency" : "dependencies"}`,
4981
+ help: "Remove these `@react-navigation/*` packages and replace direct imports with their expo-router equivalents. See https://docs.expo.dev/router/migrate/sdk-55-to-56/"
4982
+ })];
4983
+ };
4984
+ const VECTOR_ICONS_MIN_SDK_MAJOR = 56;
4985
+ const SCOPED_VECTOR_ICONS_NAMESPACE = "@react-native-vector-icons/";
4986
+ const CONFLICTING_VECTOR_ICONS_PACKAGES = ["@expo/vector-icons", "react-native-vector-icons"];
4987
+ const checkExpoVectorIcons = (context) => {
4988
+ if (!isExpoSdkAtLeast(context.expoSdkMajor, VECTOR_ICONS_MIN_SDK_MAJOR)) return [];
4989
+ const hasScopedPackage = [...context.directDependencyNames].some((packageName) => packageName.startsWith(SCOPED_VECTOR_ICONS_NAMESPACE));
4990
+ const hasConflictingPackage = CONFLICTING_VECTOR_ICONS_PACKAGES.some((packageName) => context.directDependencyNames.has(packageName));
4991
+ if (!hasScopedPackage || !hasConflictingPackage) return [];
4992
+ return [buildExpoDiagnostic({
4993
+ rule: "expo-vector-icons-conflict",
4994
+ message: "This project installs both the scoped `@react-native-vector-icons/*` packages and `@expo/vector-icons` (or the deprecated `react-native-vector-icons`) — mixing them causes icon-rendering conflicts",
4995
+ help: "Migrate to the scoped packages by running `npx @react-native-vector-icons/codemod`, then remove the conflicting package"
4996
+ })];
4997
+ };
4998
+ const checkExpoProject = (rootDirectory, project) => {
4999
+ if (project.expoVersion === null) return [];
5000
+ const context = buildExpoCheckContext(rootDirectory, project.expoVersion);
5001
+ return [
5002
+ ...checkExpoFlaggedDependencies(context),
5003
+ ...checkExpoDependencyOverrides(context),
5004
+ ...checkExpoRouterReactNavigation(context),
5005
+ ...checkExpoVectorIcons(context),
5006
+ ...checkExpoPackageJsonConflicts(context),
5007
+ ...checkExpoLockfile(context),
5008
+ ...checkExpoGitignore(context),
5009
+ ...checkExpoEnvLocalFiles(context),
5010
+ ...checkExpoMetroConfig(context)
5011
+ ];
5012
+ };
4406
5013
  const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
4407
5014
  const PNPM_LOCKFILE = "pnpm-lock.yaml";
4408
5015
  const PACKAGE_JSON_FILE = "package.json";
@@ -4572,99 +5179,6 @@ const checkReducedMotion = (rootDirectory) => {
4572
5179
  return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
4573
5180
  };
4574
5181
  const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
4575
- const toStringSet = (values) => {
4576
- if (!values || values.length === 0) return /* @__PURE__ */ new Set();
4577
- return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
4578
- };
4579
- const buildResolvedControls = (surface, userControls) => {
4580
- const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
4581
- const includeTags = toStringSet(userControls?.includeTags);
4582
- for (const tag of includeTags) excludeTags.delete(tag);
4583
- for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
4584
- return {
4585
- includeTags,
4586
- excludeTags,
4587
- includeCategories: toStringSet(userControls?.includeCategories),
4588
- excludeCategories: toStringSet(userControls?.excludeCategories),
4589
- includeRuleKeys: toStringSet(userControls?.includeRules),
4590
- excludeRuleKeys: toStringSet(userControls?.excludeRules)
4591
- };
4592
- };
4593
- const intersects = (values, candidates) => values.some((value) => candidates.has(value));
4594
- const isDiagnosticOnSurface = (diagnostic, surface, config) => {
4595
- const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
4596
- const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
4597
- if (resolved.includeRuleKeys.has(ruleKey)) return true;
4598
- if (resolved.includeCategories.has(category)) return true;
4599
- if (intersects(tags, resolved.includeTags)) return true;
4600
- if (resolved.excludeRuleKeys.has(ruleKey)) return false;
4601
- if (resolved.excludeCategories.has(category)) return false;
4602
- if (intersects(tags, resolved.excludeTags)) return false;
4603
- return true;
4604
- };
4605
- const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
4606
- const listSourceFilesViaGit = (rootDirectory) => {
4607
- const result = spawnSync("git", [
4608
- "ls-files",
4609
- "-z",
4610
- "--cached",
4611
- "--others",
4612
- "--exclude-standard"
4613
- ], {
4614
- cwd: rootDirectory,
4615
- encoding: "utf-8",
4616
- maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
4617
- });
4618
- if (result.error || result.status !== 0) return null;
4619
- return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
4620
- };
4621
- const listSourceFilesViaFilesystem = (rootDirectory) => {
4622
- const filePaths = [];
4623
- const stack = [rootDirectory];
4624
- while (stack.length > 0) {
4625
- const currentDirectory = stack.pop();
4626
- const entries = readDirectoryEntries(currentDirectory);
4627
- for (const entry of entries) {
4628
- const absolutePath = path.join(currentDirectory, entry.name);
4629
- if (entry.isDirectory()) {
4630
- if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
4631
- continue;
4632
- }
4633
- if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
4634
- }
4635
- }
4636
- return filePaths;
4637
- };
4638
- const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
4639
- const resolveLintIncludePaths = (rootDirectory, userConfig) => {
4640
- if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
4641
- const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
4642
- return listSourceFiles(rootDirectory).filter((filePath) => {
4643
- if (!JSX_FILE_PATTERN.test(filePath)) return false;
4644
- return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
4645
- });
4646
- };
4647
- var Config = class Config extends Context.Service()("react-doctor/Config") {
4648
- static layerNode = Layer.effect(Config, Effect.gen(function* () {
4649
- const cache = yield* Cache.make({
4650
- capacity: 16,
4651
- timeToLive: CONFIG_CACHE_TTL_MS,
4652
- lookup: (directory) => Effect.sync(() => {
4653
- const loaded = loadConfigWithSource(directory);
4654
- const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
4655
- return {
4656
- config: loaded?.config ?? null,
4657
- resolvedDirectory: redirected ?? directory,
4658
- configSourceDirectory: loaded?.sourceDirectory ?? null
4659
- };
4660
- })
4661
- });
4662
- return Config.of({ resolve: Effect.fn("Config.resolve")(function* (directory) {
4663
- return yield* Cache.get(cache, directory);
4664
- }) });
4665
- }));
4666
- static layerOf = (resolved) => Layer.succeed(Config, Config.of({ resolve: () => Effect.succeed(resolved) }));
4667
- };
4668
5182
  const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
4669
5183
  const FALSY_VALUES = new Set([
4670
5184
  "false",
@@ -4746,6 +5260,30 @@ const collectIgnorePatterns = (rootDirectory) => {
4746
5260
  cachedPatternsByRoot.set(rootDirectory, patterns);
4747
5261
  return patterns;
4748
5262
  };
5263
+ /**
5264
+ * Resolves a path to its canonical, symlink-free form, falling back to
5265
+ * the input when it cannot be realpath'd (broken symlink, permission
5266
+ * error) so a best-effort normalization never throws.
5267
+ *
5268
+ * deslop's dead-code module graph is collected with `fast-glob` (which
5269
+ * keeps the scan root's symlinks intact) while imports are resolved
5270
+ * through `oxc-resolver` (which returns realpath'd targets). When the
5271
+ * project root sits behind a symlink — e.g. macOS iCloud-synced
5272
+ * `~/Documents` / `~/Desktop`, or a symlinked checkout — those two path
5273
+ * spaces diverge: every resolved import misses the graph and the files
5274
+ * they point at (commonly every `@/…` alias target) are mis-reported as
5275
+ * unreachable. Canonicalizing the root before the scan keeps both path
5276
+ * spaces in agreement.
5277
+ */
5278
+ const toCanonicalPath = (filePath) => {
5279
+ try {
5280
+ return fs.realpathSync(filePath);
5281
+ } catch {
5282
+ return filePath;
5283
+ }
5284
+ };
5285
+ const DEAD_CODE_PLUGIN = "deslop";
5286
+ const DEAD_CODE_CATEGORY = "Maintainability";
4749
5287
  const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
4750
5288
  const DEAD_CODE_WORKER_SCRIPT = `
4751
5289
  const inputChunks = [];
@@ -4921,7 +5459,11 @@ const buildDeadCodeWorkerError = (workerError) => {
4921
5459
  return error;
4922
5460
  };
4923
5461
  const createDeadCodeWorker = (input) => {
4924
- const child = spawn(process.execPath, ["-e", DEAD_CODE_WORKER_SCRIPT], {
5462
+ const child = spawn(process.execPath, [
5463
+ `--max-old-space-size=${DEAD_CODE_WORKER_MAX_OLD_SPACE_MB}`,
5464
+ "-e",
5465
+ DEAD_CODE_WORKER_SCRIPT
5466
+ ], {
4925
5467
  stdio: [
4926
5468
  "pipe",
4927
5469
  "pipe",
@@ -4996,7 +5538,8 @@ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve
4996
5538
  });
4997
5539
  });
4998
5540
  const checkDeadCode = async (options) => {
4999
- const { rootDirectory, userConfig } = options;
5541
+ const { userConfig } = options;
5542
+ const rootDirectory = toCanonicalPath(options.rootDirectory);
5000
5543
  if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];
5001
5544
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
5002
5545
  const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
@@ -5009,59 +5552,162 @@ const checkDeadCode = async (options) => {
5009
5552
  const diagnostics = [];
5010
5553
  for (const unusedFile of result.unusedFiles) diagnostics.push({
5011
5554
  filePath: toRelative(unusedFile.path),
5012
- plugin: "deslop",
5555
+ plugin: DEAD_CODE_PLUGIN,
5013
5556
  rule: "unused-file",
5014
5557
  severity: "warning",
5015
5558
  message: "Unused file — not reachable from any entry point",
5016
5559
  help: "Delete the file if it is truly unreachable, or import it from an entry point.",
5017
5560
  line: 0,
5018
5561
  column: 0,
5019
- category: "Dead Code"
5562
+ category: DEAD_CODE_CATEGORY
5020
5563
  });
5021
5564
  for (const unusedExport of result.unusedExports) {
5022
5565
  const label = unusedExport.isTypeOnly ? "type export" : "export";
5023
5566
  diagnostics.push({
5024
5567
  filePath: toRelative(unusedExport.path),
5025
- plugin: "deslop",
5568
+ plugin: DEAD_CODE_PLUGIN,
5026
5569
  rule: unusedExport.isTypeOnly ? "unused-type" : "unused-export",
5027
5570
  severity: "warning",
5028
5571
  message: `Unused ${label}: \`${unusedExport.name}\``,
5029
5572
  help: "Drop the `export` keyword (or remove the declaration) if no other module uses this symbol.",
5030
5573
  line: unusedExport.line,
5031
5574
  column: unusedExport.column,
5032
- category: "Dead Code"
5575
+ category: DEAD_CODE_CATEGORY
5033
5576
  });
5034
5577
  }
5035
5578
  for (const unusedDependency of result.unusedDependencies) {
5036
5579
  const label = unusedDependency.isDevDependency ? "devDependency" : "dependency";
5037
5580
  diagnostics.push({
5038
5581
  filePath: "package.json",
5039
- plugin: "deslop",
5582
+ plugin: DEAD_CODE_PLUGIN,
5040
5583
  rule: unusedDependency.isDevDependency ? "unused-dev-dependency" : "unused-dependency",
5041
5584
  severity: "warning",
5042
5585
  message: `Unused ${label}: \`${unusedDependency.name}\``,
5043
5586
  help: "Remove the dependency from package.json if it is genuinely unused.",
5044
5587
  line: 0,
5045
5588
  column: 0,
5046
- category: "Dead Code"
5589
+ category: DEAD_CODE_CATEGORY
5047
5590
  });
5048
5591
  }
5049
5592
  for (const cycle of result.circularDependencies) {
5050
5593
  if (cycle.files.length === 0) continue;
5051
5594
  diagnostics.push({
5052
5595
  filePath: toRelative(cycle.files[0]),
5053
- plugin: "deslop",
5596
+ plugin: DEAD_CODE_PLUGIN,
5054
5597
  rule: "circular-dependency",
5055
5598
  severity: "warning",
5056
5599
  message: `Circular import cycle: ${cycle.files.map(toRelative).join(" → ")}`,
5057
5600
  help: "Break the cycle by extracting the shared code into a third module that both files import.",
5058
5601
  line: 0,
5059
5602
  column: 0,
5060
- category: "Dead Code"
5603
+ category: DEAD_CODE_CATEGORY
5061
5604
  });
5062
5605
  }
5063
5606
  return diagnostics;
5064
5607
  };
5608
+ const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
5609
+ const isSurfacingOverride = (override) => override === "warn" || override === "error";
5610
+ const deadCodeMaySurfaceWhenWarningsHidden = (userConfig) => {
5611
+ const severityControls = buildRuleSeverityControls(userConfig);
5612
+ if (!severityControls) return false;
5613
+ if (isSurfacingOverride(severityControls.categories?.["Maintainability"])) return true;
5614
+ for (const [ruleKey, override] of Object.entries(severityControls.rules ?? {})) if (ruleKey.startsWith(DEAD_CODE_RULE_KEY_PREFIX) && isSurfacingOverride(override)) return true;
5615
+ return false;
5616
+ };
5617
+ const toStringSet = (values) => {
5618
+ if (!values || values.length === 0) return /* @__PURE__ */ new Set();
5619
+ return new Set(values.filter((value) => typeof value === "string" && value.length > 0));
5620
+ };
5621
+ const buildResolvedControls = (surface, userControls) => {
5622
+ const excludeTags = new Set(DEFAULT_SURFACE_EXCLUDED_TAGS[surface]);
5623
+ const includeTags = toStringSet(userControls?.includeTags);
5624
+ for (const tag of includeTags) excludeTags.delete(tag);
5625
+ for (const tag of toStringSet(userControls?.excludeTags)) excludeTags.add(tag);
5626
+ return {
5627
+ includeTags,
5628
+ excludeTags,
5629
+ includeCategories: toStringSet(userControls?.includeCategories),
5630
+ excludeCategories: toStringSet(userControls?.excludeCategories),
5631
+ includeRuleKeys: toStringSet(userControls?.includeRules),
5632
+ excludeRuleKeys: toStringSet(userControls?.excludeRules)
5633
+ };
5634
+ };
5635
+ const intersects = (values, candidates) => values.some((value) => candidates.has(value));
5636
+ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
5637
+ const resolved = buildResolvedControls(surface, config?.surfaces?.[surface]);
5638
+ const { ruleKey, category, tags } = getDiagnosticRuleIdentity(diagnostic);
5639
+ if (resolved.includeRuleKeys.has(ruleKey)) return true;
5640
+ if (resolved.includeCategories.has(category)) return true;
5641
+ if (intersects(tags, resolved.includeTags)) return true;
5642
+ if (resolved.excludeRuleKeys.has(ruleKey)) return false;
5643
+ if (resolved.excludeCategories.has(category)) return false;
5644
+ if (intersects(tags, resolved.excludeTags)) return false;
5645
+ return true;
5646
+ };
5647
+ const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
5648
+ const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(path.resolve(rootDirectory, relativePath)));
5649
+ const listSourceFilesViaGit = (rootDirectory) => {
5650
+ const result = spawnSync("git", [
5651
+ "ls-files",
5652
+ "-z",
5653
+ "--cached",
5654
+ "--others",
5655
+ "--exclude-standard"
5656
+ ], {
5657
+ cwd: rootDirectory,
5658
+ encoding: "utf-8",
5659
+ maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
5660
+ });
5661
+ if (result.error || result.status !== 0) return null;
5662
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
5663
+ };
5664
+ const listSourceFilesViaFilesystem = (rootDirectory) => {
5665
+ const filePaths = [];
5666
+ const stack = [rootDirectory];
5667
+ while (stack.length > 0) {
5668
+ const currentDirectory = stack.pop();
5669
+ const entries = readDirectoryEntries(currentDirectory);
5670
+ for (const entry of entries) {
5671
+ const absolutePath = path.join(currentDirectory, entry.name);
5672
+ if (entry.isDirectory()) {
5673
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
5674
+ continue;
5675
+ }
5676
+ if (entry.isFile() && isLintableSourceFile(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
5677
+ }
5678
+ }
5679
+ return filePaths;
5680
+ };
5681
+ const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
5682
+ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
5683
+ if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
5684
+ const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
5685
+ return listSourceFiles(rootDirectory).filter((filePath) => {
5686
+ if (!JSX_FILE_PATTERN.test(filePath)) return false;
5687
+ return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
5688
+ });
5689
+ };
5690
+ var Config = class Config extends Context.Service()("react-doctor/Config") {
5691
+ static layerNode = Layer.effect(Config, Effect.gen(function* () {
5692
+ const cache = yield* Cache.make({
5693
+ capacity: 16,
5694
+ timeToLive: CONFIG_CACHE_TTL_MS,
5695
+ lookup: (directory) => Effect.promise(async () => {
5696
+ const loaded = await loadConfigWithSource(directory);
5697
+ const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
5698
+ return {
5699
+ config: loaded?.config ?? null,
5700
+ resolvedDirectory: redirected ?? directory,
5701
+ configSourceDirectory: loaded?.sourceDirectory ?? null
5702
+ };
5703
+ })
5704
+ });
5705
+ return Config.of({ resolve: Effect.fn("Config.resolve")(function* (directory) {
5706
+ return yield* Cache.get(cache, directory);
5707
+ }) });
5708
+ }));
5709
+ static layerOf = (resolved) => Layer.succeed(Config, Config.of({ resolve: () => Effect.succeed(resolved) }));
5710
+ };
5065
5711
  /**
5066
5712
  * `DeadCode` runs whole-project reachability analysis and streams
5067
5713
  * diagnostics. Reachability is a whole-project property — the
@@ -5567,12 +6213,12 @@ const findFilesWithDisableDirectivesViaGit = async (rootDirectory, includePaths)
5567
6213
  return null;
5568
6214
  }
5569
6215
  if (grepResult === null) return null;
5570
- return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
6216
+ return grepResult.stdout.split("\n").filter((filePath) => filePath.length > 0 && isLintableSourceFile(filePath));
5571
6217
  };
5572
6218
  const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
5573
6219
  const matches = [];
5574
6220
  const checkFile = (relativePath) => {
5575
- if (!SOURCE_FILE_PATTERN.test(relativePath)) return;
6221
+ if (!isLintableSourceFile(relativePath)) return;
5576
6222
  const absolutePath = path.join(rootDirectory, relativePath);
5577
6223
  let content;
5578
6224
  try {
@@ -5644,6 +6290,7 @@ const buildCapabilities = (project) => {
5644
6290
  const capabilities = /* @__PURE__ */ new Set();
5645
6291
  capabilities.add(project.framework);
5646
6292
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
6293
+ if (project.expoVersion !== null) capabilities.add("expo");
5647
6294
  const reactMajor = project.reactMajorVersion;
5648
6295
  if (reactMajor !== null) {
5649
6296
  const cappedReactMajor = Math.min(reactMajor, 30);
@@ -5815,10 +6462,14 @@ const resolveSettingsRootDirectory = (rootDirectory) => {
5815
6462
  if (!fs.existsSync(rootDirectory)) return rootDirectory;
5816
6463
  return fs.realpathSync(rootDirectory);
5817
6464
  };
6465
+ const resolveCompilerCleanupBucketSeverity = (ruleKey, severityControls) => {
6466
+ if (!COMPILER_CLEANUP_RULE_KEYS.has(ruleKey)) return void 0;
6467
+ return severityControls?.buckets?.[COMPILER_CLEANUP_BUCKET];
6468
+ };
5818
6469
  const applyRuleSeverityControls = (rules, severityControls) => {
5819
6470
  const enabledRules = {};
5820
6471
  for (const [ruleKey, defaultSeverity] of Object.entries(rules)) {
5821
- const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? defaultSeverity;
6472
+ const severity = resolveRuleSeverityOverride({ ruleKey }, severityControls) ?? resolveCompilerCleanupBucketSeverity(ruleKey, severityControls) ?? defaultSeverity;
5822
6473
  if (severity === "off") continue;
5823
6474
  enabledRules[ruleKey] = severity;
5824
6475
  }
@@ -5860,7 +6511,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
5860
6511
  category: rule.category
5861
6512
  }, severityControls);
5862
6513
  if (rule.defaultEnabled === false && explicitSeverity === void 0) continue;
5863
- const severity = explicitSeverity ?? rule.severity;
6514
+ const severity = explicitSeverity ?? resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity;
5864
6515
  if (severity === "off") continue;
5865
6516
  enabledReactDoctorRules[registryEntry.key] = severity;
5866
6517
  }
@@ -5917,6 +6568,44 @@ const dedupeDiagnostics = (diagnostics) => {
5917
6568
  }
5918
6569
  return uniqueDiagnostics;
5919
6570
  };
6571
+ /**
6572
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
6573
+ * once, returning results in input order. A pool of workers each pulls the
6574
+ * next not-yet-started index until the list drains — so a worker that
6575
+ * finishes a fast task immediately picks up the next one (greedy load
6576
+ * balancing), which matters when tasks have uneven durations (oxlint
6577
+ * batches do).
6578
+ *
6579
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
6580
+ * no further tasks are started, the already-in-flight tasks are awaited to
6581
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
6582
+ * rejects with that first error. This keeps the caller's fail-fast retry
6583
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
6584
+ * top of a still-running first one.
6585
+ */
6586
+ const mapWithConcurrency = async (items, concurrency, task) => {
6587
+ const results = new Array(items.length);
6588
+ if (items.length === 0) return results;
6589
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
6590
+ let nextIndex = 0;
6591
+ const errors = [];
6592
+ const runWorker = async () => {
6593
+ while (errors.length === 0) {
6594
+ const index = nextIndex;
6595
+ nextIndex += 1;
6596
+ if (index >= items.length) return;
6597
+ try {
6598
+ results[index] = await task(items[index], index);
6599
+ } catch (error) {
6600
+ errors.push(error);
6601
+ return;
6602
+ }
6603
+ }
6604
+ };
6605
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
6606
+ if (errors.length > 0) throw errors[0];
6607
+ return results;
6608
+ };
5920
6609
  const getPublicEnvPrefix = (framework) => {
5921
6610
  switch (framework) {
5922
6611
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -5939,6 +6628,149 @@ const appendReanimatedSharedValueHint = (help, rule, project) => {
5939
6628
  if (!help) return REANIMATED_SHARED_VALUE_HINT;
5940
6629
  return `${help}\n\n${REANIMATED_SHARED_VALUE_HINT}`;
5941
6630
  };
6631
+ const REDACTED_PLACEHOLDER = "<redacted>";
6632
+ const KEEP_PREFIX = `$1${REDACTED_PLACEHOLDER}`;
6633
+ const KNOWN_SECRET_RULES = [
6634
+ {
6635
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
6636
+ replacement: REDACTED_PLACEHOLDER
6637
+ },
6638
+ {
6639
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
6640
+ replacement: REDACTED_PLACEHOLDER
6641
+ },
6642
+ {
6643
+ pattern: /(?<=:\/\/)[^\s/:@]+:[^\s/@]+(?=@)/g,
6644
+ replacement: REDACTED_PLACEHOLDER
6645
+ },
6646
+ {
6647
+ pattern: /\b(AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|A3T[A-Z0-9])[0-9A-Z]{16,}/g,
6648
+ replacement: KEEP_PREFIX
6649
+ },
6650
+ {
6651
+ pattern: /\b(gh[pousr]_)[A-Za-z0-9]{36,}/g,
6652
+ replacement: KEEP_PREFIX
6653
+ },
6654
+ {
6655
+ pattern: /\b(github_pat_)[A-Za-z0-9_]{22,}/g,
6656
+ replacement: KEEP_PREFIX
6657
+ },
6658
+ {
6659
+ pattern: /\b(glpat-)[A-Za-z0-9_-]{20,}/g,
6660
+ replacement: KEEP_PREFIX
6661
+ },
6662
+ {
6663
+ pattern: /\b(xox[baprs]-)[A-Za-z0-9-]{10,}/g,
6664
+ replacement: KEEP_PREFIX
6665
+ },
6666
+ {
6667
+ pattern: /(?<=hooks\.slack\.com\/services\/)[A-Za-z0-9/+_-]{20,}/g,
6668
+ replacement: REDACTED_PLACEHOLDER
6669
+ },
6670
+ {
6671
+ pattern: /\b((?:sk|rk)_(?:live|test)_)[0-9A-Za-z]{10,}/g,
6672
+ replacement: KEEP_PREFIX
6673
+ },
6674
+ {
6675
+ pattern: /\b(sk-(?:proj-|ant-)?)[A-Za-z0-9_-]{20,}/g,
6676
+ replacement: KEEP_PREFIX
6677
+ },
6678
+ {
6679
+ pattern: /\b(AIza)[0-9A-Za-z_-]{35,}/g,
6680
+ replacement: KEEP_PREFIX
6681
+ },
6682
+ {
6683
+ pattern: /\b(ya29\.)[0-9A-Za-z_-]{20,}/g,
6684
+ replacement: KEEP_PREFIX
6685
+ },
6686
+ {
6687
+ pattern: /\b(npm_)[A-Za-z0-9]{36,}/g,
6688
+ replacement: KEEP_PREFIX
6689
+ },
6690
+ {
6691
+ pattern: /\b(SG\.)[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{43,}/g,
6692
+ replacement: KEEP_PREFIX
6693
+ },
6694
+ {
6695
+ pattern: /\b(SK)[0-9a-fA-F]{32,}/g,
6696
+ replacement: KEEP_PREFIX
6697
+ },
6698
+ {
6699
+ pattern: /\b(dop_v1_)[a-f0-9]{64,}/g,
6700
+ replacement: KEEP_PREFIX
6701
+ },
6702
+ {
6703
+ pattern: /\b(shp(?:at|ca|pa|ss)_)[a-fA-F0-9]{32,}/g,
6704
+ replacement: KEEP_PREFIX
6705
+ },
6706
+ {
6707
+ pattern: /\b(sq0[a-z]{3}-)[0-9A-Za-z_-]{22,}/g,
6708
+ replacement: KEEP_PREFIX
6709
+ },
6710
+ {
6711
+ pattern: /\b([0-9]{8,10}:AA)[0-9A-Za-z_-]{32,}/g,
6712
+ replacement: KEEP_PREFIX
6713
+ },
6714
+ {
6715
+ pattern: /(?<=\bBearer\s)[A-Za-z0-9._~+/=-]{16,}/g,
6716
+ replacement: REDACTED_PLACEHOLDER
6717
+ },
6718
+ {
6719
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
6720
+ replacement: REDACTED_PLACEHOLDER
6721
+ }
6722
+ ];
6723
+ const CANDIDATE_TOKEN_PATTERN = /[A-Za-z0-9_][A-Za-z0-9_-]*/g;
6724
+ const HEX_DIGEST_PATTERN = /^(?:[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/;
6725
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6726
+ const HAS_LETTER_PATTERN = /[A-Za-z]/;
6727
+ const HAS_DIGIT_PATTERN = /[0-9]/;
6728
+ const shannonEntropyBits = (value) => {
6729
+ const counts = /* @__PURE__ */ new Map();
6730
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
6731
+ let bits = 0;
6732
+ for (const count of counts.values()) {
6733
+ const probability = count / value.length;
6734
+ bits -= probability * Math.log2(probability);
6735
+ }
6736
+ return bits;
6737
+ };
6738
+ const looksLikeHighEntropySecret = (token) => {
6739
+ if (token.length < 32) return false;
6740
+ if (!HAS_LETTER_PATTERN.test(token) || !HAS_DIGIT_PATTERN.test(token)) return false;
6741
+ if (HEX_DIGEST_PATTERN.test(token) || UUID_PATTERN.test(token)) return false;
6742
+ return shannonEntropyBits(token) >= 3;
6743
+ };
6744
+ const redactHighEntropyTokens = (text) => text.replace(CANDIDATE_TOKEN_PATTERN, (token) => looksLikeHighEntropySecret(token) ? REDACTED_PLACEHOLDER : token);
6745
+ /**
6746
+ * Masks API keys, tokens, private keys, credentialed URLs, and emails
6747
+ * found anywhere inside a free-text string, returning the scrubbed text.
6748
+ * Applied to every diagnostic's `message` / `help` at construction time
6749
+ * so secrets never reach the terminal, the JSON report, or the score
6750
+ * API — react-doctor must never echo or transmit a user's secrets.
6751
+ *
6752
+ * Provider tokens keep their non-secret, type-identifying prefix (e.g.
6753
+ * `sk_live_<redacted>`, `ghp_<redacted>`, `AKIA<redacted>`) so the leaked
6754
+ * credential's type stays visible; structural or unknown-format secrets
6755
+ * with no meaningful prefix are masked whole.
6756
+ *
6757
+ * Runs the high-precision known-shape detectors first, then a generic
6758
+ * entropy-gated sweep for unknown-format secrets. Idempotent: the inert
6759
+ * `<redacted>` placeholder matches none of the detectors and is too
6760
+ * short for the generic sweep, so re-running leaves the text unchanged.
6761
+ *
6762
+ * Accepts `unknown` on purpose: callers feed it diagnostic `message` /
6763
+ * `help` that originate from oxlint JSON, which is only shape-checked at
6764
+ * the top level (the per-field `string` types are assumed, not validated).
6765
+ * A malformed non-string value returns `""` instead of throwing on
6766
+ * `.replace`, so one bad diagnostic can't abort parsing the whole batch.
6767
+ */
6768
+ const redactSensitiveText = (text) => {
6769
+ if (typeof text !== "string" || text === "") return "";
6770
+ let redacted = text;
6771
+ for (const rule of KNOWN_SECRET_RULES) redacted = redacted.replace(rule.pattern, rule.replacement);
6772
+ return redactHighEntropyTokens(redacted);
6773
+ };
5942
6774
  const REACT_MODULE_SOURCE = "react";
5943
6775
  const REQUIRE_IDENTIFIER = "require";
5944
6776
  const USE_IDENTIFIER = "use";
@@ -6233,25 +7065,26 @@ const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
6233
7065
  return bindingResolution !== null && !bindingResolution.isReactUseBinding;
6234
7066
  };
6235
7067
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
6236
- const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
7068
+ const REACT_COMPILER_TITLE = "React Compiler can't optimize this";
7069
+ const REACT_COMPILER_MESSAGE = "This component misses React Compiler's automatic memoization & re-renders more than it should. Rewrite the flagged code so the compiler can optimize it.";
6237
7070
  const PLUGIN_CATEGORY_MAP = {
6238
- react: "Correctness",
6239
- "react-hooks": "Correctness",
6240
- "react-hooks-js": "React Compiler",
6241
- "react-doctor": "Other",
7071
+ react: "Bugs",
7072
+ "react-hooks": "Bugs",
7073
+ "react-hooks-js": "Performance",
7074
+ "react-doctor": "Bugs",
6242
7075
  "jsx-a11y": "Accessibility",
6243
- effect: "State & Effects",
6244
- eslint: "Correctness",
6245
- oxc: "Correctness",
6246
- typescript: "Correctness",
6247
- unicorn: "Correctness",
6248
- import: "Bundle Size",
6249
- promise: "Correctness",
6250
- n: "Correctness",
6251
- node: "Correctness",
6252
- vitest: "Correctness",
6253
- jest: "Correctness",
6254
- nextjs: "Next.js"
7076
+ effect: "Bugs",
7077
+ eslint: "Bugs",
7078
+ oxc: "Bugs",
7079
+ typescript: "Bugs",
7080
+ unicorn: "Bugs",
7081
+ import: "Performance",
7082
+ promise: "Bugs",
7083
+ n: "Bugs",
7084
+ node: "Bugs",
7085
+ vitest: "Bugs",
7086
+ jest: "Bugs",
7087
+ nextjs: "Bugs"
6255
7088
  };
6256
7089
  const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
6257
7090
  const getRuleRecommendation = (ruleName, project) => {
@@ -6259,7 +7092,16 @@ const getRuleRecommendation = (ruleName, project) => {
6259
7092
  return reactDoctorPlugin.rules[ruleName]?.recommendation;
6260
7093
  };
6261
7094
  const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
7095
+ const getRuleTitle = (ruleName) => reactDoctorPlugin.rules[ruleName]?.title;
7096
+ const resolveDiagnosticTitle = (plugin, rule) => plugin === "react-hooks-js" ? REACT_COMPILER_TITLE : getRuleTitle(rule);
6262
7097
  const cleanDiagnosticMessage = (message, help, plugin, rule, project) => {
7098
+ const cleaned = resolveCleanedDiagnostic(typeof message === "string" ? message : "", typeof help === "string" ? help : "", plugin, rule, project);
7099
+ return {
7100
+ message: redactSensitiveText(cleaned.message),
7101
+ help: redactSensitiveText(cleaned.help)
7102
+ };
7103
+ };
7104
+ const resolveCleanedDiagnostic = (message, help, plugin, rule, project) => {
6263
7105
  if (plugin === "react-hooks-js") return {
6264
7106
  message: REACT_COMPILER_MESSAGE,
6265
7107
  help: appendReanimatedSharedValueHint(message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help, rule, project)
@@ -6280,7 +7122,7 @@ const parseRuleCode = (code) => {
6280
7122
  rule: match[2]
6281
7123
  };
6282
7124
  };
6283
- const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
7125
+ const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Bugs";
6284
7126
  const isOxlintOutput = (value) => {
6285
7127
  if (typeof value !== "object" || value === null) return false;
6286
7128
  const candidate = value;
@@ -6304,7 +7146,16 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
6304
7146
  throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
6305
7147
  }
6306
7148
  if (!isOxlintOutput(parsed)) throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
6307
- return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
7149
+ const minifiedFileCache = /* @__PURE__ */ new Map();
7150
+ const isMinifiedDiagnosticFile = (filename) => {
7151
+ const absolutePath = path.isAbsolute(filename) ? filename : path.resolve(rootDirectory || ".", filename);
7152
+ const cached = minifiedFileCache.get(absolutePath);
7153
+ if (cached !== void 0) return cached;
7154
+ const minified = isMinifiedSource(absolutePath);
7155
+ minifiedFileCache.set(absolutePath, minified);
7156
+ return minified;
7157
+ };
7158
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && isLintableSourceFile(diagnostic.filename) && !isMinifiedDiagnosticFile(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
6308
7159
  const { plugin, rule } = parseRuleCode(diagnostic.code);
6309
7160
  const primaryLabel = diagnostic.labels[0];
6310
7161
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule, project);
@@ -6313,6 +7164,7 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
6313
7164
  plugin,
6314
7165
  rule,
6315
7166
  severity: diagnostic.severity,
7167
+ title: resolveDiagnosticTitle(plugin, rule),
6316
7168
  message: cleaned.message,
6317
7169
  help: cleaned.help,
6318
7170
  url: diagnostic.url,
@@ -6436,6 +7288,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
6436
7288
  */
6437
7289
  const spawnLintBatches = async (input) => {
6438
7290
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
7291
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
6439
7292
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
6440
7293
  const allDiagnostics = [];
6441
7294
  const droppedFiles = [];
@@ -6455,20 +7308,31 @@ const spawnLintBatches = async (input) => {
6455
7308
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
6456
7309
  }
6457
7310
  };
7311
+ let startedFileCount = 0;
6458
7312
  let scannedFileCount = 0;
6459
- for (const batch of fileBatches) {
6460
- let batchFileIndex = 0;
6461
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
6462
- if (batchFileIndex < batch.length) {
6463
- batchFileIndex += 1;
6464
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
7313
+ let displayedFileCount = 0;
7314
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
7315
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
7316
+ if (displayedFileCount < ceiling) {
7317
+ displayedFileCount += 1;
7318
+ onFileProgress(displayedFileCount, totalFileCount);
7319
+ }
7320
+ }, 50) : null;
7321
+ progressTimer?.unref?.();
7322
+ try {
7323
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
7324
+ startedFileCount += batch.length;
7325
+ const batchDiagnostics = await spawnLintBatch(batch);
7326
+ scannedFileCount += batch.length;
7327
+ if (onFileProgress) {
7328
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
7329
+ onFileProgress(displayedFileCount, totalFileCount);
6465
7330
  }
6466
- }, 50) : null;
6467
- const batchDiagnostics = await spawnLintBatch(batch);
6468
- if (progressInterval !== null) clearInterval(progressInterval);
6469
- allDiagnostics.push(...batchDiagnostics);
6470
- scannedFileCount += batch.length;
6471
- onFileProgress?.(scannedFileCount, totalFileCount);
7331
+ return batchDiagnostics;
7332
+ });
7333
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
7334
+ } finally {
7335
+ if (progressTimer !== null) clearInterval(progressTimer);
6472
7336
  }
6473
7337
  if (droppedFiles.length > 0 && onPartialFailure) {
6474
7338
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -6595,7 +7459,8 @@ const runOxlint = async (options) => {
6595
7459
  onPartialFailure,
6596
7460
  onFileProgress: options.onFileProgress,
6597
7461
  spawnTimeoutMs,
6598
- outputMaxBytes
7462
+ outputMaxBytes,
7463
+ concurrency: options.concurrency
6599
7464
  });
6600
7465
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
6601
7466
  try {
@@ -6663,6 +7528,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6663
7528
  const partialFailures = yield* LintPartialFailures;
6664
7529
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
6665
7530
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
7531
+ const concurrency = yield* OxlintConcurrency;
6666
7532
  const collectedFailures = [];
6667
7533
  const diagnostics = yield* Effect.tryPromise({
6668
7534
  try: () => runOxlint({
@@ -6681,7 +7547,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
6681
7547
  },
6682
7548
  onFileProgress: input.onFileProgress,
6683
7549
  spawnTimeoutMs,
6684
- outputMaxBytes
7550
+ outputMaxBytes,
7551
+ concurrency
6685
7552
  }),
6686
7553
  catch: ensureReactDoctorError
6687
7554
  });
@@ -6727,7 +7594,8 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
6727
7594
  static layerNoop = Layer.succeed(Progress, Progress.of({ start: () => Effect.succeed({
6728
7595
  update: () => Effect.void,
6729
7596
  succeed: () => Effect.void,
6730
- fail: () => Effect.void
7597
+ fail: () => Effect.void,
7598
+ stop: () => Effect.void
6731
7599
  }) }));
6732
7600
  static layerCapture = Layer.effect(Progress, Effect.map(ProgressCapture, (events) => Progress.of({ start: (text) => Effect.gen(function* () {
6733
7601
  yield* Ref.update(events, (existing) => [...existing, {
@@ -6746,6 +7614,10 @@ var Progress = class Progress extends Context.Service()("react-doctor/Progress")
6746
7614
  fail: (displayText) => Ref.update(events, (existing) => [...existing, {
6747
7615
  _tag: "Failed",
6748
7616
  text: displayText
7617
+ }]),
7618
+ stop: () => Ref.update(events, (existing) => [...existing, {
7619
+ _tag: "Stopped",
7620
+ text
6749
7621
  }])
6750
7622
  };
6751
7623
  }) }))).pipe(Layer.provideMerge(ProgressCapture.layer));
@@ -6817,17 +7689,21 @@ var Reporter = class Reporter extends Context.Service()("react-doctor/Reporter")
6817
7689
  });
6818
7690
  }));
6819
7691
  };
6820
- const parseScoreResult = (value) => {
6821
- if (typeof value !== "object" || value === null) return null;
6822
- if (!("score" in value) || !("label" in value)) return null;
6823
- const scoreValue = Reflect.get(value, "score");
6824
- const labelValue = Reflect.get(value, "label");
6825
- if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
6826
- return {
6827
- score: scoreValue,
6828
- label: labelValue
6829
- };
6830
- };
7692
+ const RulePrioritySchema = Schema.Struct({
7693
+ priority: Schema.NullOr(Schema.Number),
7694
+ tier: Schema.Literals([
7695
+ "P0",
7696
+ "P1",
7697
+ "P2",
7698
+ "P3"
7699
+ ])
7700
+ });
7701
+ const ScoreApiResponseSchema = Schema.Struct({
7702
+ score: Schema.Number,
7703
+ label: Schema.String,
7704
+ rules: Schema.optional(Schema.Record(Schema.String, RulePrioritySchema))
7705
+ });
7706
+ const parseScoreResult = (value) => Option.getOrNull(Schema.decodeUnknownOption(ScoreApiResponseSchema)(value));
6831
7707
  const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
6832
7708
  const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
6833
7709
  const describeFailure = (error) => {
@@ -6991,18 +7867,25 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
6991
7867
  repo
6992
7868
  }).pipe(Effect.orElseSucceed(() => null)) : Effect.succeed(null));
6993
7869
  const lintIncludePaths = computeJsxIncludePaths([...input.includePaths]) ?? resolveLintIncludePaths(scanDirectory, resolvedConfig.config);
7870
+ const scannedFilePaths = input.suppressScanSummary ? (lintIncludePaths ?? (yield* filesService.listSourceFiles(scanDirectory))).map((relativePath) => path.resolve(scanDirectory, relativePath)) : [];
6994
7871
  const beforeLint = hooks.beforeLint ?? NO_HOOKS.beforeLint;
6995
7872
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
6996
7873
  yield* beforeLint(project, lintIncludePaths ?? void 0);
6997
7874
  const isDiffMode = input.includePaths.length > 0;
7875
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
6998
7876
  const transform = buildDiagnosticPipeline({
6999
7877
  rootDirectory: scanDirectory,
7000
7878
  userConfig: resolvedConfig.config,
7001
7879
  readFileLinesSync: fileReader(filesService, scanDirectory),
7002
- respectInlineDisables: input.respectInlineDisables
7880
+ respectInlineDisables: input.respectInlineDisables,
7881
+ showWarnings
7003
7882
  });
7004
7883
  const applyPerElementPipeline = (rawStream) => rawStream.pipe(Stream.filterMap(filterMapNullable(transform.apply)), Stream.tap((diagnostic) => reporterService.emit(diagnostic)));
7005
- const environmentDiagnostics = isDiffMode ? [] : [...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory)];
7884
+ const environmentDiagnostics = isDiffMode ? [] : [
7885
+ ...checkReducedMotion(scanDirectory),
7886
+ ...checkPnpmHardening(scanDirectory),
7887
+ ...checkExpoProject(scanDirectory, project)
7888
+ ];
7006
7889
  const envCollected = yield* Stream.runCollect(applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)));
7007
7890
  const lintFailure = yield* Ref.make({
7008
7891
  didFail: false,
@@ -7014,6 +7897,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7014
7897
  didFail: false,
7015
7898
  reason: null
7016
7899
  });
7900
+ const scanConcurrency = yield* OxlintConcurrency;
7901
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
7017
7902
  const scanProgress = yield* progressService.start("Scanning...");
7018
7903
  const scanStartTime = Date.now();
7019
7904
  let lastReportedTotalFileCount = 0;
@@ -7030,7 +7915,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7030
7915
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
7031
7916
  onFileProgress: (scannedFileCount, totalFileCount) => {
7032
7917
  lastReportedTotalFileCount = totalFileCount;
7033
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
7918
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
7034
7919
  }
7035
7920
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
7036
7921
  yield* Ref.set(lintFailure, {
@@ -7045,7 +7930,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7045
7930
  const lintFailureState = yield* Ref.get(lintFailure);
7046
7931
  yield* afterLint(lintFailureState.didFail);
7047
7932
  if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
7048
- const shouldRunDeadCode = input.runDeadCode && !isDiffMode;
7933
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
7049
7934
  const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update("Analyzing dead code...").pipe(Effect.andThen(Stream.runCollect(applyPerElementPipeline(deadCodeService.run({
7050
7935
  rootDirectory: scanDirectory,
7051
7936
  userConfig: resolvedConfig.config
@@ -7057,10 +7942,12 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7057
7942
  return Stream.empty;
7058
7943
  }))))))));
7059
7944
  const deadCodeFailureState = yield* Ref.get(deadCodeFailure);
7060
- const scanElapsedSeconds = ((Date.now() - scanStartTime) / 1e3).toFixed(1);
7945
+ const scanElapsedMilliseconds = Date.now() - scanStartTime;
7946
+ const scanElapsedSeconds = (scanElapsedMilliseconds / 1e3).toFixed(1);
7061
7947
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
7062
7948
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
7063
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
7949
+ else if (input.suppressScanSummary) yield* scanProgress.stop();
7950
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
7064
7951
  yield* reporterService.finalize;
7065
7952
  const finalDiagnostics = [
7066
7953
  ...envCollected,
@@ -7100,7 +7987,10 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7100
7987
  lintFailureReasonKind: lintFailureState.reasonKind,
7101
7988
  lintPartialFailures,
7102
7989
  didDeadCodeFail: deadCodeFailureState.didFail,
7103
- deadCodeFailureReason: deadCodeFailureState.reason
7990
+ deadCodeFailureReason: deadCodeFailureState.reason,
7991
+ scannedFileCount: totalFileCount,
7992
+ scannedFilePaths,
7993
+ scanElapsedMilliseconds
7104
7994
  };
7105
7995
  }).pipe(Effect.withSpan("runInspect", { attributes: {
7106
7996
  "inspect.directory": input.directory,
@@ -7109,7 +7999,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
7109
7999
  "inspect.isCi": input.isCi,
7110
8000
  "inspect.scoreSurface": input.scoreSurface ?? "score"
7111
8001
  } }));
7112
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7113
8002
  const parseNodeVersion = (versionString) => {
7114
8003
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
7115
8004
  return {
@@ -7226,7 +8115,7 @@ const isPathInsideDirectory = (childAbsolutePath, parentAbsolutePath) => {
7226
8115
  static layerNode = Layer.effect(StagedFiles, Effect.gen(function* () {
7227
8116
  const git = yield* Git;
7228
8117
  return StagedFiles.of({
7229
- discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter((entry) => SOURCE_FILE_PATTERN.test(entry)))),
8118
+ discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(Effect.map((entries) => entries.filter(isLintableSourceFile))),
7230
8119
  materialize: ({ directory, stagedFiles, tempDirectory }) => Effect.gen(function* () {
7231
8120
  const materializedFiles = [];
7232
8121
  const resolvedTempDirectory = path.resolve(tempDirectory);
@@ -7408,6 +8297,26 @@ const buildJsonReport = (input) => {
7408
8297
  };
7409
8298
  };
7410
8299
  /**
8300
+ * Single source of truth for the skipped-check accounting shared by the
8301
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
8302
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
8303
+ * failed lint / dead-code pass instead of a false "all clear", so the
8304
+ * branch logic lives here once.
8305
+ */
8306
+ const buildSkippedChecks = (input) => {
8307
+ const skippedChecks = [];
8308
+ if (input.didLintFail) skippedChecks.push("lint");
8309
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
8310
+ const skippedCheckReasons = {};
8311
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
8312
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
8313
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
8314
+ return {
8315
+ skippedChecks,
8316
+ skippedCheckReasons
8317
+ };
8318
+ };
8319
+ /**
7411
8320
  * Programmatic façade over `Git.diffSelection`. Async because the
7412
8321
  * Git service runs through Effect's `ChildProcess` (true subprocess
7413
8322
  * spawn, not `spawnSync`).
@@ -7435,7 +8344,7 @@ const getDiffInfo = (directory, explicitBaseBranch) => Effect.runPromise(Effect.
7435
8344
  GitBaseBranchInvalid: (reason) => Effect.die(new Error(reason.detail)),
7436
8345
  GitBaseBranchMissing: (reason) => Effect.die(new Error(reason.message))
7437
8346
  })));
7438
- const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
8347
+ const filterSourceFiles = (filePaths) => filePaths.filter(isLintableSourceFile);
7439
8348
  var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
7440
8349
  let p = process || {}, argv = p.argv || [], env = p.env || {};
7441
8350
  let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
@@ -7513,7 +8422,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
7513
8422
  const clearAutoSuppressionCaches = () => {};
7514
8423
  //#endregion
7515
8424
  //#region ../api/dist/index.js
7516
- const DEFAULT_LAYER = Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
8425
+ const buildDiagnoseLayer = (configLayer = Config.layerNode) => Layer.mergeAll(Project.layerNode, configLayer, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
7517
8426
  const buildInspectProgram = (scanTarget, options, configOverride) => {
7518
8427
  const effectiveConfig = configOverride ?? scanTarget.userConfig;
7519
8428
  const includePaths = options.includePaths ?? [];
@@ -7522,6 +8431,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7522
8431
  includePaths,
7523
8432
  customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
7524
8433
  respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
8434
+ warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
7525
8435
  adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
7526
8436
  ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
7527
8437
  runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
@@ -7531,13 +8441,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
7531
8441
  };
7532
8442
  const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7533
8443
  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;
8444
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
7541
8445
  return {
7542
8446
  diagnostics: [...output.diagnostics],
7543
8447
  score: output.score,
@@ -7549,8 +8453,8 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
7549
8453
  };
7550
8454
  const diagnose = async (directory, options = {}) => {
7551
8455
  const startTime = globalThis.performance.now();
7552
- const program = buildInspectProgram(resolveScanTarget(directory), options);
7553
- return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(DEFAULT_LAYER), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
8456
+ const program = buildInspectProgram(await resolveScanTarget(directory), options);
8457
+ return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
7554
8458
  };
7555
8459
  //#endregion
7556
8460
  //#region src/index.ts
@@ -7559,6 +8463,7 @@ const clearCaches = () => {
7559
8463
  clearConfigCache();
7560
8464
  clearPackageJsonCache();
7561
8465
  clearIgnorePatternsCache();
8466
+ clearPackageRoleCache();
7562
8467
  clearAutoSuppressionCaches();
7563
8468
  };
7564
8469
  const toJsonReport = (result, options) => buildJsonReport({
@@ -7582,4 +8487,5 @@ const toJsonReport = (result, options) => buildJsonReport({
7582
8487
  //#endregion
7583
8488
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
7584
8489
 
7585
- //# sourceMappingURL=index.js.map
8490
+ //# sourceMappingURL=index.js.map
8491
+ //# debugId=fce73b02-d297-5132-af08-817f37e1467c