react-doctor 0.6.3 → 0.7.0-dev.6b21b70

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/lsp.js CHANGED
@@ -8,13 +8,14 @@ import path from "node:path";
8
8
  import * as NodeChildProcess from "node:child_process";
9
9
  import { execFileSync, spawn, spawnSync } from "node:child_process";
10
10
  import * as ts from "typescript";
11
- import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, CROSS_FILE_RULE_IDS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, REACT_DOCTOR_RULES, classifySecurityScanFile, shouldReadSecurityScanContent } from "oxlint-plugin-react-doctor";
11
+ import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, CROSS_FILE_DEPENDENCY_COLLECTORS, CROSS_FILE_RULE_IDS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, REACT_DOCTOR_RULES, classifySecurityScanFile, collectCrossFileDependencyProbes, shouldReadSecurityScanContent } from "oxlint-plugin-react-doctor";
12
12
  import { parseJSON5 } from "confbox";
13
13
  import * as NodeUrl from "node:url";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { createJiti } from "jiti";
16
16
  import * as Crypto from "node:crypto";
17
17
  import crypto, { createHash } from "node:crypto";
18
+ import { ANALYZED_MANIFEST_FILENAMES, DEFAULT_EXTENSIONS } from "deslop-js/analyzed-inputs";
18
19
  import { gzipSync } from "node:zlib";
