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/index.js CHANGED
@@ -1,5 +1,5 @@
1
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]="8565c6ea-39f3-51e6-895a-ae9f7c026d0e")}catch(e){}}();
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]="07deeb70-f47f-5572-9930-e743f970f84f")}catch(e){}}();
3
3
  import { r as __toESM$1, t as __commonJSMin$1 } from "./chunk-N93fKeF6.js";
4
4
  import { createRequire } from "node:module";
5
5
  import * as NFS from "node:fs";
@@ -9,7 +9,7 @@ import path from "node:path";
9
9
  import * as NodeChildProcess from "node:child_process";
10
10
  import { execFileSync, spawn, spawnSync } from "node:child_process";
11
11
  import * as ts from "typescript";
12
- 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";
12
+ 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";
13
13
  import * as OS from "node:os";
14
14
  import os from "node:os";
15
15
  import { parseJSON5 } from "confbox";
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
18
18
  import { createJiti } from "jiti";
19
19
  import * as Crypto from "node:crypto";
20
20
  import crypto, { createHash } from "node:crypto";
21
+ import { ANALYZED_MANIFEST_FILENAMES, DEFAULT_EXTENSIONS } from "deslop-js/analyzed-inputs";
21
22
  import { gzipSync } from "node:zlib";
22
23
  //#region ../../node_modules/.pnpm/effect@4.0.0-beta.70/node_modules/effect/dist/Pipeable.js
23
24
  /**
@@ -19373,6 +19374,16 @@ var JsonReportV1 = class extends Class("JsonReportV1")({
19373
19374
  * successful `baseline` run (`schemaVersion: 2`) and on non-compare scopes.
19374
19375
  */
19375
19376
  baselineDegraded: optional(Boolean$1),
19377
+ /**
19378
+ * Whether any scanned project resolved a React-compatible runtime (React
19379
+ * or Preact). `false` means every React-runtime rule family was gated off,
19380
+ * so an empty `diagnostics` array is vacuous — NOT the same as a clean
19381
+ * React scan. Consumers gating on the report (CI, verifiers, hooks) should
19382
+ * treat `reactDetected === false` as "wrong scan target", not "all clear".
19383
+ * Absent when nothing was scanned (`projects` is empty), on error reports,
19384
+ * and on reports from older CLI versions.
19385
+ */
19386
+ reactDetected: optional(Boolean$1),
19376
19387
  diff: NullOr(JsonReportDiffInfo),
19377
19388
  projects: ArraySchema(JsonReportProjectEntry),
19378
19389
  diagnostics: ArraySchema(Diagnostic),
@@ -19398,6 +19409,8 @@ var JsonReportV2 = class extends Class("JsonReportV2")({
19398
19409
  ok: Boolean$1,
19399
19410
  directory: String$1,
19400
19411
  mode: JsonReportMode,
19412
+ /** See `JsonReportV1.reactDetected`. */
19413
+ reactDetected: optional(Boolean$1),
19401
19414
  diff: NullOr(JsonReportDiffInfo),
19402
19415
  baseline: JsonReportBaseline,
19403
19416
  projects: ArraySchema(JsonReportProjectEntry),
@@ -32927,10 +32940,15 @@ const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
32927
32940
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
32928
32941
  const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
32929
32942
  const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
32943
+ const SIDECAR_LINT_CACHE_FILENAME = "sidecar-lint-cache.json";
32944
+ const CORE_PACKAGE_VERSION = "0.7.0";
32945
+ const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json";
32946
+ const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json";
32930
32947
  const SUPPLY_CHAIN_PLUGIN = "socket";
32931
32948
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
32932
32949
  const SUPPLY_CHAIN_CATEGORY = "Security";
32933
32950
  const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
32951
+ const SUPPLY_CHAIN_CACHE_TTL_MS = 864e5;
32934
32952
  const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
32935
32953
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
32936
32954
  const hasIgnoredPathSegment = (relativePath) => relativePath.split(/[/\\]/).slice(0, -1).some((segment) => IGNORED_DIRECTORIES.has(segment));
@@ -35613,6 +35631,48 @@ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintC
35613
35631
  if (CACHE_DISABLED_VALUES.has(noFileCache)) return false;
35614
35632
  return true;
35615
35633
  } }) {};
35634
+ /**
35635
+ * Whether the sidecar lint cache (`runners/oxlint/sidecar-lint-cache.ts`) is
35636
+ * active. Defaults ON — warm rescans replay the cross-file rules' cached
35637
+ * diagnostics for every file whose dependency fingerprint (the probe set the
35638
+ * plugin's collectors recorded) still matches the tree, and re-lint only the
35639
+ * rest. Only reachable when the per-file lint cache itself is on (the
35640
+ * sidecar covers its cache hits), so `REACT_DOCTOR_NO_CACHE` /
35641
+ * `REACT_DOCTOR_NO_FILE_CACHE` implicitly disable it too. Opt-OUT knobs:
35642
+ *
35643
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
35644
+ * - `REACT_DOCTOR_NO_SIDECAR_CACHE` — granular rollback hatch: keep the
35645
+ * per-file cache but run the always-fresh sidecar over every hit
35646
+ * (the pre-cache behavior).
35647
+ *
35648
+ * Tests override via `Layer.succeed(SidecarLintCacheEnabled, false)`.
35649
+ */
35650
+ var SidecarLintCacheEnabled = class extends Reference("react-doctor/SidecarLintCacheEnabled", { defaultValue: () => {
35651
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35652
+ const noSidecarCache = process.env["REACT_DOCTOR_NO_SIDECAR_CACHE"]?.toLowerCase() ?? "";
35653
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35654
+ if (CACHE_DISABLED_VALUES.has(noSidecarCache)) return false;
35655
+ return true;
35656
+ } }) {};
35657
+ /**
35658
+ * Whether the whole-project dead-code result cache
35659
+ * (`dead-code/dead-code-result-cache.ts`) is active. Defaults ON — a rescan
35660
+ * whose inputs (source tree, manifests, configs, analyzer version) are
35661
+ * unchanged replays the stored diagnostics instead of re-running the
35662
+ * analysis worker. Opt-OUT, two knobs (matching the per-file lint cache):
35663
+ *
35664
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
35665
+ * - `REACT_DOCTOR_NO_DEAD_CODE_CACHE` — granular: bust only this cache.
35666
+ *
35667
+ * Tests override via `Layer.succeed(DeadCodeResultCacheEnabled, false)`.
35668
+ */
35669
+ var DeadCodeResultCacheEnabled = class extends Reference("react-doctor/DeadCodeResultCacheEnabled", { defaultValue: () => {
35670
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35671
+ const noDeadCodeCache = process.env["REACT_DOCTOR_NO_DEAD_CODE_CACHE"]?.toLowerCase() ?? "";
35672
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35673
+ if (CACHE_DISABLED_VALUES.has(noDeadCodeCache)) return false;
35674
+ return true;
35675
+ } }) {};
35616
35676
  const DIAGNOSTIC_SURFACES = [
35617
35677
  "cli",
35618
35678
  "prComment",
@@ -36962,10 +37022,17 @@ function* collectSecurityScanFiles(rootDirectory) {
36962
37022
  }
36963
37023
  }
36964
37024
  }