19
20
  import { CodeActionKind, CodeActionTriggerKind, DidChangeWatchedFilesNotification, DocumentDiagnosticReportKind, FileChangeType, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node.js";
20
21
  import { TextDocument } from "vscode-languageserver-textdocument";
@@ -19409,6 +19410,16 @@ var JsonReportV1 = class extends Class("JsonReportV1")({
19409
19410
  * successful `baseline` run (`schemaVersion: 2`) and on non-compare scopes.
19410
19411
  */
19411
19412
  baselineDegraded: optional(Boolean$1),
19413
+ /**
19414
+ * Whether any scanned project resolved a React-compatible runtime (React
19415
+ * or Preact). `false` means every React-runtime rule family was gated off,
19416
+ * so an empty `diagnostics` array is vacuous — NOT the same as a clean
19417
+ * React scan. Consumers gating on the report (CI, verifiers, hooks) should
19418
+ * treat `reactDetected === false` as "wrong scan target", not "all clear".
19419
+ * Absent when nothing was scanned (`projects` is empty), on error reports,
19420
+ * and on reports from older CLI versions.
19421
+ */
19422
+ reactDetected: optional(Boolean$1),
19412
19423
  diff: NullOr(JsonReportDiffInfo),
19413
19424
  projects: ArraySchema(JsonReportProjectEntry),
19414
19425
  diagnostics: ArraySchema(Diagnostic),
@@ -19434,6 +19445,8 @@ var JsonReportV2 = class extends Class("JsonReportV2")({
19434
19445
  ok: Boolean$1,
19435
19446
  directory: String$1,
19436
19447
  mode: JsonReportMode,
19448
+ /** See `JsonReportV1.reactDetected`. */
19449
+ reactDetected: optional(Boolean$1),
19437
19450
  diff: NullOr(JsonReportDiffInfo),
19438
19451
  baseline: JsonReportBaseline,
19439
19452
  projects: ArraySchema(JsonReportProjectEntry),
@@ -32986,10 +32999,15 @@ const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
32986
32999
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
32987
33000
  const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
32988
33001
  const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
33002
+ const SIDECAR_LINT_CACHE_FILENAME = "sidecar-lint-cache.json";
33003
+ const CORE_PACKAGE_VERSION = "0.7.0";
33004
+ const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json";
33005
+ const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json";
32989
33006
  const SUPPLY_CHAIN_PLUGIN = "socket";
32990
33007
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
32991
33008
  const SUPPLY_CHAIN_CATEGORY = "Security";
32992
33009
  const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
33010
+ const SUPPLY_CHAIN_CACHE_TTL_MS = 864e5;
32993
33011
  const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
32994
33012
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
32995
33013
  const hasIgnoredPathSegment = (relativePath) => relativePath.split(/[/\\]/).slice(0, -1).some((segment) => IGNORED_DIRECTORIES.has(segment));
@@ -35646,6 +35664,48 @@ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintC
35646
35664
  if (CACHE_DISABLED_VALUES.has(noFileCache)) return false;
35647
35665
  return true;
35648
35666
  } }) {};
35667
+ /**
35668
+ * Whether the sidecar lint cache (`runners/oxlint/sidecar-lint-cache.ts`) is
35669
+ * active. Defaults ON — warm rescans replay the cross-file rules' cached
35670
+ * diagnostics for every file whose dependency fingerprint (the probe set the
35671
+ * plugin's collectors recorded) still matches the tree, and re-lint only the
35672
+ * rest. Only reachable when the per-file lint cache itself is on (the
35673
+ * sidecar covers its cache hits), so `REACT_DOCTOR_NO_CACHE` /
35674
+ * `REACT_DOCTOR_NO_FILE_CACHE` implicitly disable it too. Opt-OUT knobs:
35675
+ *
35676
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
35677
+ * - `REACT_DOCTOR_NO_SIDECAR_CACHE` — granular rollback hatch: keep the
35678
+ * per-file cache but run the always-fresh sidecar over every hit
35679
+ * (the pre-cache behavior).
35680
+ *
35681
+ * Tests override via `Layer.succeed(SidecarLintCacheEnabled, false)`.
35682
+ */
35683
+ var SidecarLintCacheEnabled = class extends Reference("react-doctor/SidecarLintCacheEnabled", { defaultValue: () => {
35684
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35685
+ const noSidecarCache = process.env["REACT_DOCTOR_NO_SIDECAR_CACHE"]?.toLowerCase() ?? "";
35686
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35687
+ if (CACHE_DISABLED_VALUES.has(noSidecarCache)) return false;
35688
+ return true;
35689
+ } }) {};
35690
+ /**
35691
+ * Whether the whole-project dead-code result cache
35692
+ * (`dead-code/dead-code-result-cache.ts`) is active. Defaults ON — a rescan
35693
+ * whose inputs (source tree, manifests, configs, analyzer version) are
35694
+ * unchanged replays the stored diagnostics instead of re-running the
35695
+ * analysis worker. Opt-OUT, two knobs (matching the per-file lint cache):
35696
+ *
35697
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
35698
+ * - `REACT_DOCTOR_NO_DEAD_CODE_CACHE` — granular: bust only this cache.
35699
+ *
35700
+ * Tests override via `Layer.succeed(DeadCodeResultCacheEnabled, false)`.
35701
+ */
35702
+ var DeadCodeResultCacheEnabled = class extends Reference("react-doctor/DeadCodeResultCacheEnabled", { defaultValue: () => {
35703
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35704
+ const noDeadCodeCache = process.env["REACT_DOCTOR_NO_DEAD_CODE_CACHE"]?.toLowerCase() ?? "";
35705
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35706
+ if (CACHE_DISABLED_VALUES.has(noDeadCodeCache)) return false;
35707
+ return true;
35708
+ } }) {};
35649
35709
  const DIAGNOSTIC_SURFACES = [
35650
35710
  "cli",
35651
35711
  "prComment",
@@ -36947,10 +37007,17 @@ function* collectSecurityScanFiles(rootDirectory) {
36947
37007
  }
36948
37008
  }
36949
37009
  }
37010
+ /**
37011
+ * Whether project discovery resolved a React-compatible runtime (React or
37012
+ * Preact, which ships the same hooks + JSX model). This is the same
37013
+ * predicate that grants the `react` capability, so `false` means every
37014
+ * React-runtime rule family was gated off for the project.
37015
+ */
37016
+ const hasReactRuntime = (project) => project.reactVersion !== null || project.preactVersion !== null;
36950
37017
  const buildCapabilities = (project) => {
36951
37018
  const capabilities = /* @__PURE__ */ new Set();
36952
37019
  capabilities.add(project.framework);
36953
- if (project.reactVersion !== null || project.preactVersion !== null) capabilities.add("react");
37020
+ if (hasReactRuntime(project)) capabilities.add("react");
36954
37021
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
36955
37022
  if (project.expoVersion !== null) capabilities.add("expo");
36956
37023
  if (project.nextjsMajorVersion !== null && project.nextjsMajorVersion >= 15) capabilities.add("nextjs:15");
@@ -37286,6 +37353,146 @@ const collectDeadCodeIgnorePatterns = (rootDirectory) => {
37286
37353
  return [...seen].filter((pattern) => pattern.length > 0);
37287
37354
  };
37288
37355
  const collectDeadCodeEntryPatterns = (rootDirectory) => [...new Set(collectKnipPatterns(rootDirectory, "entry"))].filter((pattern) => pattern.length > 0);
37356
+ const atomicWriteJson = (filePath, value) => {
37357
+ try {
37358
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
37359
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
37360
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
37361
+ NFS.renameSync(temporaryPath, filePath);
37362
+ } catch {
37363
+ return;
37364
+ }
37365
+ };
37366
+ const failOpenReadJson = (filePath, fallback) => {
37367
+ try {
37368
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
37369
+ } catch {
37370
+ return fallback;
37371
+ }
37372
+ };
37373
+ const hashFileContents = (filePath) => {
37374
+ try {
37375
+ return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
37376
+ } catch {
37377
+ return null;
37378
+ }
37379
+ };
37380
+ const ANALYZED_FILE_EXTENSIONS = new Set(DEFAULT_EXTENSIONS);
37381
+ const CORE_SIDE_MANIFEST_NAMES = ["knip.json", "deno.lock"];
37382
+ const ANALYZED_MANIFEST_NAMES = new Set([...ANALYZED_MANIFEST_FILENAMES, ...CORE_SIDE_MANIFEST_NAMES]);
37383
+ const isTsConfigLikeFile = (fileName) => (fileName.startsWith("tsconfig") || fileName.startsWith("jsconfig")) && fileName.endsWith(".json");
37384
+ const isFingerprintedFile = (fileName) => ANALYZED_FILE_EXTENSIONS.has(Path.extname(fileName).toLowerCase()) || ANALYZED_MANIFEST_NAMES.has(fileName) || isTsConfigLikeFile(fileName);
37385
+ /**
37386
+ * Stat snapshot of every file the analysis reads, keyed by root-relative
37387
+ * `/`-separated path. Taken BEFORE the (long) analysis so a stored result is
37388
+ * verified against the tree it started from.
37389
+ */
37390
+ const collectAnalyzedFileStats = (rootDirectory) => {
37391
+ const statByRelativePath = /* @__PURE__ */ new Map();
37392
+ for (const { absolutePath, name } of walkSourceTreeFiles(rootDirectory)) {
37393
+ if (!isFingerprintedFile(name)) continue;
37394
+ try {
37395
+ const fileStat = NFS.statSync(absolutePath);
37396
+ const relativePath = Path.relative(rootDirectory, absolutePath).replace(/\\/g, "/");
37397
+ statByRelativePath.set(relativePath, {
37398
+ mtimeMs: fileStat.mtimeMs,
37399
+ size: fileStat.size
37400
+ });
37401
+ } catch {}
37402
+ }
37403
+ return statByRelativePath;
37404
+ };
37405
+ const bundledRequire$2 = createRequire(import.meta.url);
37406
+ const resolveDeslopVersion = () => {
37407
+ try {
37408
+ const packageJson = JSON.parse(NFS.readFileSync(bundledRequire$2.resolve("deslop-js/package.json"), "utf8"));
37409
+ return isRecord(packageJson) && typeof packageJson.version === "string" ? packageJson.version : "unknown";
37410
+ } catch {
37411
+ return "unknown";
37412
+ }
37413
+ };
37414
+ const computeDeadCodeCacheKey = (input) => crypto.createHash("sha1").update(JSON.stringify({
37415
+ schemaVersion: 2,
37416
+ coreVersion: input.coreVersion,
37417
+ deslopVersion: resolveDeslopVersion(),
37418
+ deslopJsModuleSpecifier: input.deslopJsModuleSpecifier,
37419
+ entryPatterns: input.entryPatterns,
37420
+ ignorePatterns: input.ignorePatterns,
37421
+ tsConfigFile: input.tsConfigPath === void 0 ? null : Path.relative(input.rootDirectory, input.tsConfigPath).replace(/\\/g, "/")
37422
+ })).digest("hex");
37423
+ const validateDiagnostic$2 = decodeUnknownSync(Diagnostic);
37424
+ const decodeCachedDiagnostics = (raw) => {
37425
+ try {
37426
+ for (const entry of raw) validateDiagnostic$2(entry);
37427
+ return raw;
37428
+ } catch {
37429
+ return null;
37430
+ }
37431
+ };
37432
+ const isPersistedFileIdentity = (value) => Array.isArray(value) && value.length === 3 && typeof value[0] === "number" && typeof value[1] === "number" && typeof value[2] === "string";
37433
+ const lookupDeadCodeResultCache = (input) => {
37434
+ const cacheFilePath = Path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME);
37435
+ const persisted = failOpenReadJson(cacheFilePath, null);
37436
+ if (persisted === null || !isRecord(persisted) || persisted.version !== 2 || persisted.key !== input.cacheKey || !isRecord(persisted.files) || !Array.isArray(persisted.diagnostics)) return null;
37437
+ const storedFileEntries = Object.entries(persisted.files);
37438
+ if (storedFileEntries.length !== input.currentFileStats.size) return null;
37439
+ const repairedFiles = {};
37440
+ let repairedCount = 0;
37441
+ for (const [relativePath, storedIdentity] of storedFileEntries) {
37442
+ if (!isPersistedFileIdentity(storedIdentity)) return null;
37443
+ const currentStat = input.currentFileStats.get(relativePath);
37444
+ if (currentStat === void 0) return null;
37445
+ const [storedMtimeMs, storedSize, storedContentHash] = storedIdentity;
37446
+ if (currentStat.mtimeMs === storedMtimeMs && currentStat.size === storedSize) {
37447
+ repairedFiles[relativePath] = storedIdentity;
37448
+ continue;
37449
+ }
37450
+ if (currentStat.size !== storedSize) return null;
37451
+ const currentContentHash = hashFileContents(Path.join(input.rootDirectory, relativePath));
37452
+ if (currentContentHash === null || currentContentHash !== storedContentHash) return null;
37453
+ repairedFiles[relativePath] = [
37454
+ currentStat.mtimeMs,
37455
+ currentStat.size,
37456
+ storedContentHash
37457
+ ];
37458
+ repairedCount += 1;
37459
+ }
37460
+ const diagnostics = decodeCachedDiagnostics(persisted.diagnostics);
37461
+ if (diagnostics === null) return null;
37462
+ if (repairedCount > 0) atomicWriteJson(cacheFilePath, {
37463
+ version: 2,
37464
+ key: input.cacheKey,
37465
+ files: repairedFiles,
37466
+ diagnostics: persisted.diagnostics
37467
+ });
37468
+ return diagnostics;
37469
+ };
37470
+ const storeDeadCodeResultCache = (input) => {
37471
+ const persistedFiles = {};
37472
+ for (const [relativePath, snapshotStat] of input.snapshotFileStats) {
37473
+ const absolutePath = Path.join(input.rootDirectory, relativePath);
37474
+ const contentHash = hashFileContents(absolutePath);
37475
+ if (contentHash === null) return;
37476
+ let currentStat;
37477
+ try {
37478
+ currentStat = NFS.statSync(absolutePath);
37479
+ } catch {
37480
+ return;
37481
+ }
37482
+ if (currentStat.mtimeMs !== snapshotStat.mtimeMs || currentStat.size !== snapshotStat.size) return;
37483
+ persistedFiles[relativePath] = [
37484
+ snapshotStat.mtimeMs,
37485
+ snapshotStat.size,
37486
+ contentHash
37487
+ ];
37488
+ }
37489
+ atomicWriteJson(Path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME), {
37490
+ version: 2,
37491
+ key: input.cacheKey,
37492
+ files: persistedFiles,
37493
+ diagnostics: input.diagnostics
37494
+ });
37495
+ };
37289
37496
  const readSystemFacts = () => ({
37290
37497
  availableCores: os.availableParallelism(),
37291
37498
  totalMemoryBytes: os.totalmem(),
@@ -37354,6 +37561,21 @@ const withDeadCodeWorkerSlot = async (task, abortSignal) => {
37354
37561
  releaseSlot();
37355
37562
  }
37356
37563
  };
37564
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
37565
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
37566
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
37567
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
37568
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
37569
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
37570
+ return Path.join(os.tmpdir(), `react-doctor-cache-${resolveUserCacheScope()}`, projectCacheSubdir(projectDirectory));
37571
+ };
37572
+ const resolveUserCacheScope = () => {
37573
+ try {
37574
+ return String(process.getuid?.() ?? os.userInfo().username);
37575
+ } catch {
37576
+ return "shared";
37577
+ }
37578
+ };
37357
37579
  /**
37358
37580
  * Resolves a path to its canonical, symlink-free form, falling back to
37359
37581
  * the input when it cannot be realpath'd (broken symlink, permission
@@ -37407,6 +37629,14 @@ process.stdin.on("end", () => {
37407
37629
  circularDependencies: result.circularDependencies.map((cycle) => ({
37408
37630
  files: cycle.files,
37409
37631
  })),
37632
+ ...(result.incrementalCacheStats
37633
+ ? {
37634
+ summaryCacheStats: {
37635
+ hits: result.incrementalCacheStats.summaryHits,
37636
+ misses: result.incrementalCacheStats.summaryMisses,
37637
+ },
37638
+ }
37639
+ : {}),
37410
37640
  });
37411
37641
 
37412
37642
  const serializeError = (error) =>
@@ -37430,6 +37660,9 @@ process.stdin.on("end", () => {
37430
37660
  ...(workerInput.ignorePatterns.length > 0
37431
37661
  ? { ignorePatterns: workerInput.ignorePatterns }
37432
37662
  : {}),
37663
+ ...(workerInput.incrementalCachePath
37664
+ ? { incrementalCachePath: workerInput.incrementalCachePath }
37665
+ : {}),
37433
37666
  // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
37434
37667
  // unusedDependencies, circularDependencies). Everything else deslop can compute
37435
37668
  // is pure wasted work for us, and it's the bulk of the runtime:
@@ -37440,12 +37673,18 @@ process.stdin.on("end", () => {
37440
37673
  // are the single most expensive pass — duplicate-block detection alone was
37441
37674
  // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
37442
37675
  // speedup on a large repo.
37443
- // Both are provably safe: the consumed graph findings are computed by their own
37676
+ // - reportRedundancy: the DRY-pattern detectors (duplicate types/constants,
37677
+ // simplifiable functions, identity wrappers, …) — ~120 ms of discarded
37678
+ // output per scan, and skipping them lets the incremental cache drop the
37679
+ // DRY-pattern summary fields (the largest slice of the cache file).
37680
+ // All are provably safe: the consumed graph findings are computed by their own
37444
37681
  // detectors, independent of these passes (confirmed byte-identical on
37445
- // excalidraw + mui-material + sentry). tsConfigPath stays — the module resolver
37446
- // needs it for path-alias resolution in the import graph.
37682
+ // excalidraw + mui-material + sentry; re-verified on sentry after the
37683
+ // reportRedundancy flip). tsConfigPath stays — the module resolver needs it
37684
+ // for path-alias resolution in the import graph.
37447
37685
  semantic: { enabled: false },
37448
37686
  reportCodeQuality: false,
37687
+ reportRedundancy: false,
37449
37688
  };
37450
37689
  const result = await analyze(defineConfig(config));
37451
37690
  emit({ ok: true, result: normalizeResult(result) });
@@ -37529,13 +37768,23 @@ const parseCircularDependencies = (value) => {
37529
37768
  }
37530
37769
  return circularDependencies;
37531
37770
  };
37771
+ const parseSummaryCacheStats = (value) => {
37772
+ if (!isRecord(value)) return void 0;
37773
+ if (typeof value.hits !== "number" || typeof value.misses !== "number") return void 0;
37774
+ return {
37775
+ hits: value.hits,
37776
+ misses: value.misses
37777
+ };
37778
+ };
37532
37779
  const parseDeadCodeWorkerResult = (value) => {
37533
37780
  if (!isRecord(value)) throw new Error("Dead-code worker returned an invalid result.");
37781
+ const summaryCacheStats = parseSummaryCacheStats(value.summaryCacheStats);
37534
37782
  return {
37535
37783
  unusedFiles: parseUnusedFiles(value.unusedFiles),
37536
37784
  unusedExports: parseUnusedExports(value.unusedExports),
37537
37785
  unusedDependencies: parseUnusedDependencies(value.unusedDependencies),
37538
- circularDependencies: parseCircularDependencies(value.circularDependencies)
37786
+ circularDependencies: parseCircularDependencies(value.circularDependencies),
37787
+ ...summaryCacheStats ? { summaryCacheStats } : {}
37539
37788
  };
37540
37789
  };
37541
37790
  const parseDeadCodeWorkerError = (value) => {
@@ -37649,17 +37898,41 @@ const checkDeadCode = async (options) => {
37649
37898
  if (!NFS.existsSync(Path.join(rootDirectory, "package.json"))) return [];
37650
37899
  const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
37651
37900
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory);
37901
+ const tsConfigPath = resolveTsConfigPath(rootDirectory);
37902
+ const deslopJsModuleSpecifier = options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js");
37903
+ const fileStatsSnapshot = options.cacheEnabled ? collectAnalyzedFileStats(rootDirectory) : null;
37904
+ const cacheKey = fileStatsSnapshot === null ? null : computeDeadCodeCacheKey({
37905
+ rootDirectory,
37906
+ entryPatterns,
37907
+ ignorePatterns,
37908
+ tsConfigPath,
37909
+ deslopJsModuleSpecifier,
37910
+ coreVersion: CORE_PACKAGE_VERSION
37911
+ });
37912
+ if (cacheKey !== null && fileStatsSnapshot !== null) {
37913
+ const cachedDiagnostics = lookupDeadCodeResultCache({
37914
+ cacheDirectory: resolveReactDoctorCacheDir(rootDirectory),
37915
+ cacheKey,
37916
+ rootDirectory,
37917
+ currentFileStats: fileStatsSnapshot
37918
+ });
37919
+ options.onCacheOutcome?.(cachedDiagnostics !== null);
37920
+ if (cachedDiagnostics !== null) return [...cachedDiagnostics];
37921
+ }
37922
+ const incrementalCachePath = options.cacheEnabled === true ? Path.join(resolveReactDoctorCacheDir(rootDirectory), DEAD_CODE_SUMMARY_CACHE_FILENAME) : void 0;
37652
37923
  const spawnAndRun = () => {
37653
37924
  return runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
37654
37925
  rootDirectory,
37655
37926
  entryPatterns,
37656
- tsConfigPath: resolveTsConfigPath(rootDirectory),
37927
+ tsConfigPath,
37657
37928
  ignorePatterns,
37658
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
37659
- parseConcurrency: options.parseConcurrency
37929
+ deslopJsModuleSpecifier,
37930
+ parseConcurrency: options.parseConcurrency,
37931
+ incrementalCachePath
37660
37932
  }), options.workerTimeoutMs ?? 12e4, options.abortSignal);
37661
37933
  };
37662
37934
  const result = parseDeadCodeWorkerResult(options.createWorker === void 0 ? await withDeadCodeWorkerSlot(spawnAndRun, options.abortSignal) : await spawnAndRun());
37935
+ if (result.summaryCacheStats !== void 0) options.onSummaryCacheStats?.(result.summaryCacheStats);
37663
37936
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
37664
37937
  const diagnostics = [];
37665
37938
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -37716,6 +37989,13 @@ const checkDeadCode = async (options) => {
37716
37989
  category: DEAD_CODE_CATEGORY
37717
37990
  });
37718
37991
  }
37992
+ if (cacheKey !== null && fileStatsSnapshot !== null) storeDeadCodeResultCache({
37993
+ cacheDirectory: resolveReactDoctorCacheDir(rootDirectory),
37994
+ cacheKey,
37995
+ rootDirectory,
37996
+ snapshotFileStats: fileStatsSnapshot,
37997
+ diagnostics
37998
+ });
37719
37999
  return diagnostics;
37720
38000
  };
37721
38001
  const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
@@ -37818,12 +38098,16 @@ var Config = class Config extends Service()("react-doctor/Config") {
37818
38098
  */