37025
+ /**
37026
+ * Whether project discovery resolved a React-compatible runtime (React or
37027
+ * Preact, which ships the same hooks + JSX model). This is the same
37028
+ * predicate that grants the `react` capability, so `false` means every
37029
+ * React-runtime rule family was gated off for the project.
37030
+ */
37031
+ const hasReactRuntime = (project) => project.reactVersion !== null || project.preactVersion !== null;
36965
37032
  const buildCapabilities = (project) => {
36966
37033
  const capabilities = /* @__PURE__ */ new Set();
36967
37034
  capabilities.add(project.framework);
36968
- if (project.reactVersion !== null || project.preactVersion !== null) capabilities.add("react");
37035
+ if (hasReactRuntime(project)) capabilities.add("react");
36969
37036
  if (project.framework === "expo" || project.framework === "react-native" || project.hasReactNativeWorkspace) capabilities.add("react-native");
36970
37037
  if (project.expoVersion !== null) capabilities.add("expo");
36971
37038
  if (project.nextjsMajorVersion !== null && project.nextjsMajorVersion >= 15) capabilities.add("nextjs:15");
@@ -37301,6 +37368,146 @@ const collectDeadCodeIgnorePatterns = (rootDirectory) => {
37301
37368
  return [...seen].filter((pattern) => pattern.length > 0);
37302
37369
  };
37303
37370
  const collectDeadCodeEntryPatterns = (rootDirectory) => [...new Set(collectKnipPatterns(rootDirectory, "entry"))].filter((pattern) => pattern.length > 0);
37371
+ const atomicWriteJson = (filePath, value) => {
37372
+ try {
37373
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
37374
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
37375
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
37376
+ NFS.renameSync(temporaryPath, filePath);
37377
+ } catch {
37378
+ return;
37379
+ }
37380
+ };
37381
+ const failOpenReadJson = (filePath, fallback) => {
37382
+ try {
37383
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
37384
+ } catch {
37385
+ return fallback;
37386
+ }
37387
+ };
37388
+ const hashFileContents = (filePath) => {
37389
+ try {
37390
+ return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
37391
+ } catch {
37392
+ return null;
37393
+ }
37394
+ };
37395
+ const ANALYZED_FILE_EXTENSIONS = new Set(DEFAULT_EXTENSIONS);
37396
+ const CORE_SIDE_MANIFEST_NAMES = ["knip.json", "deno.lock"];
37397
+ const ANALYZED_MANIFEST_NAMES = new Set([...ANALYZED_MANIFEST_FILENAMES, ...CORE_SIDE_MANIFEST_NAMES]);
37398
+ const isTsConfigLikeFile = (fileName) => (fileName.startsWith("tsconfig") || fileName.startsWith("jsconfig")) && fileName.endsWith(".json");
37399
+ const isFingerprintedFile = (fileName) => ANALYZED_FILE_EXTENSIONS.has(Path.extname(fileName).toLowerCase()) || ANALYZED_MANIFEST_NAMES.has(fileName) || isTsConfigLikeFile(fileName);
37400
+ /**
37401
+ * Stat snapshot of every file the analysis reads, keyed by root-relative
37402
+ * `/`-separated path. Taken BEFORE the (long) analysis so a stored result is
37403
+ * verified against the tree it started from.
37404
+ */
37405
+ const collectAnalyzedFileStats = (rootDirectory) => {
37406
+ const statByRelativePath = /* @__PURE__ */ new Map();
37407
+ for (const { absolutePath, name } of walkSourceTreeFiles(rootDirectory)) {
37408
+ if (!isFingerprintedFile(name)) continue;
37409
+ try {
37410
+ const fileStat = NFS.statSync(absolutePath);
37411
+ const relativePath = Path.relative(rootDirectory, absolutePath).replace(/\\/g, "/");
37412
+ statByRelativePath.set(relativePath, {
37413
+ mtimeMs: fileStat.mtimeMs,
37414
+ size: fileStat.size
37415
+ });
37416
+ } catch {}
37417
+ }
37418
+ return statByRelativePath;
37419
+ };
37420
+ const bundledRequire$2 = createRequire(import.meta.url);
37421
+ const resolveDeslopVersion = () => {
37422
+ try {
37423
+ const packageJson = JSON.parse(NFS.readFileSync(bundledRequire$2.resolve("deslop-js/package.json"), "utf8"));
37424
+ return isRecord(packageJson) && typeof packageJson.version === "string" ? packageJson.version : "unknown";
37425
+ } catch {
37426
+ return "unknown";
37427
+ }
37428
+ };
37429
+ const computeDeadCodeCacheKey = (input) => crypto.createHash("sha1").update(JSON.stringify({
37430
+ schemaVersion: 2,
37431
+ coreVersion: input.coreVersion,
37432
+ deslopVersion: resolveDeslopVersion(),
37433
+ deslopJsModuleSpecifier: input.deslopJsModuleSpecifier,
37434
+ entryPatterns: input.entryPatterns,
37435
+ ignorePatterns: input.ignorePatterns,
37436
+ tsConfigFile: input.tsConfigPath === void 0 ? null : Path.relative(input.rootDirectory, input.tsConfigPath).replace(/\\/g, "/")
37437
+ })).digest("hex");
37438
+ const validateDiagnostic$2 = decodeUnknownSync(Diagnostic);
37439
+ const decodeCachedDiagnostics = (raw) => {
37440
+ try {
37441
+ for (const entry of raw) validateDiagnostic$2(entry);
37442
+ return raw;
37443
+ } catch {
37444
+ return null;
37445
+ }
37446
+ };
37447
+ const isPersistedFileIdentity = (value) => Array.isArray(value) && value.length === 3 && typeof value[0] === "number" && typeof value[1] === "number" && typeof value[2] === "string";
37448
+ const lookupDeadCodeResultCache = (input) => {
37449
+ const cacheFilePath = Path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME);
37450
+ const persisted = failOpenReadJson(cacheFilePath, null);
37451
+ if (persisted === null || !isRecord(persisted) || persisted.version !== 2 || persisted.key !== input.cacheKey || !isRecord(persisted.files) || !Array.isArray(persisted.diagnostics)) return null;
37452
+ const storedFileEntries = Object.entries(persisted.files);
37453
+ if (storedFileEntries.length !== input.currentFileStats.size) return null;
37454
+ const repairedFiles = {};
37455
+ let repairedCount = 0;
37456
+ for (const [relativePath, storedIdentity] of storedFileEntries) {
37457
+ if (!isPersistedFileIdentity(storedIdentity)) return null;
37458
+ const currentStat = input.currentFileStats.get(relativePath);
37459
+ if (currentStat === void 0) return null;
37460
+ const [storedMtimeMs, storedSize, storedContentHash] = storedIdentity;
37461
+ if (currentStat.mtimeMs === storedMtimeMs && currentStat.size === storedSize) {
37462
+ repairedFiles[relativePath] = storedIdentity;
37463
+ continue;
37464
+ }
37465
+ if (currentStat.size !== storedSize) return null;
37466
+ const currentContentHash = hashFileContents(Path.join(input.rootDirectory, relativePath));
37467
+ if (currentContentHash === null || currentContentHash !== storedContentHash) return null;
37468
+ repairedFiles[relativePath] = [
37469
+ currentStat.mtimeMs,
37470
+ currentStat.size,
37471
+ storedContentHash
37472
+ ];
37473
+ repairedCount += 1;
37474
+ }
37475
+ const diagnostics = decodeCachedDiagnostics(persisted.diagnostics);
37476
+ if (diagnostics === null) return null;
37477
+ if (repairedCount > 0) atomicWriteJson(cacheFilePath, {
37478
+ version: 2,
37479
+ key: input.cacheKey,
37480
+ files: repairedFiles,
37481
+ diagnostics: persisted.diagnostics
37482
+ });
37483
+ return diagnostics;
37484
+ };
37485
+ const storeDeadCodeResultCache = (input) => {
37486
+ const persistedFiles = {};
37487
+ for (const [relativePath, snapshotStat] of input.snapshotFileStats) {
37488
+ const absolutePath = Path.join(input.rootDirectory, relativePath);
37489
+ const contentHash = hashFileContents(absolutePath);
37490
+ if (contentHash === null) return;
37491
+ let currentStat;
37492
+ try {
37493
+ currentStat = NFS.statSync(absolutePath);
37494
+ } catch {
37495
+ return;
37496
+ }
37497
+ if (currentStat.mtimeMs !== snapshotStat.mtimeMs || currentStat.size !== snapshotStat.size) return;
37498
+ persistedFiles[relativePath] = [
37499
+ snapshotStat.mtimeMs,
37500
+ snapshotStat.size,
37501
+ contentHash
37502
+ ];
37503
+ }
37504
+ atomicWriteJson(Path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME), {
37505
+ version: 2,
37506
+ key: input.cacheKey,
37507
+ files: persistedFiles,
37508
+ diagnostics: input.diagnostics
37509
+ });
37510
+ };
37304
37511
  const readSystemFacts = () => ({
37305
37512
  availableCores: os.availableParallelism(),
37306
37513
  totalMemoryBytes: os.totalmem(),
@@ -37369,6 +37576,21 @@ const withDeadCodeWorkerSlot = async (task, abortSignal) => {
37369
37576
  releaseSlot();
37370
37577
  }
37371
37578
  };
37579
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
37580
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
37581
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
37582
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
37583
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
37584
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
37585
+ return Path.join(os.tmpdir(), `react-doctor-cache-${resolveUserCacheScope()}`, projectCacheSubdir(projectDirectory));
37586
+ };
37587
+ const resolveUserCacheScope = () => {
37588
+ try {
37589
+ return String(process.getuid?.() ?? os.userInfo().username);
37590
+ } catch {
37591
+ return "shared";
37592
+ }
37593
+ };
37372
37594
  /**
37373
37595
  * Resolves a path to its canonical, symlink-free form, falling back to
37374
37596
  * the input when it cannot be realpath'd (broken symlink, permission
@@ -37422,6 +37644,14 @@ process.stdin.on("end", () => {
37422
37644
  circularDependencies: result.circularDependencies.map((cycle) => ({
37423
37645
  files: cycle.files,
37424
37646
  })),
37647
+ ...(result.incrementalCacheStats
37648
+ ? {
37649
+ summaryCacheStats: {
37650
+ hits: result.incrementalCacheStats.summaryHits,
37651
+ misses: result.incrementalCacheStats.summaryMisses,
37652
+ },
37653
+ }
37654
+ : {}),
37425
37655
  });
37426
37656
 
37427
37657
  const serializeError = (error) =>
@@ -37445,6 +37675,9 @@ process.stdin.on("end", () => {
37445
37675
  ...(workerInput.ignorePatterns.length > 0
37446
37676
  ? { ignorePatterns: workerInput.ignorePatterns }
37447
37677
  : {}),
37678
+ ...(workerInput.incrementalCachePath
37679
+ ? { incrementalCachePath: workerInput.incrementalCachePath }
37680
+ : {}),
37448
37681
  // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
37449
37682
  // unusedDependencies, circularDependencies). Everything else deslop can compute
37450
37683
  // is pure wasted work for us, and it's the bulk of the runtime:
@@ -37455,12 +37688,18 @@ process.stdin.on("end", () => {
37455
37688
  // are the single most expensive pass — duplicate-block detection alone was
37456
37689
  // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
37457
37690
  // speedup on a large repo.
37458
- // Both are provably safe: the consumed graph findings are computed by their own
37691
+ // - reportRedundancy: the DRY-pattern detectors (duplicate types/constants,
37692
+ // simplifiable functions, identity wrappers, …) — ~120 ms of discarded
37693
+ // output per scan, and skipping them lets the incremental cache drop the
37694
+ // DRY-pattern summary fields (the largest slice of the cache file).
37695
+ // All are provably safe: the consumed graph findings are computed by their own
37459
37696
  // detectors, independent of these passes (confirmed byte-identical on
37460
- // excalidraw + mui-material + sentry). tsConfigPath stays — the module resolver
37461
- // needs it for path-alias resolution in the import graph.
37697
+ // excalidraw + mui-material + sentry; re-verified on sentry after the
37698
+ // reportRedundancy flip). tsConfigPath stays — the module resolver needs it
37699
+ // for path-alias resolution in the import graph.
37462
37700
  semantic: { enabled: false },
37463
37701
  reportCodeQuality: false,
37702
+ reportRedundancy: false,
37464
37703
  };
37465
37704
  const result = await analyze(defineConfig(config));
37466
37705
  emit({ ok: true, result: normalizeResult(result) });
@@ -37544,13 +37783,23 @@ const parseCircularDependencies = (value) => {
37544
37783
  }
37545
37784
  return circularDependencies;
37546
37785
  };
37786
+ const parseSummaryCacheStats = (value) => {
37787
+ if (!isRecord(value)) return void 0;
37788
+ if (typeof value.hits !== "number" || typeof value.misses !== "number") return void 0;
37789
+ return {
37790
+ hits: value.hits,
37791
+ misses: value.misses
37792
+ };
37793
+ };
37547
37794
  const parseDeadCodeWorkerResult = (value) => {
37548
37795
  if (!isRecord(value)) throw new Error("Dead-code worker returned an invalid result.");
37796
+ const summaryCacheStats = parseSummaryCacheStats(value.summaryCacheStats);
37549
37797
  return {
37550
37798
  unusedFiles: parseUnusedFiles(value.unusedFiles),
37551
37799
  unusedExports: parseUnusedExports(value.unusedExports),
37552
37800
  unusedDependencies: parseUnusedDependencies(value.unusedDependencies),
37553
- circularDependencies: parseCircularDependencies(value.circularDependencies)
37801
+ circularDependencies: parseCircularDependencies(value.circularDependencies),
37802
+ ...summaryCacheStats ? { summaryCacheStats } : {}
37554
37803
  };
37555
37804
  };
37556
37805
  const parseDeadCodeWorkerError = (value) => {
@@ -37664,17 +37913,41 @@ const checkDeadCode = async (options) => {
37664
37913
  if (!NFS.existsSync(Path.join(rootDirectory, "package.json"))) return [];
37665
37914
  const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
37666
37915
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory);
37916
+ const tsConfigPath = resolveTsConfigPath(rootDirectory);
37917
+ const deslopJsModuleSpecifier = options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js");
37918
+ const fileStatsSnapshot = options.cacheEnabled ? collectAnalyzedFileStats(rootDirectory) : null;
37919
+ const cacheKey = fileStatsSnapshot === null ? null : computeDeadCodeCacheKey({
37920
+ rootDirectory,
37921
+ entryPatterns,
37922
+ ignorePatterns,
37923
+ tsConfigPath,
37924
+ deslopJsModuleSpecifier,
37925
+ coreVersion: CORE_PACKAGE_VERSION
37926
+ });
37927
+ if (cacheKey !== null && fileStatsSnapshot !== null) {
37928
+ const cachedDiagnostics = lookupDeadCodeResultCache({
37929
+ cacheDirectory: resolveReactDoctorCacheDir(rootDirectory),
37930
+ cacheKey,
37931
+ rootDirectory,
37932
+ currentFileStats: fileStatsSnapshot
37933
+ });
37934
+ options.onCacheOutcome?.(cachedDiagnostics !== null);
37935
+ if (cachedDiagnostics !== null) return [...cachedDiagnostics];
37936
+ }
37937
+ const incrementalCachePath = options.cacheEnabled === true ? Path.join(resolveReactDoctorCacheDir(rootDirectory), DEAD_CODE_SUMMARY_CACHE_FILENAME) : void 0;
37667
37938
  const spawnAndRun = () => {
37668
37939
  return runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
37669
37940
  rootDirectory,
37670
37941
  entryPatterns,
37671
- tsConfigPath: resolveTsConfigPath(rootDirectory),
37942
+ tsConfigPath,
37672
37943
  ignorePatterns,
37673
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
37674
- parseConcurrency: options.parseConcurrency
37944
+ deslopJsModuleSpecifier,
37945
+ parseConcurrency: options.parseConcurrency,
37946
+ incrementalCachePath
37675
37947
  }), options.workerTimeoutMs ?? 12e4, options.abortSignal);
37676
37948
  };
37677
37949
  const result = parseDeadCodeWorkerResult(options.createWorker === void 0 ? await withDeadCodeWorkerSlot(spawnAndRun, options.abortSignal) : await spawnAndRun());
37950
+ if (result.summaryCacheStats !== void 0) options.onSummaryCacheStats?.(result.summaryCacheStats);
37678
37951
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
37679
37952
  const diagnostics = [];
37680
37953
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -37731,6 +38004,13 @@ const checkDeadCode = async (options) => {
37731
38004
  category: DEAD_CODE_CATEGORY
37732
38005
  });
37733
38006
  }
38007
+ if (cacheKey !== null && fileStatsSnapshot !== null) storeDeadCodeResultCache({
38008
+ cacheDirectory: resolveReactDoctorCacheDir(rootDirectory),
38009
+ cacheKey,
38010
+ rootDirectory,
38011
+ snapshotFileStats: fileStatsSnapshot,
38012
+ diagnostics
38013
+ });
37734
38014
  return diagnostics;
37735
38015
  };
37736
38016
  const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
@@ -37833,12 +38113,16 @@ var Config = class Config extends Service()("react-doctor/Config") {
37833
38113
  */