37819
38099
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
37820
38100
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
38101
+ const cacheEnabled = yield* DeadCodeResultCacheEnabled;
37821
38102
  return yield* tryPromise({
37822
38103
  try: (signal) => checkDeadCode({
37823
38104
  rootDirectory: input.rootDirectory,
37824
38105
  parseConcurrency: input.parseConcurrency,
37825
38106
  workerTimeoutMs: input.workerTimeoutMs,
37826
- abortSignal: signal
38107
+ abortSignal: signal,
38108
+ cacheEnabled,
38109
+ onCacheOutcome: input.onCacheOutcome,
38110
+ onSummaryCacheStats: input.onSummaryCacheStats
37827
38111
  }),
37828
38112
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
37829
38113
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -38693,7 +38977,7 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
38693
38977
  }
38694
38978
  return enabled;
38695
38979
  };
38696
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection }) => {
38980
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection, sidecarRuleIdFilter }) => {
38697
38981
  const reactHooksJsPlugin = disableReactHooksJsPlugin || ruleSelection === "sidecar" ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
38698
38982
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
38699
38983
  const jsPlugins = [];
@@ -38705,6 +38989,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38705
38989
  if (!rule) continue;
38706
38990
  if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38707
38991
  if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38992
+ if (ruleSelection === "sidecar" && sidecarRuleIdFilter !== void 0 && !sidecarRuleIdFilter.has(registryEntry.id)) continue;
38708
38993
  if (rule.scan !== void 0) continue;
38709
38994
  if (customRulesOnly && registryEntry.originallyExternal) continue;
38710
38995
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -38749,33 +39034,145 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38749
39034
  }
38750
39035
  };
38751
39036
  };
38752
- const atomicWriteJson = (filePath, value) => {
39037
+ const validateDiagnostic$1 = decodeUnknownSync(Diagnostic);
39038
+ const decodeFileDiagnostics = (raw) => {
39039
+ if (!Array.isArray(raw)) return null;
38753
39040
  try {
38754
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
38755
- const temporaryPath = `${filePath}.${process.pid}.tmp`;
38756
- NFS.writeFileSync(temporaryPath, JSON.stringify(value));
38757
- NFS.renameSync(temporaryPath, filePath);
39041
+ for (const entry of raw) validateDiagnostic$1(entry);
39042
+ return raw;
38758
39043
  } catch {
38759
- return;
39044
+ return null;
38760
39045
  }
38761
39046
  };
38762
- const failOpenReadJson = (filePath, fallback) => {
38763
- try {
38764
- return JSON.parse(NFS.readFileSync(filePath, "utf8"));
38765
- } catch {
38766
- return fallback;
39047
+ const emptyCache$1 = () => ({
39048
+ version: 1,
39049
+ rulesets: {}
39050
+ });
39051
+ const loadRulesetEntries$1 = (cacheFilePath, rulesetHash) => {
39052
+ const entries = /* @__PURE__ */ new Map();
39053
+ const persisted = failOpenReadJson(cacheFilePath, emptyCache$1());
39054
+ if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
39055
+ const bucket = persisted.rulesets[rulesetHash];
39056
+ if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
39057
+ for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
39058
+ const decoded = decodeFileDiagnostics(rawDiagnostics);
39059
+ if (decoded !== null) entries.set(fileKey, decoded);
38767
39060
  }
39061
+ return entries;
39062
+ };
39063
+ const createFileLintCache = (cacheDirectory, rulesetHash) => {
39064
+ const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
39065
+ const entries = loadRulesetEntries$1(cacheFilePath, rulesetHash);
39066
+ return {
39067
+ lookup: (fileKey) => entries.get(fileKey) ?? null,
39068
+ store: (fileKey, diagnostics) => {
39069
+ entries.delete(fileKey);
39070
+ entries.set(fileKey, diagnostics);
39071
+ },
39072
+ persist: () => {
39073
+ const onDisk = failOpenReadJson(cacheFilePath, emptyCache$1());
39074
+ const rulesets = onDisk.version === 1 && isRecord(onDisk.rulesets) ? { ...onDisk.rulesets } : {};
39075
+ const existingBucket = rulesets[rulesetHash];
39076
+ const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
39077
+ const ourFiles = {};
39078
+ for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
39079
+ const cappedEntries = Object.entries({
39080
+ ...existingFiles,
39081
+ ...ourFiles
39082
+ }).slice(-FILE_LINT_CACHE_MAX_FILE_COUNT);
39083
+ rulesets[rulesetHash] = {
39084
+ updatedAtMs: Date.now(),
39085
+ files: Object.fromEntries(cappedEntries)
39086
+ };
39087
+ atomicWriteJson(cacheFilePath, {
39088
+ version: 1,
39089
+ rulesets: Object.fromEntries(Object.entries(rulesets).filter(([, bucket]) => isRecord(bucket) && typeof bucket.updatedAtMs === "number").sort(([, first], [, second]) => second.updatedAtMs - first.updatedAtMs).slice(0, 8))
39090
+ });
39091
+ }
39092
+ };
39093
+ };
39094
+ const ABSENT_CONTENT_ANSWER = "absent";
39095
+ /**
39096
+ * Answers dependency probes for the sidecar lint cache — used both when
39097
+ * STORING an entry (turn the collector's probe paths into answers) and when
39098
+ * VERIFYING one (re-answer each stored probe against the current tree).
39099
+ * Store and verify must agree exactly, so both go through this one resolver.
39100
+ *
39101
+ * - `content` — SHA-1 of the file's bytes, `"absent"` when unreadable
39102
+ * (missing, a directory, permission failure). Source files reuse the
39103
+ * hashes the cache-partition loop already computed
39104
+ * (`contentHashByRelativePath`); everything else (package.json,
39105
+ * tsconfig, ignored files) is hashed on demand.
39106
+ * - `exists` — the existence class a module-resolution candidate probe
39107
+ * sees: `"file"`, `"dir"`, or `"none"` (missing or an exotic entry —
39108
+ * `statSync` follows symlinks, matching the resolvers).
39109
+ *
39110
+ * Answers are memoized per scan: the filesystem is frozen for the run, and
39111
+ * hot dependency paths (a barrel imported by hundreds of files, the shared
39112
+ * tsconfig chain) repeat across many files' probe sets.
39113
+ */
39114
+ const createSidecarProbeAnswerResolver = (input) => {
39115
+ const contentAnswerByPath = /* @__PURE__ */ new Map();
39116
+ const existsAnswerByPath = /* @__PURE__ */ new Map();
39117
+ const toRelativePath = (absolutePath) => Path.relative(input.rootDirectory, absolutePath).replaceAll("\\", "/");
39118
+ const contentAnswer = (relativePath) => {
39119
+ const memoized = contentAnswerByPath.get(relativePath);
39120
+ if (memoized !== void 0) return memoized;
39121
+ const answer = input.contentHashByRelativePath.get(relativePath) ?? hashFileContents(Path.resolve(input.rootDirectory, relativePath)) ?? ABSENT_CONTENT_ANSWER;
39122
+ contentAnswerByPath.set(relativePath, answer);
39123
+ return answer;
39124
+ };
39125
+ const existsAnswer = (relativePath) => {
39126
+ const memoized = existsAnswerByPath.get(relativePath);
39127
+ if (memoized !== void 0) return memoized;
39128
+ let answer;
39129
+ try {
39130
+ const stat = NFS.statSync(Path.resolve(input.rootDirectory, relativePath));
39131
+ answer = stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "none";
39132
+ } catch {
39133
+ answer = "none";
39134
+ }
39135
+ existsAnswerByPath.set(relativePath, answer);
39136
+ return answer;
39137
+ };
39138
+ return {
39139
+ answerFor: (kind, relativePath) => kind === "content" ? contentAnswer(relativePath) : existsAnswer(relativePath),
39140
+ toRelativePath
39141
+ };
38768
39142
  };
38769
39143
  const validateDiagnostic = decodeUnknownSync(Diagnostic);
38770
- const decodeFileDiagnostics = (raw) => {
38771
- if (!Array.isArray(raw)) return null;
39144
+ const isProbeKind = (value) => value === "content" || value === "exists";
39145
+ const decodeSidecarFileEntry = (raw) => {
39146
+ if (!isRecord(raw) || !Array.isArray(raw.probes) || !Array.isArray(raw.diagnostics)) return null;
39147
+ const probes = [];
39148
+ for (const rawProbe of raw.probes) {
39149
+ if (!Array.isArray(rawProbe) || rawProbe.length !== 3) return null;
39150
+ const [kind, probePath, answer] = rawProbe;
39151
+ if (!isProbeKind(kind) || typeof probePath !== "string" || typeof answer !== "string") return null;
39152
+ probes.push({
39153
+ kind,
39154
+ path: probePath,
39155
+ answer
39156
+ });
39157
+ }
38772
39158
  try {
38773
- for (const entry of raw) validateDiagnostic(entry);
38774
- return raw;
39159
+ for (const entry of raw.diagnostics) validateDiagnostic(entry);
38775
39160
  } catch {
38776
39161
  return null;
38777
39162
  }
39163
+ return {
39164
+ probes,
39165
+ diagnostics: raw.diagnostics
39166
+ };
38778
39167
  };
39168
+ const encodeSidecarFileEntry = (entry) => ({
39169
+ probes: entry.probes.map((probe) => [
39170
+ probe.kind,
39171
+ probe.path,
39172
+ probe.answer
39173
+ ]),
39174
+ diagnostics: entry.diagnostics
39175
+ });
38779
39176
  const emptyCache = () => ({
38780
39177
  version: 1,
38781
39178
  rulesets: {}
@@ -38786,20 +39183,20 @@ const loadRulesetEntries = (cacheFilePath, rulesetHash) => {
38786
39183
  if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
38787
39184
  const bucket = persisted.rulesets[rulesetHash];
38788
39185
  if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
38789
- for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
38790
- const decoded = decodeFileDiagnostics(rawDiagnostics);
39186
+ for (const [fileKey, rawEntry] of Object.entries(bucket.files)) {
39187
+ const decoded = decodeSidecarFileEntry(rawEntry);
38791
39188
  if (decoded !== null) entries.set(fileKey, decoded);
38792
39189
  }
38793
39190
  return entries;
38794
39191
  };
38795
- const createFileLintCache = (cacheDirectory, rulesetHash) => {
38796
- const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
39192
+ const createSidecarLintCache = (cacheDirectory, rulesetHash) => {
39193
+ const cacheFilePath = Path.join(cacheDirectory, SIDECAR_LINT_CACHE_FILENAME);
38797
39194
  const entries = loadRulesetEntries(cacheFilePath, rulesetHash);
38798
39195
  return {
38799
39196
  lookup: (fileKey) => entries.get(fileKey) ?? null,
38800
- store: (fileKey, diagnostics) => {
39197
+ store: (fileKey, entry) => {
38801
39198
  entries.delete(fileKey);
38802
- entries.set(fileKey, diagnostics);
39199
+ entries.set(fileKey, entry);
38803
39200
  },
38804
39201
  persist: () => {
38805
39202
  const onDisk = failOpenReadJson(cacheFilePath, emptyCache());
@@ -38807,7 +39204,7 @@ const createFileLintCache = (cacheDirectory, rulesetHash) => {
38807
39204
  const existingBucket = rulesets[rulesetHash];
38808
39205
  const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
38809
39206
  const ourFiles = {};
38810
- for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
39207
+ for (const [fileKey, entry] of entries) ourFiles[fileKey] = encodeSidecarFileEntry(entry);
38811
39208
  const cappedEntries = Object.entries({
38812
39209
  ...existingFiles,
38813
39210
  ...ourFiles
@@ -39822,13 +40219,6 @@ const validateRuleRegistration = () => {
39822
40219
  ].filter((entry) => entry !== null).join("; ");
39823
40220
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
39824
40221
  };
39825
- const hashFileContents = (filePath) => {
39826
- try {
39827
- return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
39828
- } catch {
39829
- return null;
39830
- }
39831
- };
39832
40222
  /**
39833
40223
  * Balanced LPT (longest-processing-time-first) lint batch planner. Where
39834
40224
  * `batchIncludePaths` greedily fills 100-file batches and leaves a small
@@ -39893,21 +40283,6 @@ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
39893
40283
  }
39894
40284
  return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
39895
40285
  };
39896
- const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
39897
- const resolveReactDoctorCacheDir = (projectDirectory) => {
39898
- const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
39899
- if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
39900
- const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
39901
- if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
39902
- return Path.join(os.tmpdir(), `react-doctor-cache-${resolveUserCacheScope()}`, projectCacheSubdir(projectDirectory));
39903
- };
39904
- const resolveUserCacheScope = () => {
39905
- try {
39906
- return String(process.getuid?.() ?? os.userInfo().username);
39907
- } catch {
39908
- return "shared";
39909
- }
39910
- };
39911
40286
  /**
39912
40287
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39913
40288
  * the runner: once for the primary scan, once for the
@@ -39925,6 +40300,106 @@ const writeOxlintConfig = (configPath, configToWrite) => {
39925
40300
  NFS.closeSync(fileHandle);
39926
40301
  }
39927
40302
  };
40303
+ /**
40304
+ * Attributes diagnostics back to the file that produced them by the
40305
+ * normalized path oxlint echoes. Returns `null` when ANY diagnostic can't be
40306
+ * attributed — the path forms don't align, so the caller must skip its cache
40307
+ * store rather than risk caching a wrong empty result for a file that
40308
+ * actually had diagnostics.
40309
+ */
40310
+ const attributeDiagnosticsToFiles = (diagnostics, files) => {
40311
+ const fileByNormalizedPath = /* @__PURE__ */ new Map();
40312
+ for (const file of files) fileByNormalizedPath.set(file.replaceAll("\\", "/"), file);
40313
+ const diagnosticsByFile = /* @__PURE__ */ new Map();
40314
+ for (const diagnostic of diagnostics) {
40315
+ const file = fileByNormalizedPath.get(diagnostic.filePath);
40316
+ if (file === void 0) return null;
40317
+ const fileDiagnostics = diagnosticsByFile.get(file) ?? [];
40318
+ fileDiagnostics.push(diagnostic);
40319
+ diagnosticsByFile.set(file, fileDiagnostics);
40320
+ }
40321
+ return diagnosticsByFile;
40322
+ };
40323
+ /**
40324
+ * Collects each file's cross-file dependency probes (via the plugin's
40325
+ * collectors) with their current answers. Fail-open per file: an unreadable /
40326
+ * unparseable / collector-failing file maps to `null` (no entry stored — it
40327
+ * re-lints next scan). The filesystem is frozen for the scan, so this can run
40328
+ * CONCURRENTLY with an oxlint child-process await — the caller starts it
40329
+ * unawaited before a lint pass and awaits it at store time, hiding the
40330
+ * in-process parse + resolution cost under the subprocess wait. The loop
40331
+ * yields on the cooperative time budget so the child's stdout handling (and
40332
+ * sibling fibers) keep flowing; each per-file collection is synchronous, so
40333
+ * the module-level probe recorder never sees interleaved use. Never rejects.
40334
+ */
40335
+ const collectSidecarProbesForFiles = async (input) => {
40336
+ const buildProbes = (file) => {
40337
+ const absoluteFilePath = Path.resolve(input.rootDirectory, file);
40338
+ let trace;
40339
+ try {
40340
+ trace = collectCrossFileDependencyProbes({
40341
+ absoluteFilePath,
40342
+ sourceText: NFS.readFileSync(absoluteFilePath, "utf8"),
40343
+ ruleIds: input.boundedSidecarRuleIds
40344
+ });
40345
+ } catch {
40346
+ return null;
40347
+ }
40348
+ if (trace === null) return null;
40349
+ const probes = [];
40350
+ for (const contentPath of trace.contentPaths) {
40351
+ const relativePath = input.probeAnswers.toRelativePath(contentPath);
40352
+ probes.push({
40353
+ kind: "content",
40354
+ path: relativePath,
40355
+ answer: input.probeAnswers.answerFor("content", relativePath)
40356
+ });
40357
+ }
40358
+ for (const existencePath of trace.existencePaths) {
40359
+ const relativePath = input.probeAnswers.toRelativePath(existencePath);
40360
+ probes.push({
40361
+ kind: "exists",
40362
+ path: relativePath,
40363
+ answer: input.probeAnswers.answerFor("exists", relativePath)
40364
+ });
40365
+ }
40366
+ return probes;
40367
+ };
40368
+ const probesByFile = /* @__PURE__ */ new Map();
40369
+ let collectSliceStartedAt = performance.now();
40370
+ for (const file of input.files) {
40371
+ probesByFile.set(file, buildProbes(file));
40372
+ if (performance.now() - collectSliceStartedAt >= 12) {
40373
+ await yieldToEventLoop();
40374
+ collectSliceStartedAt = performance.now();
40375
+ }
40376
+ }
40377
+ return probesByFile;
40378
+ };
40379
+ /**
40380
+ * Stores each freshly-linted file's probe set + cross-file diagnostics so
40381
+ * the next scan can replay instead of re-linting.
40382
+ */
40383
+ const storeSidecarEntries = (input) => {
40384
+ let didStoreAnyEntry = false;
40385
+ for (const pass of input.storablePasses) {
40386
+ if (!pass.isTrusted) continue;
40387
+ const diagnosticsByFile = attributeDiagnosticsToFiles(pass.diagnostics, pass.files);
40388
+ if (diagnosticsByFile === null) continue;
40389
+ for (const file of pass.files) {
40390
+ const cacheKey = input.cacheKeyByFile.get(file);
40391
+ if (cacheKey === void 0) continue;
40392
+ const probes = pass.probesByFile.get(file);
40393
+ if (probes === void 0 || probes === null) continue;
40394
+ input.sidecarCache.store(cacheKey, {
40395
+ probes,
40396
+ diagnostics: diagnosticsByFile.get(file) ?? []
40397
+ });
40398
+ didStoreAnyEntry = true;
40399
+ }
40400
+ }
40401
+ if (didStoreAnyEntry) input.sidecarCache.persist();
40402
+ };
39928
40403
  const REACT_HOOKS_JS_DROP_PREFIX = "React Compiler rules (react-hooks-js/*) skipped — eslint-plugin-react-hooks failed to load in this environment";
39929
40404
  /**
39930
40405
  * Detects an oxlint config-load crash caused by the optional
@@ -39966,7 +40441,7 @@ const reactHooksJsPluginDropNote = (error) => {
39966
40441
  * 6. always restore disable directives + clean up the temp dir
39967
40442
  */
39968
40443
  const runOxlint = async (options) => {
39969
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, onCacheStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "cost" } = options;
40444
+ const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, sidecarLintCacheEnabled = false, onCacheStats, onSidecarStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "cost" } = options;
39970
40445
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39971
40446
  const severityControls = buildRuleSeverityControls(userConfig);
39972
40447
  validateRuleRegistration();
@@ -39984,7 +40459,8 @@ const runOxlint = async (options) => {
39984
40459
  severityControls,
39985
40460
  userPlugins,
39986
40461
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
39987
- ruleSelection: overrides.ruleSelection
40462
+ ruleSelection: overrides.ruleSelection,
40463
+ sidecarRuleIdFilter: overrides.sidecarRuleIdFilter
39988
40464
  });
39989
40465
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
39990
40466
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
@@ -40087,15 +40563,18 @@ const runOxlint = async (options) => {
40087
40563
  });
40088
40564
  const cache = createFileLintCache(resolveReactDoctorCacheDir(rootDirectory), rulesetHash);
40089
40565
  const cacheKeyByFile = /* @__PURE__ */ new Map();
40566
+ const contentHashByRelativePath = /* @__PURE__ */ new Map();
40090
40567
  const missFiles = [];
40091
40568
  const replayedDiagnostics = [];
40092
40569
  let hashSliceStartedAt = performance.now();
40093
40570
  for (const candidateFile of candidateFiles) {
40094
- const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
40571
+ const absoluteCandidatePath = Path.resolve(rootDirectory, candidateFile);
40572
+ const contentHash = hashFileContents(absoluteCandidatePath);
40095
40573
  if (contentHash === null) {
40096
40574
  missFiles.push(candidateFile);
40097
40575
  continue;
40098
40576
  }
40577
+ contentHashByRelativePath.set(Path.relative(rootDirectory, absoluteCandidatePath).replaceAll("\\", "/"), contentHash);
40099
40578
  const cacheKey = `${candidateFile.replaceAll("\\", "/")}\u0000${contentHash}`;
40100
40579
  cacheKeyByFile.set(candidateFile, cacheKey);
40101
40580
  const cachedDiagnostics = cache.lookup(cacheKey);
@@ -40107,43 +40586,111 @@ const runOxlint = async (options) => {
40107
40586
  }
40108
40587
  }
40109
40588
  const cacheHitFileCount = candidateFiles.length - missFiles.length;
40589
+ const enabledSidecarRuleIds = Object.keys(buildConfig({
40590
+ extendsPaths: [],
40591
+ ruleSelection: "sidecar"
40592
+ }).rules).map((ruleKey) => ruleKey.slice(ruleKey.lastIndexOf("/") + 1)).filter((ruleId) => CROSS_FILE_RULE_IDS.has(ruleId));
40593
+ const boundedSidecarRuleIds = enabledSidecarRuleIds.filter((ruleId) => CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId));
40594
+ const boundedSidecarRuleIdSet = new Set(boundedSidecarRuleIds);
40595
+ const unboundedSidecarRuleIds = enabledSidecarRuleIds.filter((ruleId) => !CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId));
40596
+ const useSidecarCache = sidecarLintCacheEnabled && boundedSidecarRuleIds.length > 0;
40597
+ const sidecarCache = useSidecarCache ? createSidecarLintCache(resolveReactDoctorCacheDir(rootDirectory), computeRulesetHash({
40598
+ config: buildConfig({
40599
+ extendsPaths: [],
40600
+ ruleSelection: "sidecar",
40601
+ sidecarRuleIdFilter: boundedSidecarRuleIdSet
40602
+ }),
40603
+ toolchainVersions: resolveOxlintToolchainVersions(nodeBinaryPath),
40604
+ ignorePatterns: combinedPatterns,
40605
+ tsconfigContent
40606
+ })) : null;
40607
+ const probeAnswers = createSidecarProbeAnswerResolver({
40608
+ rootDirectory,
40609
+ contentHashByRelativePath
40610
+ });
40611
+ const missProbesTask = sidecarCache === null ? null : collectSidecarProbesForFiles({
40612
+ files: missFiles,
40613
+ rootDirectory,
40614
+ boundedSidecarRuleIds,
40615
+ probeAnswers
40616
+ });
40110
40617
  const fullResult = await runConfigOverFiles((overrides) => buildConfig({
40111
40618
  extendsPaths: [],
40112
40619
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
40113
40620
  }), "oxlintrc.full.json", missFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(scannedFileCount, candidateFiles.length)));
40114
40621
  const missFileSet = new Set(missFiles);
40115
- const sidecarResult = await runConfigOverFiles(() => buildConfig({
40622
+ const hitFiles = candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile));
40623
+ const sidecarReplayedDiagnostics = [];
40624
+ const sidecarLintFiles = [];
40625
+ if (sidecarCache === null) sidecarLintFiles.push(...hitFiles);
40626
+ else {
40627
+ let verifySliceStartedAt = performance.now();
40628
+ for (const hitFile of hitFiles) {
40629
+ const cacheKey = cacheKeyByFile.get(hitFile);
40630
+ const entry = cacheKey === void 0 ? null : sidecarCache.lookup(cacheKey);
40631
+ if (entry !== null && entry.probes.every((probe) => probeAnswers.answerFor(probe.kind, probe.path) === probe.answer)) sidecarReplayedDiagnostics.push(...entry.diagnostics);
40632
+ else sidecarLintFiles.push(hitFile);
40633
+ if (performance.now() - verifySliceStartedAt >= 12) {
40634
+ await yieldToEventLoop();
40635
+ verifySliceStartedAt = performance.now();
40636
+ }
40637
+ }
40638
+ }
40639
+ const sidecarProbesTask = sidecarCache === null ? null : collectSidecarProbesForFiles({
40640
+ files: sidecarLintFiles,
40641
+ rootDirectory,
40642
+ boundedSidecarRuleIds,
40643
+ probeAnswers
40644
+ });
40645
+ const sidecarReplayedFileCount = hitFiles.length - sidecarLintFiles.length;
40646
+ if (sidecarReplayedFileCount > 0) options.onFileProgress?.(missFiles.length + sidecarReplayedFileCount, candidateFiles.length);
40647
+ const boundedSidecarResult = await runConfigOverFiles(() => buildConfig({
40116
40648
  extendsPaths: [],
40117
- ruleSelection: "sidecar"
40118
- }), "oxlintrc.sidecar.json", candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile)), options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + scannedFileCount, candidateFiles.length)));
40649
+ ruleSelection: "sidecar",
40650
+ ...useSidecarCache ? { sidecarRuleIdFilter: boundedSidecarRuleIdSet } : {}
40651
+ }), "oxlintrc.sidecar.json", sidecarLintFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + sidecarReplayedFileCount + scannedFileCount, candidateFiles.length)));
40652
+ const unboundedSidecarResult = useSidecarCache && unboundedSidecarRuleIds.length > 0 ? await runConfigOverFiles(() => buildConfig({
40653
+ extendsPaths: [],
40654
+ ruleSelection: "sidecar",
40655
+ sidecarRuleIdFilter: new Set(unboundedSidecarRuleIds)
40656
+ }), "oxlintrc.sidecar-unbounded.json", hitFiles, void 0) : {
40657
+ diagnostics: [],
40658
+ didDropReactHooksJsPlugin: false,
40659
+ hadPartialFailure: false
40660
+ };
40119
40661
  const freshCacheableDiagnostics = [];