37834
38114
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
37835
38115
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
38116
+ const cacheEnabled = yield* DeadCodeResultCacheEnabled;
37836
38117
  return yield* tryPromise({
37837
38118
  try: (signal) => checkDeadCode({
37838
38119
  rootDirectory: input.rootDirectory,
37839
38120
  parseConcurrency: input.parseConcurrency,
37840
38121
  workerTimeoutMs: input.workerTimeoutMs,
37841
- abortSignal: signal
38122
+ abortSignal: signal,
38123
+ cacheEnabled,
38124
+ onCacheOutcome: input.onCacheOutcome,
38125
+ onSummaryCacheStats: input.onSummaryCacheStats
37842
38126
  }),
37843
38127
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
37844
38128
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -38708,7 +38992,7 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
38708
38992
  }
38709
38993
  return enabled;
38710
38994
  };
38711
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection }) => {
38995
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection, sidecarRuleIdFilter }) => {
38712
38996
  const reactHooksJsPlugin = disableReactHooksJsPlugin || ruleSelection === "sidecar" ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
38713
38997
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
38714
38998
  const jsPlugins = [];
@@ -38720,6 +39004,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38720
39004
  if (!rule) continue;
38721
39005
  if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38722
39006
  if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
39007
+ if (ruleSelection === "sidecar" && sidecarRuleIdFilter !== void 0 && !sidecarRuleIdFilter.has(registryEntry.id)) continue;
38723
39008
  if (rule.scan !== void 0) continue;
38724
39009
  if (customRulesOnly && registryEntry.originallyExternal) continue;
38725
39010
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -38764,33 +39049,145 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38764
39049
  }
38765
39050
  };
38766
39051
  };
38767
- const atomicWriteJson = (filePath, value) => {
39052
+ const validateDiagnostic$1 = decodeUnknownSync(Diagnostic);
39053
+ const decodeFileDiagnostics = (raw) => {
39054
+ if (!Array.isArray(raw)) return null;
38768
39055
  try {
38769
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
38770
- const temporaryPath = `${filePath}.${process.pid}.tmp`;
38771
- NFS.writeFileSync(temporaryPath, JSON.stringify(value));
38772
- NFS.renameSync(temporaryPath, filePath);
39056
+ for (const entry of raw) validateDiagnostic$1(entry);
39057
+ return raw;
38773
39058
  } catch {
38774
- return;
39059
+ return null;
38775
39060
  }
38776
39061
  };
38777
- const failOpenReadJson = (filePath, fallback) => {
38778
- try {
38779
- return JSON.parse(NFS.readFileSync(filePath, "utf8"));
38780
- } catch {
38781
- return fallback;
39062
+ const emptyCache$1 = () => ({
39063
+ version: 1,
39064
+ rulesets: {}
39065
+ });
39066
+ const loadRulesetEntries$1 = (cacheFilePath, rulesetHash) => {
39067
+ const entries = /* @__PURE__ */ new Map();
39068
+ const persisted = failOpenReadJson(cacheFilePath, emptyCache$1());
39069
+ if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
39070
+ const bucket = persisted.rulesets[rulesetHash];
39071
+ if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
39072
+ for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
39073
+ const decoded = decodeFileDiagnostics(rawDiagnostics);
39074
+ if (decoded !== null) entries.set(fileKey, decoded);
38782
39075
  }
39076
+ return entries;
39077
+ };
39078
+ const createFileLintCache = (cacheDirectory, rulesetHash) => {
39079
+ const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
39080
+ const entries = loadRulesetEntries$1(cacheFilePath, rulesetHash);
39081
+ return {
39082
+ lookup: (fileKey) => entries.get(fileKey) ?? null,
39083
+ store: (fileKey, diagnostics) => {
39084
+ entries.delete(fileKey);
39085
+ entries.set(fileKey, diagnostics);
39086
+ },
39087
+ persist: () => {
39088
+ const onDisk = failOpenReadJson(cacheFilePath, emptyCache$1());
39089
+ const rulesets = onDisk.version === 1 && isRecord(onDisk.rulesets) ? { ...onDisk.rulesets } : {};
39090
+ const existingBucket = rulesets[rulesetHash];
39091
+ const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
39092
+ const ourFiles = {};
39093
+ for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
39094
+ const cappedEntries = Object.entries({
39095
+ ...existingFiles,
39096
+ ...ourFiles
39097
+ }).slice(-FILE_LINT_CACHE_MAX_FILE_COUNT);
39098
+ rulesets[rulesetHash] = {
39099
+ updatedAtMs: Date.now(),
39100
+ files: Object.fromEntries(cappedEntries)
39101
+ };
39102
+ atomicWriteJson(cacheFilePath, {
39103
+ version: 1,
39104
+ rulesets: Object.fromEntries(Object.entries(rulesets).filter(([, bucket]) => isRecord(bucket) && typeof bucket.updatedAtMs === "number").sort(([, first], [, second]) => second.updatedAtMs - first.updatedAtMs).slice(0, 8))
39105
+ });
39106
+ }
39107
+ };
39108
+ };
39109
+ const ABSENT_CONTENT_ANSWER = "absent";
39110
+ /**
39111
+ * Answers dependency probes for the sidecar lint cache — used both when
39112
+ * STORING an entry (turn the collector's probe paths into answers) and when
39113
+ * VERIFYING one (re-answer each stored probe against the current tree).
39114
+ * Store and verify must agree exactly, so both go through this one resolver.
39115
+ *
39116
+ * - `content` — SHA-1 of the file's bytes, `"absent"` when unreadable
39117
+ * (missing, a directory, permission failure). Source files reuse the
39118
+ * hashes the cache-partition loop already computed
39119
+ * (`contentHashByRelativePath`); everything else (package.json,
39120
+ * tsconfig, ignored files) is hashed on demand.
39121
+ * - `exists` — the existence class a module-resolution candidate probe
39122
+ * sees: `"file"`, `"dir"`, or `"none"` (missing or an exotic entry —
39123
+ * `statSync` follows symlinks, matching the resolvers).
39124
+ *
39125
+ * Answers are memoized per scan: the filesystem is frozen for the run, and
39126
+ * hot dependency paths (a barrel imported by hundreds of files, the shared
39127
+ * tsconfig chain) repeat across many files' probe sets.
39128
+ */
39129
+ const createSidecarProbeAnswerResolver = (input) => {
39130
+ const contentAnswerByPath = /* @__PURE__ */ new Map();
39131
+ const existsAnswerByPath = /* @__PURE__ */ new Map();
39132
+ const toRelativePath = (absolutePath) => Path.relative(input.rootDirectory, absolutePath).replaceAll("\\", "/");
39133
+ const contentAnswer = (relativePath) => {
39134
+ const memoized = contentAnswerByPath.get(relativePath);
39135
+ if (memoized !== void 0) return memoized;
39136
+ const answer = input.contentHashByRelativePath.get(relativePath) ?? hashFileContents(Path.resolve(input.rootDirectory, relativePath)) ?? ABSENT_CONTENT_ANSWER;
39137
+ contentAnswerByPath.set(relativePath, answer);
39138
+ return answer;
39139
+ };
39140
+ const existsAnswer = (relativePath) => {
39141
+ const memoized = existsAnswerByPath.get(relativePath);
39142
+ if (memoized !== void 0) return memoized;
39143
+ let answer;
39144
+ try {
39145
+ const stat = NFS.statSync(Path.resolve(input.rootDirectory, relativePath));
39146
+ answer = stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "none";
39147
+ } catch {
39148
+ answer = "none";
39149
+ }
39150
+ existsAnswerByPath.set(relativePath, answer);
39151
+ return answer;
39152
+ };
39153
+ return {
39154
+ answerFor: (kind, relativePath) => kind === "content" ? contentAnswer(relativePath) : existsAnswer(relativePath),
39155
+ toRelativePath
39156
+ };
38783
39157
  };
38784
39158
  const validateDiagnostic = decodeUnknownSync(Diagnostic);
38785
- const decodeFileDiagnostics = (raw) => {
38786
- if (!Array.isArray(raw)) return null;
39159
+ const isProbeKind = (value) => value === "content" || value === "exists";
39160
+ const decodeSidecarFileEntry = (raw) => {
39161
+ if (!isRecord(raw) || !Array.isArray(raw.probes) || !Array.isArray(raw.diagnostics)) return null;
39162
+ const probes = [];
39163
+ for (const rawProbe of raw.probes) {
39164
+ if (!Array.isArray(rawProbe) || rawProbe.length !== 3) return null;
39165
+ const [kind, probePath, answer] = rawProbe;
39166
+ if (!isProbeKind(kind) || typeof probePath !== "string" || typeof answer !== "string") return null;
39167
+ probes.push({
39168
+ kind,
39169
+ path: probePath,
39170
+ answer
39171
+ });
39172
+ }
38787
39173
  try {
38788
- for (const entry of raw) validateDiagnostic(entry);
38789
- return raw;
39174
+ for (const entry of raw.diagnostics) validateDiagnostic(entry);
38790
39175
  } catch {
38791
39176
  return null;
38792
39177
  }
39178
+ return {
39179
+ probes,
39180
+ diagnostics: raw.diagnostics
39181
+ };
38793
39182
  };
39183
+ const encodeSidecarFileEntry = (entry) => ({
39184
+ probes: entry.probes.map((probe) => [
39185
+ probe.kind,
39186
+ probe.path,
39187
+ probe.answer
39188
+ ]),
39189
+ diagnostics: entry.diagnostics
39190
+ });
38794
39191
  const emptyCache = () => ({
38795
39192
  version: 1,
38796
39193
  rulesets: {}
@@ -38801,20 +39198,20 @@ const loadRulesetEntries = (cacheFilePath, rulesetHash) => {
38801
39198
  if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
38802
39199
  const bucket = persisted.rulesets[rulesetHash];
38803
39200
  if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
38804
- for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
38805
- const decoded = decodeFileDiagnostics(rawDiagnostics);
39201
+ for (const [fileKey, rawEntry] of Object.entries(bucket.files)) {
39202
+ const decoded = decodeSidecarFileEntry(rawEntry);
38806
39203
  if (decoded !== null) entries.set(fileKey, decoded);
38807
39204
  }
38808
39205
  return entries;
38809
39206
  };
38810
- const createFileLintCache = (cacheDirectory, rulesetHash) => {
38811
- const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
39207
+ const createSidecarLintCache = (cacheDirectory, rulesetHash) => {
39208
+ const cacheFilePath = Path.join(cacheDirectory, SIDECAR_LINT_CACHE_FILENAME);
38812
39209
  const entries = loadRulesetEntries(cacheFilePath, rulesetHash);
38813
39210
  return {
38814
39211
  lookup: (fileKey) => entries.get(fileKey) ?? null,
38815
- store: (fileKey, diagnostics) => {
39212
+ store: (fileKey, entry) => {
38816
39213
  entries.delete(fileKey);
38817
- entries.set(fileKey, diagnostics);
39214
+ entries.set(fileKey, entry);
38818
39215
  },
38819
39216
  persist: () => {
38820
39217
  const onDisk = failOpenReadJson(cacheFilePath, emptyCache());
@@ -38822,7 +39219,7 @@ const createFileLintCache = (cacheDirectory, rulesetHash) => {
38822
39219
  const existingBucket = rulesets[rulesetHash];
38823
39220
  const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
38824
39221
  const ourFiles = {};
38825
- for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
39222
+ for (const [fileKey, entry] of entries) ourFiles[fileKey] = encodeSidecarFileEntry(entry);
38826
39223
  const cappedEntries = Object.entries({
38827
39224
  ...existingFiles,
38828
39225
  ...ourFiles
@@ -39837,13 +40234,6 @@ const validateRuleRegistration = () => {
39837
40234
  ].filter((entry) => entry !== null).join("; ");
39838
40235
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
39839
40236
  };
39840
- const hashFileContents = (filePath) => {
39841
- try {
39842
- return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
39843
- } catch {
39844
- return null;
39845
- }
39846
- };
39847
40237
  /**
39848
40238
  * Balanced LPT (longest-processing-time-first) lint batch planner. Where
39849
40239
  * `batchIncludePaths` greedily fills 100-file batches and leaves a small
@@ -39908,21 +40298,6 @@ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
39908
40298
  }
39909
40299
  return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
39910
40300
  };
39911
- const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
39912
- const resolveReactDoctorCacheDir = (projectDirectory) => {
39913
- const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
39914
- if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
39915
- const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
39916
- if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
39917
- return Path.join(os.tmpdir(), `react-doctor-cache-${resolveUserCacheScope()}`, projectCacheSubdir(projectDirectory));
39918
- };
39919
- const resolveUserCacheScope = () => {
39920
- try {
39921
- return String(process.getuid?.() ?? os.userInfo().username);
39922
- } catch {
39923
- return "shared";
39924
- }
39925
- };
39926
40301
  /**
39927
40302
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39928
40303
  * the runner: once for the primary scan, once for the
@@ -39940,6 +40315,106 @@ const writeOxlintConfig = (configPath, configToWrite) => {
39940
40315
  NFS.closeSync(fileHandle);
39941
40316
  }
39942
40317
  };
40318
+ /**
40319
+ * Attributes diagnostics back to the file that produced them by the
40320
+ * normalized path oxlint echoes. Returns `null` when ANY diagnostic can't be
40321
+ * attributed — the path forms don't align, so the caller must skip its cache
40322
+ * store rather than risk caching a wrong empty result for a file that
40323
+ * actually had diagnostics.
40324
+ */
40325
+ const attributeDiagnosticsToFiles = (diagnostics, files) => {
40326
+ const fileByNormalizedPath = /* @__PURE__ */ new Map();
40327
+ for (const file of files) fileByNormalizedPath.set(file.replaceAll("\\", "/"), file);
40328
+ const diagnosticsByFile = /* @__PURE__ */ new Map();
40329
+ for (const diagnostic of diagnostics) {
40330
+ const file = fileByNormalizedPath.get(diagnostic.filePath);
40331
+ if (file === void 0) return null;
40332
+ const fileDiagnostics = diagnosticsByFile.get(file) ?? [];
40333
+ fileDiagnostics.push(diagnostic);
40334
+ diagnosticsByFile.set(file, fileDiagnostics);
40335
+ }
40336
+ return diagnosticsByFile;
40337
+ };
40338
+ /**
40339
+ * Collects each file's cross-file dependency probes (via the plugin's
40340
+ * collectors) with their current answers. Fail-open per file: an unreadable /
40341
+ * unparseable / collector-failing file maps to `null` (no entry stored — it
40342
+ * re-lints next scan). The filesystem is frozen for the scan, so this can run
40343
+ * CONCURRENTLY with an oxlint child-process await — the caller starts it
40344
+ * unawaited before a lint pass and awaits it at store time, hiding the
40345
+ * in-process parse + resolution cost under the subprocess wait. The loop
40346
+ * yields on the cooperative time budget so the child's stdout handling (and
40347
+ * sibling fibers) keep flowing; each per-file collection is synchronous, so
40348
+ * the module-level probe recorder never sees interleaved use. Never rejects.
40349
+ */
40350
+ const collectSidecarProbesForFiles = async (input) => {
40351
+ const buildProbes = (file) => {
40352
+ const absoluteFilePath = Path.resolve(input.rootDirectory, file);
40353
+ let trace;
40354
+ try {
40355
+ trace = collectCrossFileDependencyProbes({
40356
+ absoluteFilePath,
40357
+ sourceText: NFS.readFileSync(absoluteFilePath, "utf8"),
40358
+ ruleIds: input.boundedSidecarRuleIds
40359
+ });
40360
+ } catch {
40361
+ return null;
40362
+ }
40363
+ if (trace === null) return null;
40364
+ const probes = [];
40365
+ for (const contentPath of trace.contentPaths) {
40366
+ const relativePath = input.probeAnswers.toRelativePath(contentPath);
40367
+ probes.push({
40368
+ kind: "content",
40369
+ path: relativePath,
40370
+ answer: input.probeAnswers.answerFor("content", relativePath)
40371
+ });
40372
+ }
40373
+ for (const existencePath of trace.existencePaths) {
40374
+ const relativePath = input.probeAnswers.toRelativePath(existencePath);
40375
+ probes.push({
40376
+ kind: "exists",
40377
+ path: relativePath,
40378
+ answer: input.probeAnswers.answerFor("exists", relativePath)
40379
+ });
40380
+ }
40381
+ return probes;
40382
+ };
40383
+ const probesByFile = /* @__PURE__ */ new Map();
40384
+ let collectSliceStartedAt = performance.now();
40385
+ for (const file of input.files) {
40386
+ probesByFile.set(file, buildProbes(file));
40387
+ if (performance.now() - collectSliceStartedAt >= 12) {
40388
+ await yieldToEventLoop();
40389
+ collectSliceStartedAt = performance.now();
40390
+ }
40391
+ }
40392
+ return probesByFile;
40393
+ };
40394
+ /**
40395
+ * Stores each freshly-linted file's probe set + cross-file diagnostics so
40396
+ * the next scan can replay instead of re-linting.
40397
+ */
40398
+ const storeSidecarEntries = (input) => {
40399
+ let didStoreAnyEntry = false;
40400
+ for (const pass of input.storablePasses) {
40401
+ if (!pass.isTrusted) continue;
40402
+ const diagnosticsByFile = attributeDiagnosticsToFiles(pass.diagnostics, pass.files);
40403
+ if (diagnosticsByFile === null) continue;
40404
+ for (const file of pass.files) {
40405
+ const cacheKey = input.cacheKeyByFile.get(file);
40406
+ if (cacheKey === void 0) continue;
40407
+ const probes = pass.probesByFile.get(file);
40408
+ if (probes === void 0 || probes === null) continue;
40409
+ input.sidecarCache.store(cacheKey, {
40410
+ probes,
40411
+ diagnostics: diagnosticsByFile.get(file) ?? []
40412
+ });
40413
+ didStoreAnyEntry = true;
40414
+ }
40415
+ }
40416
+ if (didStoreAnyEntry) input.sidecarCache.persist();
40417
+ };
39943
40418
  const REACT_HOOKS_JS_DROP_PREFIX = "React Compiler rules (react-hooks-js/*) skipped — eslint-plugin-react-hooks failed to load in this environment";
39944
40419
  /**
39945
40420
  * Detects an oxlint config-load crash caused by the optional
@@ -39981,7 +40456,7 @@ const reactHooksJsPluginDropNote = (error) => {
39981
40456
  * 6. always restore disable directives + clean up the temp dir
39982
40457
  */
39983
40458
  const runOxlint = async (options) => {
39984
- 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;
40459
+ 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;
39985
40460
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39986
40461
  const severityControls = buildRuleSeverityControls(userConfig);
39987
40462
  validateRuleRegistration();
@@ -39999,7 +40474,8 @@ const runOxlint = async (options) => {
39999
40474
  severityControls,
40000
40475
  userPlugins,
40001
40476
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
40002
- ruleSelection: overrides.ruleSelection
40477
+ ruleSelection: overrides.ruleSelection,
40478
+ sidecarRuleIdFilter: overrides.sidecarRuleIdFilter
40003
40479
  });
40004
40480
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
40005
40481
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
@@ -40102,15 +40578,18 @@ const runOxlint = async (options) => {
40102
40578
  });
40103
40579
  const cache = createFileLintCache(resolveReactDoctorCacheDir(rootDirectory), rulesetHash);
40104
40580
  const cacheKeyByFile = /* @__PURE__ */ new Map();
40581
+ const contentHashByRelativePath = /* @__PURE__ */ new Map();
40105
40582
  const missFiles = [];
40106
40583
  const replayedDiagnostics = [];
40107
40584
  let hashSliceStartedAt = performance.now();
40108
40585
  for (const candidateFile of candidateFiles) {
40109
- const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
40586
+ const absoluteCandidatePath = Path.resolve(rootDirectory, candidateFile);
40587
+ const contentHash = hashFileContents(absoluteCandidatePath);
40110
40588
  if (contentHash === null) {
40111
40589
  missFiles.push(candidateFile);
40112
40590
  continue;
40113
40591
  }
40592
+ contentHashByRelativePath.set(Path.relative(rootDirectory, absoluteCandidatePath).replaceAll("\\", "/"), contentHash);
40114
40593
  const cacheKey = `${candidateFile.replaceAll("\\", "/")}\u0000${contentHash}`;
40115
40594
  cacheKeyByFile.set(candidateFile, cacheKey);
40116
40595
  const cachedDiagnostics = cache.lookup(cacheKey);
@@ -40122,43 +40601,111 @@ const runOxlint = async (options) => {
40122
40601
  }
40123
40602
  }
40124
40603
  const cacheHitFileCount = candidateFiles.length - missFiles.length;
40604
+ const enabledSidecarRuleIds = Object.keys(buildConfig({
40605
+ extendsPaths: [],
40606
+ ruleSelection: "sidecar"
40607
+ }).rules).map((ruleKey) => ruleKey.slice(ruleKey.lastIndexOf("/") + 1)).filter((ruleId) => CROSS_FILE_RULE_IDS.has(ruleId));
40608
+ const boundedSidecarRuleIds = enabledSidecarRuleIds.filter((ruleId) => CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId));
40609
+ const boundedSidecarRuleIdSet = new Set(boundedSidecarRuleIds);
40610
+ const unboundedSidecarRuleIds = enabledSidecarRuleIds.filter((ruleId) => !CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId));
40611
+ const useSidecarCache = sidecarLintCacheEnabled && boundedSidecarRuleIds.length > 0;
40612
+ const sidecarCache = useSidecarCache ? createSidecarLintCache(resolveReactDoctorCacheDir(rootDirectory), computeRulesetHash({
40613
+ config: buildConfig({
40614
+ extendsPaths: [],
40615
+ ruleSelection: "sidecar",
40616
+ sidecarRuleIdFilter: boundedSidecarRuleIdSet
40617
+ }),
40618
+ toolchainVersions: resolveOxlintToolchainVersions(nodeBinaryPath),
40619
+ ignorePatterns: combinedPatterns,
40620
+ tsconfigContent
40621
+ })) : null;
40622
+ const probeAnswers = createSidecarProbeAnswerResolver({
40623
+ rootDirectory,
40624
+ contentHashByRelativePath
40625
+ });
40626
+ const missProbesTask = sidecarCache === null ? null : collectSidecarProbesForFiles({
40627
+ files: missFiles,
40628
+ rootDirectory,
40629
+ boundedSidecarRuleIds,
40630
+ probeAnswers
40631
+ });
40125
40632
  const fullResult = await runConfigOverFiles((overrides) => buildConfig({
40126
40633
  extendsPaths: [],
40127
40634
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
40128
40635
  }), "oxlintrc.full.json", missFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(scannedFileCount, candidateFiles.length)));
40129
40636
  const missFileSet = new Set(missFiles);
40130
- const sidecarResult = await runConfigOverFiles(() => buildConfig({
40637
+ const hitFiles = candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile));
40638
+ const sidecarReplayedDiagnostics = [];
40639
+ const sidecarLintFiles = [];
40640
+ if (sidecarCache === null) sidecarLintFiles.push(...hitFiles);
40641
+ else {
40642
+ let verifySliceStartedAt = performance.now();
40643
+ for (const hitFile of hitFiles) {
40644
+ const cacheKey = cacheKeyByFile.get(hitFile);
40645
+ const entry = cacheKey === void 0 ? null : sidecarCache.lookup(cacheKey);
40646
+ if (entry !== null && entry.probes.every((probe) => probeAnswers.answerFor(probe.kind, probe.path) === probe.answer)) sidecarReplayedDiagnostics.push(...entry.diagnostics);
40647
+ else sidecarLintFiles.push(hitFile);
40648
+ if (performance.now() - verifySliceStartedAt >= 12) {
40649
+ await yieldToEventLoop();
40650
+ verifySliceStartedAt = performance.now();
40651
+ }
40652
+ }
40653
+ }
40654
+ const sidecarProbesTask = sidecarCache === null ? null : collectSidecarProbesForFiles({
40655
+ files: sidecarLintFiles,
40656
+ rootDirectory,
40657
+ boundedSidecarRuleIds,
40658
+ probeAnswers
40659
+ });
40660
+ const sidecarReplayedFileCount = hitFiles.length - sidecarLintFiles.length;
40661
+ if (sidecarReplayedFileCount > 0) options.onFileProgress?.(missFiles.length + sidecarReplayedFileCount, candidateFiles.length);
40662
+ const boundedSidecarResult = await runConfigOverFiles(() => buildConfig({
40131
40663
  extendsPaths: [],
40132
- ruleSelection: "sidecar"
40133
- }), "oxlintrc.sidecar.json", candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile)), options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + scannedFileCount, candidateFiles.length)));
40664
+ ruleSelection: "sidecar",
40665
+ ...useSidecarCache ? { sidecarRuleIdFilter: boundedSidecarRuleIdSet } : {}
40666
+ }), "oxlintrc.sidecar.json", sidecarLintFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + sidecarReplayedFileCount + scannedFileCount, candidateFiles.length)));
40667
+ const unboundedSidecarResult = useSidecarCache && unboundedSidecarRuleIds.length > 0 ? await runConfigOverFiles(() => buildConfig({
40668
+ extendsPaths: [],
40669
+ ruleSelection: "sidecar",
40670
+ sidecarRuleIdFilter: new Set(unboundedSidecarRuleIds)
40671
+ }), "oxlintrc.sidecar-unbounded.json", hitFiles, void 0) : {
40672
+ diagnostics: [],
40673
+ didDropReactHooksJsPlugin: false,
40674
+ hadPartialFailure: false
40675
+ };
40134
40676
  const freshCacheableDiagnostics = [];