40120
40662
  for (const diagnostic of fullResult.diagnostics) if (!CROSS_FILE_RULE_IDS.has(diagnostic.rule)) freshCacheableDiagnostics.push(diagnostic);
40121
40663
  onCacheStats?.(cacheHitFileCount, candidateFiles.length);
40122
- const missFileByNormalizedPath = /* @__PURE__ */ new Map();
40123
- for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
40124
- const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
40125
- let isAttributionSound = true;
40126
- for (const diagnostic of freshCacheableDiagnostics) {
40127
- const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
40128
- if (missFile === void 0) {
40129
- isAttributionSound = false;
40130
- break;
40131
- }
40132
- const fileDiagnostics = freshDiagnosticsByFile.get(missFile) ?? [];
40133
- fileDiagnostics.push(diagnostic);
40134
- freshDiagnosticsByFile.set(missFile, fileDiagnostics);
40135
- }
40136
- if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && isAttributionSound) {
40664
+ if (sidecarCache !== null) onSidecarStats?.(hitFiles.length - sidecarLintFiles.length, hitFiles.length);
40665
+ const freshDiagnosticsByFile = attributeDiagnosticsToFiles(freshCacheableDiagnostics, missFiles);
40666
+ if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && freshDiagnosticsByFile !== null) {
40137
40667
  for (const missFile of missFiles) {
40138
40668
  const cacheKey = cacheKeyByFile.get(missFile);
40139
40669
  if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
40140
40670
  }
40141
40671
  cache.persist();
40142
40672
  }