40135
40677
  for (const diagnostic of fullResult.diagnostics) if (!CROSS_FILE_RULE_IDS.has(diagnostic.rule)) freshCacheableDiagnostics.push(diagnostic);
40136
40678
  onCacheStats?.(cacheHitFileCount, candidateFiles.length);
40137
- const missFileByNormalizedPath = /* @__PURE__ */ new Map();
40138
- for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
40139
- const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
40140
- let isAttributionSound = true;
40141
- for (const diagnostic of freshCacheableDiagnostics) {
40142
- const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
40143
- if (missFile === void 0) {
40144
- isAttributionSound = false;
40145
- break;
40146
- }
40147
- const fileDiagnostics = freshDiagnosticsByFile.get(missFile) ?? [];
40148
- fileDiagnostics.push(diagnostic);
40149
- freshDiagnosticsByFile.set(missFile, fileDiagnostics);
40150
- }
40151
- if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && isAttributionSound) {
40679
+ if (sidecarCache !== null) onSidecarStats?.(hitFiles.length - sidecarLintFiles.length, hitFiles.length);
40680
+ const freshDiagnosticsByFile = attributeDiagnosticsToFiles(freshCacheableDiagnostics, missFiles);
40681
+ if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && freshDiagnosticsByFile !== null) {
40152
40682
  for (const missFile of missFiles) {
40153
40683
  const cacheKey = cacheKeyByFile.get(missFile);
40154
40684
  if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
40155
40685
  }
40156
40686
  cache.persist();
40157
40687
  }
40688
+ if (sidecarCache !== null && missProbesTask !== null && sidecarProbesTask !== null) storeSidecarEntries({
40689
+ sidecarCache,
40690
+ cacheKeyByFile,
40691
+ storablePasses: [{
40692
+ files: sidecarLintFiles,
40693
+ diagnostics: boundedSidecarResult.diagnostics,
40694
+ isTrusted: !boundedSidecarResult.hadPartialFailure,
40695
+ probesByFile: await sidecarProbesTask
40696
+ }, {
40697
+ files: missFiles,
40698
+ diagnostics: fullResult.diagnostics.filter((diagnostic) => boundedSidecarRuleIdSet.has(diagnostic.rule)),
40699
+ isTrusted: !fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure,
40700
+ probesByFile: await missProbesTask
40701
+ }]
40702
+ });
40158
40703
  return dedupeDiagnostics([
40159
40704
  ...replayedDiagnostics,
40160
40705
  ...fullResult.diagnostics,
40161
- ...sidecarResult.diagnostics
40706
+ ...sidecarReplayedDiagnostics,
40707
+ ...boundedSidecarResult.diagnostics,
40708
+ ...unboundedSidecarResult.diagnostics
40162
40709
  ]);
40163
40710
  }
40164
40711
  const baseArgs = makeBaseArgs(configPath);