40673
+ if (sidecarCache !== null && missProbesTask !== null && sidecarProbesTask !== null) storeSidecarEntries({
40674
+ sidecarCache,
40675
+ cacheKeyByFile,
40676
+ storablePasses: [{
40677
+ files: sidecarLintFiles,
40678
+ diagnostics: boundedSidecarResult.diagnostics,
40679
+ isTrusted: !boundedSidecarResult.hadPartialFailure,
40680
+ probesByFile: await sidecarProbesTask
40681
+ }, {
40682
+ files: missFiles,
40683
+ diagnostics: fullResult.diagnostics.filter((diagnostic) => boundedSidecarRuleIdSet.has(diagnostic.rule)),
40684
+ isTrusted: !fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure,
40685
+ probesByFile: await missProbesTask
40686
+ }]
40687
+ });
40143
40688
  return dedupeDiagnostics([
40144
40689
  ...replayedDiagnostics,
40145
40690
  ...fullResult.diagnostics,
40146
- ...sidecarResult.diagnostics
40691
+ ...sidecarReplayedDiagnostics,
40692
+ ...boundedSidecarResult.diagnostics,
40693
+ ...unboundedSidecarResult.diagnostics
40147
40694
  ]);
40148
40695
  }
40149
40696
  const baseArgs = makeBaseArgs(configPath);
@@ -40241,6 +40788,7 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
40241
40788
  const concurrency = yield* OxlintConcurrency;
40242
40789
  const lintBatchOrdering = yield* LintBatchOrdering;
40243
40790
  const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
40791
+ const sidecarLintCacheEnabled = yield* SidecarLintCacheEnabled;
40244
40792
  const collectedFailures = [];
40245
40793
  const diagnostics = yield* tryPromise({
40246
40794
  try: (signal) => runOxlint({
@@ -40259,7 +40807,9 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
40259
40807
  },
40260
40808
  onFileProgress: input.onFileProgress,
40261
40809
  perFileLintCacheEnabled,
40810
+ sidecarLintCacheEnabled,
40262
40811
  onCacheStats: input.onCacheStats,
40812
+ onSidecarStats: input.onSidecarStats,
40263
40813
  spawnTimeoutMs,
40264
40814
  outputMaxBytes,
40265
40815
  concurrency,
@@ -40681,6 +41231,20 @@ const writeCachedSocketBody = (cacheFile, body) => {
40681
41231
  }));
40682
41232
  } catch {}
40683
41233
  };
41234
+ const pruneExpiredSocketCache = (cacheDirectory) => {
41235
+ try {
41236
+ const supplyChainCacheDirectory = Path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR);
41237
+ const expiryThresholdMs = Date.now() - SUPPLY_CHAIN_CACHE_TTL_MS;
41238
+ for (const entryName of NFS.readdirSync(supplyChainCacheDirectory)) {
41239
+ const entryPath = Path.join(supplyChainCacheDirectory, entryName);
41240
+ try {
41241
+ if (NFS.statSync(entryPath).mtimeMs < expiryThresholdMs) NFS.rmSync(entryPath);
41242
+ } catch {
41243
+ continue;
41244
+ }
41245
+ }
41246
+ } catch {}
41247
+ };
40684
41248
  const fetchSocketArtifact = (dependency, cacheDirectory) => tryPromise(async (signal) => {
40685
41249
  const cacheFile = cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency);
40686
41250
  if (cacheFile !== null) {
@@ -40808,6 +41372,7 @@ const checkSupplyChain = (input) => gen(function* () {
40808
41372
  const dependencies = collectDependenciesToScore(readPackageJson(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
40809
41373
  if (dependencies.length === 0) return [];
40810
41374
  const cacheDirectory = isSupplyChainCacheDisabled() ? null : resolveReactDoctorCacheDir(input.rootDirectory);
41375
+ if (cacheDirectory !== null) pruneExpiredSocketCache(cacheDirectory);
40811
41376
  const artifacts = yield* forEach$1(dependencies, (dependency) => fetchSocketArtifact(dependency, cacheDirectory), { concurrency: 8 }).pipe(timeoutOption(input.totalTimeoutMs ?? 9e4), map$3((maybeArtifacts) => getOrElse$1(maybeArtifacts, () => [])));
40812
41377
  const diagnostics = [];
40813
41378
  for (let index = 0; index < dependencies.length; index += 1) {
@@ -41034,7 +41599,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41034
41599
  const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
41035
41600
  rootDirectory: scanDirectory,
41036
41601
  parseConcurrency: deadCodeParseConcurrency,
41037
- workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
41602
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs,
41603
+ onCacheOutcome: (didHitCache) => {
41604
+ deadCodeCacheHit = didHitCache;
41605
+ },
41606
+ onSummaryCacheStats: (stats) => {
41607
+ deadCodeSummaryCacheHits = stats.hits;
41608
+ deadCodeSummaryCacheMisses = stats.misses;
41609
+ }
41038
41610
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
41039
41611
  yield* set(deadCodeFailure, {
41040
41612
  didFail: true,
@@ -41062,6 +41634,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41062
41634
  let lastReportedTotalFileCount = 0;
41063
41635
  let lintCacheHitFileCount = null;
41064
41636
  let lintCacheTotalFileCount = null;
41637
+ let lintSidecarReplayedFileCount = null;
41638
+ let lintSidecarTotalFileCount = null;
41639
+ let deadCodeCacheHit = null;
41640
+ let deadCodeSummaryCacheHits = null;
41641
+ let deadCodeSummaryCacheMisses = null;
41065
41642
  const baseLintStream = linterService.run({
41066
41643
  rootDirectory: scanDirectory,
41067
41644
  project,
@@ -41081,6 +41658,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41081
41658
  lintCacheHitFileCount = cacheHitFileCount;
41082
41659
  lintCacheTotalFileCount = totalConsideredFileCount;
41083
41660
  },
41661
+ onSidecarStats: (sidecarReplayedFileCount, sidecarConsideredFileCount) => {
41662
+ lintSidecarReplayedFileCount = sidecarReplayedFileCount;
41663
+ lintSidecarTotalFileCount = sidecarConsideredFileCount;
41664
+ },
41084
41665
  deadlineEpochMs: input.deadlineEpochMs
41085
41666
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
41086
41667
  yield* set(lintFailure, {
@@ -41191,6 +41772,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41191
41772
  securityScanFailed,
41192
41773
  lintCacheHitFileCount,
41193
41774
  lintCacheTotalFileCount,
41775
+ lintSidecarReplayedFileCount,
41776
+ lintSidecarTotalFileCount,
41777
+ deadCodeCacheHit: lintFailureState.didFail ? null : deadCodeCacheHit,
41778
+ deadCodeSummaryCacheHits: lintFailureState.didFail ? null : deadCodeSummaryCacheHits,
41779
+ deadCodeSummaryCacheMisses: lintFailureState.didFail ? null : deadCodeSummaryCacheMisses,
41194
41780
  suppressedRuleCounts: transform.summarizeSuppressions()
41195
41781
  };
41196
41782
  }).pipe(withSpan("runInspect", { attributes: {
@@ -41503,11 +42089,9 @@ const computeConfigFingerprint = (projectDirectory, version) => {
41503
42089
  const parts = [`v=${version}`];
41504
42090
  let directory = projectDirectory;
41505
42091
  for (;;) {
41506
- for (const filename of CONFIG_FINGERPRINT_FILENAMES) try {
41507
- const stat = fs.statSync(path.join(directory, filename));
41508
- parts.push(`${directory}/${filename}=${stat.mtimeMs}:${stat.size}`);
41509
- } catch {
41510
- continue;
42092
+ for (const filename of CONFIG_FINGERPRINT_FILENAMES) {
42093
+ const contentHash = hashFileContents(path.join(directory, filename));
42094
+ if (contentHash !== null) parts.push(`${directory}/${filename}=${contentHash}`);
41511
42095
  }
41512
42096
  const parent = path.dirname(directory);
41513
42097
  if (parent === directory) break;
@@ -41520,7 +42104,7 @@ const computeConfigFingerprint = (projectDirectory, version) => {
41520
42104
  /** Display name used in client-facing messages and progress titles. */
41521
42105
  const SERVER_DISPLAY_NAME = "React Doctor";
41522
42106
  /** Server version reported in `serverInfo`; injected at build, `dev` from source. */
41523
- const SERVER_VERSION = "0.6.3";
42107
+ const SERVER_VERSION = "0.7.0";
41524
42108
  /** `Diagnostic.source` shown next to every published diagnostic. */
41525
42109
  const DIAGNOSTIC_SOURCE = "react-doctor";
41526
42110
  /**
@@ -43227,6 +43811,7 @@ const METRIC = {
43227
43811
  scanScore: "scan.score",
43228
43812
  scanClean: "scan.clean",
43229
43813
  scanCheckSkipped: "scan.check_skipped",
43814
+ scanNoReactDetected: "scan.no_react_detected",
43230
43815
  baselineDegraded: "baseline.degraded",
43231
43816
  ruleFired: "rule.fired",
43232
43817
  ruleDisabled: "rule.disabled",
@@ -43570,5 +44155,5 @@ const startLanguageServer = () => {
43570
44155
  };
43571
44156
  //#endregion
43572
44157
  export { startLanguageServer };
43573
- !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]="94a57ec3-d5a9-52ea-a743-e91d996065b8")}catch(e){}}();
43574
- //# debugId=94a57ec3-d5a9-52ea-a743-e91d996065b8
44158
+ !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]="1c7dd926-433e-5edb-a6d7-2bb0a9f6df24")}catch(e){}}();
44159
+ //# debugId=1c7dd926-433e-5edb-a6d7-2bb0a9f6df24