@@ -40256,6 +40803,7 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
40256
40803
  const concurrency = yield* OxlintConcurrency;
40257
40804
  const lintBatchOrdering = yield* LintBatchOrdering;
40258
40805
  const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
40806
+ const sidecarLintCacheEnabled = yield* SidecarLintCacheEnabled;
40259
40807
  const collectedFailures = [];
40260
40808
  const diagnostics = yield* tryPromise({
40261
40809
  try: (signal) => runOxlint({
@@ -40274,7 +40822,9 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
40274
40822
  },
40275
40823
  onFileProgress: input.onFileProgress,
40276
40824
  perFileLintCacheEnabled,
40825
+ sidecarLintCacheEnabled,
40277
40826
  onCacheStats: input.onCacheStats,
40827
+ onSidecarStats: input.onSidecarStats,
40278
40828
  spawnTimeoutMs,
40279
40829
  outputMaxBytes,
40280
40830
  concurrency,
@@ -40696,6 +41246,20 @@ const writeCachedSocketBody = (cacheFile, body) => {
40696
41246
  }));
40697
41247
  } catch {}
40698
41248
  };
41249
+ const pruneExpiredSocketCache = (cacheDirectory) => {
41250
+ try {
41251
+ const supplyChainCacheDirectory = Path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR);
41252
+ const expiryThresholdMs = Date.now() - SUPPLY_CHAIN_CACHE_TTL_MS;
41253
+ for (const entryName of NFS.readdirSync(supplyChainCacheDirectory)) {
41254
+ const entryPath = Path.join(supplyChainCacheDirectory, entryName);
41255
+ try {
41256
+ if (NFS.statSync(entryPath).mtimeMs < expiryThresholdMs) NFS.rmSync(entryPath);
41257
+ } catch {
41258
+ continue;
41259
+ }
41260
+ }
41261
+ } catch {}
41262
+ };
40699
41263
  const fetchSocketArtifact = (dependency, cacheDirectory) => tryPromise(async (signal) => {
40700
41264
  const cacheFile = cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency);
40701
41265
  if (cacheFile !== null) {
@@ -40823,6 +41387,7 @@ const checkSupplyChain = (input) => gen(function* () {
40823
41387
  const dependencies = collectDependenciesToScore(readPackageJson(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
40824
41388
  if (dependencies.length === 0) return [];
40825
41389
  const cacheDirectory = isSupplyChainCacheDisabled() ? null : resolveReactDoctorCacheDir(input.rootDirectory);
41390
+ if (cacheDirectory !== null) pruneExpiredSocketCache(cacheDirectory);
40826
41391
  const artifacts = yield* forEach$1(dependencies, (dependency) => fetchSocketArtifact(dependency, cacheDirectory), { concurrency: 8 }).pipe(timeoutOption(input.totalTimeoutMs ?? 9e4), map$3((maybeArtifacts) => getOrElse$1(maybeArtifacts, () => [])));
40827
41392
  const diagnostics = [];
40828
41393
  for (let index = 0; index < dependencies.length; index += 1) {
@@ -41049,7 +41614,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41049
41614
  const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
41050
41615
  rootDirectory: scanDirectory,
41051
41616
  parseConcurrency: deadCodeParseConcurrency,
41052
- workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
41617
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs,
41618
+ onCacheOutcome: (didHitCache) => {
41619
+ deadCodeCacheHit = didHitCache;
41620
+ },
41621
+ onSummaryCacheStats: (stats) => {
41622
+ deadCodeSummaryCacheHits = stats.hits;
41623
+ deadCodeSummaryCacheMisses = stats.misses;
41624
+ }
41053
41625
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
41054
41626
  yield* set(deadCodeFailure, {
41055
41627
  didFail: true,
@@ -41077,6 +41649,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41077
41649
  let lastReportedTotalFileCount = 0;
41078
41650
  let lintCacheHitFileCount = null;
41079
41651
  let lintCacheTotalFileCount = null;
41652
+ let lintSidecarReplayedFileCount = null;
41653
+ let lintSidecarTotalFileCount = null;
41654
+ let deadCodeCacheHit = null;
41655
+ let deadCodeSummaryCacheHits = null;
41656
+ let deadCodeSummaryCacheMisses = null;
41080
41657
  const baseLintStream = linterService.run({
41081
41658
  rootDirectory: scanDirectory,
41082
41659
  project,
@@ -41096,6 +41673,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41096
41673
  lintCacheHitFileCount = cacheHitFileCount;
41097
41674
  lintCacheTotalFileCount = totalConsideredFileCount;
41098
41675
  },
41676
+ onSidecarStats: (sidecarReplayedFileCount, sidecarConsideredFileCount) => {
41677
+ lintSidecarReplayedFileCount = sidecarReplayedFileCount;
41678
+ lintSidecarTotalFileCount = sidecarConsideredFileCount;
41679
+ },
41099
41680
  deadlineEpochMs: input.deadlineEpochMs
41100
41681
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
41101
41682
  yield* set(lintFailure, {
@@ -41206,6 +41787,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41206
41787
  securityScanFailed,
41207
41788
  lintCacheHitFileCount,
41208
41789
  lintCacheTotalFileCount,
41790
+ lintSidecarReplayedFileCount,
41791
+ lintSidecarTotalFileCount,
41792
+ deadCodeCacheHit: lintFailureState.didFail ? null : deadCodeCacheHit,
41793
+ deadCodeSummaryCacheHits: lintFailureState.didFail ? null : deadCodeSummaryCacheHits,
41794
+ deadCodeSummaryCacheMisses: lintFailureState.didFail ? null : deadCodeSummaryCacheMisses,
41209
41795
  suppressedRuleCounts: transform.summarizeSuppressions()
41210
41796
  };
41211
41797
  }).pipe(withSpan("runInspect", { attributes: {
@@ -41521,6 +42107,7 @@ const buildJsonReport = (input) => {
41521
42107
  const worstScoredProject = findWorstScoredProject(projects);
41522
42108
  const summary = summarizeDiagnostics(flattenedDiagnostics, worstScoredProject?.score?.score ?? null, worstScoredProject?.score?.label ?? null);
41523
42109
  const shared = {
42110
+ ...input.scans.length > 0 ? { reactDetected: input.scans.some((scan) => hasReactRuntime(scan.result.project)) } : {},
41524
42111
  version: input.version,
41525
42112
  ok: true,
41526
42113
  directory: input.directory,
@@ -41750,6 +42337,7 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
41750
42337
  skippedChecks,
41751
42338
  ...Object.keys(skippedCheckReasons).length > 0 ? { skippedCheckReasons } : {},
41752
42339
  project: output.project,
42340
+ reactDetected: hasReactRuntime(output.project),
41753
42341
  elapsedMilliseconds
41754
42342
  };
41755
42343
  };
@@ -41805,10 +42393,12 @@ const diagnoseProjectBatch = async (input) => {
41805
42393
  const startTime = globalThis.performance.now();
41806
42394
  const { projects, concurrency, config: batchConfig, ...baseOptions } = input;
41807
42395
  const projectResults = await mapWithConcurrency(projects, concurrency ?? 4, (projectDefinition) => diagnoseProject(projectDefinition, baseOptions, batchConfig));
42396
+ const succeededProjects = projectResults.filter((projectResult) => projectResult.ok);
41808
42397
  return {
41809
42398
  projects: projectResults,
41810
- diagnostics: projectResults.flatMap((projectResult) => projectResult.ok ? projectResult.diagnostics : []),
42399
+ diagnostics: succeededProjects.flatMap((projectResult) => projectResult.diagnostics),
41811
42400
  score: findWorstScore(projectResults),
42401
+ ...succeededProjects.length > 0 ? { reactDetected: succeededProjects.some((projectResult) => projectResult.reactDetected) } : {},
41812
42402
  elapsedMilliseconds: globalThis.performance.now() - startTime
41813
42403
  };
41814
42404
  };
@@ -41843,7 +42433,7 @@ const toJsonReport = (result, options) => buildJsonReport({
41843
42433
  totalElapsedMilliseconds: result.elapsedMilliseconds
41844
42434
  });
41845
42435
  //#endregion
41846
- export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
42436
+ export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, hasReactRuntime, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
41847
42437
 
41848
42438
  //# sourceMappingURL=index.js.map
41849
- //# debugId=8565c6ea-39f3-51e6-895a-ae9f7c026d0e
42439
+ //# debugId=07deeb70-f47f-5572-9930-e743f970f84f