react-doctor 0.6.3 → 0.7.0-dev.574cba6

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]="5c52dece-7b5e-5102-9381-7d05fa45734f")}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
  /**
@@ -32927,10 +32928,15 @@ const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
32927
32928
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
32928
32929
  const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
32929
32930
  const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
32931
+ const SIDECAR_LINT_CACHE_FILENAME = "sidecar-lint-cache.json";
32932
+ const CORE_PACKAGE_VERSION = "0.7.0";
32933
+ const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json";
32934
+ const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json";
32930
32935
  const SUPPLY_CHAIN_PLUGIN = "socket";
32931
32936
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
32932
32937
  const SUPPLY_CHAIN_CATEGORY = "Security";
32933
32938
  const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
32939
+ const SUPPLY_CHAIN_CACHE_TTL_MS = 864e5;
32934
32940
  const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
32935
32941
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
32936
32942
  const hasIgnoredPathSegment = (relativePath) => relativePath.split(/[/\\]/).slice(0, -1).some((segment) => IGNORED_DIRECTORIES.has(segment));
@@ -35613,6 +35619,48 @@ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintC
35613
35619
  if (CACHE_DISABLED_VALUES.has(noFileCache)) return false;
35614
35620
  return true;
35615
35621
  } }) {};
35622
+ /**
35623
+ * Whether the sidecar lint cache (`runners/oxlint/sidecar-lint-cache.ts`) is
35624
+ * active. Defaults ON — warm rescans replay the cross-file rules' cached
35625
+ * diagnostics for every file whose dependency fingerprint (the probe set the
35626
+ * plugin's collectors recorded) still matches the tree, and re-lint only the
35627
+ * rest. Only reachable when the per-file lint cache itself is on (the
35628
+ * sidecar covers its cache hits), so `REACT_DOCTOR_NO_CACHE` /
35629
+ * `REACT_DOCTOR_NO_FILE_CACHE` implicitly disable it too. Opt-OUT knobs:
35630
+ *
35631
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
35632
+ * - `REACT_DOCTOR_NO_SIDECAR_CACHE` — granular rollback hatch: keep the
35633
+ * per-file cache but run the always-fresh sidecar over every hit
35634
+ * (the pre-cache behavior).
35635
+ *
35636
+ * Tests override via `Layer.succeed(SidecarLintCacheEnabled, false)`.
35637
+ */
35638
+ var SidecarLintCacheEnabled = class extends Reference("react-doctor/SidecarLintCacheEnabled", { defaultValue: () => {
35639
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35640
+ const noSidecarCache = process.env["REACT_DOCTOR_NO_SIDECAR_CACHE"]?.toLowerCase() ?? "";
35641
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35642
+ if (CACHE_DISABLED_VALUES.has(noSidecarCache)) return false;
35643
+ return true;
35644
+ } }) {};
35645
+ /**
35646
+ * Whether the whole-project dead-code result cache
35647
+ * (`dead-code/dead-code-result-cache.ts`) is active. Defaults ON — a rescan
35648
+ * whose inputs (source tree, manifests, configs, analyzer version) are
35649
+ * unchanged replays the stored diagnostics instead of re-running the
35650
+ * analysis worker. Opt-OUT, two knobs (matching the per-file lint cache):
35651
+ *
35652
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
35653
+ * - `REACT_DOCTOR_NO_DEAD_CODE_CACHE` — granular: bust only this cache.
35654
+ *
35655
+ * Tests override via `Layer.succeed(DeadCodeResultCacheEnabled, false)`.
35656
+ */
35657
+ var DeadCodeResultCacheEnabled = class extends Reference("react-doctor/DeadCodeResultCacheEnabled", { defaultValue: () => {
35658
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35659
+ const noDeadCodeCache = process.env["REACT_DOCTOR_NO_DEAD_CODE_CACHE"]?.toLowerCase() ?? "";
35660
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35661
+ if (CACHE_DISABLED_VALUES.has(noDeadCodeCache)) return false;
35662
+ return true;
35663
+ } }) {};
35616
35664
  const DIAGNOSTIC_SURFACES = [
35617
35665
  "cli",
35618
35666
  "prComment",
@@ -37301,6 +37349,146 @@ const collectDeadCodeIgnorePatterns = (rootDirectory) => {
37301
37349
  return [...seen].filter((pattern) => pattern.length > 0);
37302
37350
  };
37303
37351
  const collectDeadCodeEntryPatterns = (rootDirectory) => [...new Set(collectKnipPatterns(rootDirectory, "entry"))].filter((pattern) => pattern.length > 0);
37352
+ const atomicWriteJson = (filePath, value) => {
37353
+ try {
37354
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
37355
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
37356
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
37357
+ NFS.renameSync(temporaryPath, filePath);
37358
+ } catch {
37359
+ return;
37360
+ }
37361
+ };
37362
+ const failOpenReadJson = (filePath, fallback) => {
37363
+ try {
37364
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
37365
+ } catch {
37366
+ return fallback;
37367
+ }
37368
+ };
37369
+ const hashFileContents = (filePath) => {
37370
+ try {
37371
+ return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
37372
+ } catch {
37373
+ return null;
37374
+ }
37375
+ };
37376
+ const ANALYZED_FILE_EXTENSIONS = new Set(DEFAULT_EXTENSIONS);
37377
+ const CORE_SIDE_MANIFEST_NAMES = ["knip.json", "deno.lock"];
37378
+ const ANALYZED_MANIFEST_NAMES = new Set([...ANALYZED_MANIFEST_FILENAMES, ...CORE_SIDE_MANIFEST_NAMES]);
37379
+ const isTsConfigLikeFile = (fileName) => (fileName.startsWith("tsconfig") || fileName.startsWith("jsconfig")) && fileName.endsWith(".json");
37380
+ const isFingerprintedFile = (fileName) => ANALYZED_FILE_EXTENSIONS.has(Path.extname(fileName).toLowerCase()) || ANALYZED_MANIFEST_NAMES.has(fileName) || isTsConfigLikeFile(fileName);
37381
+ /**
37382
+ * Stat snapshot of every file the analysis reads, keyed by root-relative
37383
+ * `/`-separated path. Taken BEFORE the (long) analysis so a stored result is
37384
+ * verified against the tree it started from.
37385
+ */
37386
+ const collectAnalyzedFileStats = (rootDirectory) => {
37387
+ const statByRelativePath = /* @__PURE__ */ new Map();
37388
+ for (const { absolutePath, name } of walkSourceTreeFiles(rootDirectory)) {
37389
+ if (!isFingerprintedFile(name)) continue;
37390
+ try {
37391
+ const fileStat = NFS.statSync(absolutePath);
37392
+ const relativePath = Path.relative(rootDirectory, absolutePath).replace(/\\/g, "/");
37393
+ statByRelativePath.set(relativePath, {
37394
+ mtimeMs: fileStat.mtimeMs,
37395
+ size: fileStat.size
37396
+ });
37397
+ } catch {}
37398
+ }
37399
+ return statByRelativePath;
37400
+ };
37401
+ const bundledRequire$2 = createRequire(import.meta.url);
37402
+ const resolveDeslopVersion = () => {
37403
+ try {
37404
+ const packageJson = JSON.parse(NFS.readFileSync(bundledRequire$2.resolve("deslop-js/package.json"), "utf8"));
37405
+ return isRecord(packageJson) && typeof packageJson.version === "string" ? packageJson.version : "unknown";
37406
+ } catch {
37407
+ return "unknown";
37408
+ }
37409
+ };
37410
+ const computeDeadCodeCacheKey = (input) => crypto.createHash("sha1").update(JSON.stringify({
37411
+ schemaVersion: 2,
37412
+ coreVersion: input.coreVersion,
37413
+ deslopVersion: resolveDeslopVersion(),
37414
+ deslopJsModuleSpecifier: input.deslopJsModuleSpecifier,
37415
+ entryPatterns: input.entryPatterns,
37416
+ ignorePatterns: input.ignorePatterns,
37417
+ tsConfigFile: input.tsConfigPath === void 0 ? null : Path.relative(input.rootDirectory, input.tsConfigPath).replace(/\\/g, "/")
37418
+ })).digest("hex");
37419
+ const validateDiagnostic$2 = decodeUnknownSync(Diagnostic);
37420
+ const decodeCachedDiagnostics = (raw) => {
37421
+ try {
37422
+ for (const entry of raw) validateDiagnostic$2(entry);
37423
+ return raw;
37424
+ } catch {
37425
+ return null;
37426
+ }
37427
+ };
37428
+ const isPersistedFileIdentity = (value) => Array.isArray(value) && value.length === 3 && typeof value[0] === "number" && typeof value[1] === "number" && typeof value[2] === "string";
37429
+ const lookupDeadCodeResultCache = (input) => {
37430
+ const cacheFilePath = Path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME);
37431
+ const persisted = failOpenReadJson(cacheFilePath, null);
37432
+ if (persisted === null || !isRecord(persisted) || persisted.version !== 2 || persisted.key !== input.cacheKey || !isRecord(persisted.files) || !Array.isArray(persisted.diagnostics)) return null;
37433
+ const storedFileEntries = Object.entries(persisted.files);
37434
+ if (storedFileEntries.length !== input.currentFileStats.size) return null;
37435
+ const repairedFiles = {};
37436
+ let repairedCount = 0;
37437
+ for (const [relativePath, storedIdentity] of storedFileEntries) {
37438
+ if (!isPersistedFileIdentity(storedIdentity)) return null;
37439
+ const currentStat = input.currentFileStats.get(relativePath);
37440
+ if (currentStat === void 0) return null;
37441
+ const [storedMtimeMs, storedSize, storedContentHash] = storedIdentity;
37442
+ if (currentStat.mtimeMs === storedMtimeMs && currentStat.size === storedSize) {
37443
+ repairedFiles[relativePath] = storedIdentity;
37444
+ continue;
37445
+ }
37446
+ if (currentStat.size !== storedSize) return null;
37447
+ const currentContentHash = hashFileContents(Path.join(input.rootDirectory, relativePath));
37448
+ if (currentContentHash === null || currentContentHash !== storedContentHash) return null;
37449
+ repairedFiles[relativePath] = [
37450
+ currentStat.mtimeMs,
37451
+ currentStat.size,
37452
+ storedContentHash
37453
+ ];
37454
+ repairedCount += 1;
37455
+ }
37456
+ const diagnostics = decodeCachedDiagnostics(persisted.diagnostics);
37457
+ if (diagnostics === null) return null;
37458
+ if (repairedCount > 0) atomicWriteJson(cacheFilePath, {
37459
+ version: 2,
37460
+ key: input.cacheKey,
37461
+ files: repairedFiles,
37462
+ diagnostics: persisted.diagnostics
37463
+ });
37464
+ return diagnostics;
37465
+ };
37466
+ const storeDeadCodeResultCache = (input) => {
37467
+ const persistedFiles = {};
37468
+ for (const [relativePath, snapshotStat] of input.snapshotFileStats) {
37469
+ const absolutePath = Path.join(input.rootDirectory, relativePath);
37470
+ const contentHash = hashFileContents(absolutePath);
37471
+ if (contentHash === null) return;
37472
+ let currentStat;
37473
+ try {
37474
+ currentStat = NFS.statSync(absolutePath);
37475
+ } catch {
37476
+ return;
37477
+ }
37478
+ if (currentStat.mtimeMs !== snapshotStat.mtimeMs || currentStat.size !== snapshotStat.size) return;
37479
+ persistedFiles[relativePath] = [
37480
+ snapshotStat.mtimeMs,
37481
+ snapshotStat.size,
37482
+ contentHash
37483
+ ];
37484
+ }
37485
+ atomicWriteJson(Path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME), {
37486
+ version: 2,
37487
+ key: input.cacheKey,
37488
+ files: persistedFiles,
37489
+ diagnostics: input.diagnostics
37490
+ });
37491
+ };
37304
37492
  const readSystemFacts = () => ({
37305
37493
  availableCores: os.availableParallelism(),
37306
37494
  totalMemoryBytes: os.totalmem(),
@@ -37369,6 +37557,21 @@ const withDeadCodeWorkerSlot = async (task, abortSignal) => {
37369
37557
  releaseSlot();
37370
37558
  }
37371
37559
  };
37560
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
37561
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
37562
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
37563
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
37564
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
37565
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
37566
+ return Path.join(os.tmpdir(), `react-doctor-cache-${resolveUserCacheScope()}`, projectCacheSubdir(projectDirectory));
37567
+ };
37568
+ const resolveUserCacheScope = () => {
37569
+ try {
37570
+ return String(process.getuid?.() ?? os.userInfo().username);
37571
+ } catch {
37572
+ return "shared";
37573
+ }
37574
+ };
37372
37575
  /**
37373
37576
  * Resolves a path to its canonical, symlink-free form, falling back to
37374
37577
  * the input when it cannot be realpath'd (broken symlink, permission
@@ -37422,6 +37625,14 @@ process.stdin.on("end", () => {
37422
37625
  circularDependencies: result.circularDependencies.map((cycle) => ({
37423
37626
  files: cycle.files,
37424
37627
  })),
37628
+ ...(result.incrementalCacheStats
37629
+ ? {
37630
+ summaryCacheStats: {
37631
+ hits: result.incrementalCacheStats.summaryHits,
37632
+ misses: result.incrementalCacheStats.summaryMisses,
37633
+ },
37634
+ }
37635
+ : {}),
37425
37636
  });
37426
37637
 
37427
37638
  const serializeError = (error) =>
@@ -37445,6 +37656,9 @@ process.stdin.on("end", () => {
37445
37656
  ...(workerInput.ignorePatterns.length > 0
37446
37657
  ? { ignorePatterns: workerInput.ignorePatterns }
37447
37658
  : {}),
37659
+ ...(workerInput.incrementalCachePath
37660
+ ? { incrementalCachePath: workerInput.incrementalCachePath }
37661
+ : {}),
37448
37662
  // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
37449
37663
  // unusedDependencies, circularDependencies). Everything else deslop can compute
37450
37664
  // is pure wasted work for us, and it's the bulk of the runtime:
@@ -37455,12 +37669,18 @@ process.stdin.on("end", () => {
37455
37669
  // are the single most expensive pass — duplicate-block detection alone was
37456
37670
  // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
37457
37671
  // speedup on a large repo.
37458
- // Both are provably safe: the consumed graph findings are computed by their own
37672
+ // - reportRedundancy: the DRY-pattern detectors (duplicate types/constants,
37673
+ // simplifiable functions, identity wrappers, …) — ~120 ms of discarded
37674
+ // output per scan, and skipping them lets the incremental cache drop the
37675
+ // DRY-pattern summary fields (the largest slice of the cache file).
37676
+ // All are provably safe: the consumed graph findings are computed by their own
37459
37677
  // 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.
37678
+ // excalidraw + mui-material + sentry; re-verified on sentry after the
37679
+ // reportRedundancy flip). tsConfigPath stays — the module resolver needs it
37680
+ // for path-alias resolution in the import graph.
37462
37681
  semantic: { enabled: false },
37463
37682
  reportCodeQuality: false,
37683
+ reportRedundancy: false,
37464
37684
  };
37465
37685
  const result = await analyze(defineConfig(config));
37466
37686
  emit({ ok: true, result: normalizeResult(result) });
@@ -37544,13 +37764,23 @@ const parseCircularDependencies = (value) => {
37544
37764
  }
37545
37765
  return circularDependencies;
37546
37766
  };
37767
+ const parseSummaryCacheStats = (value) => {
37768
+ if (!isRecord(value)) return void 0;
37769
+ if (typeof value.hits !== "number" || typeof value.misses !== "number") return void 0;
37770
+ return {
37771
+ hits: value.hits,
37772
+ misses: value.misses
37773
+ };
37774
+ };
37547
37775
  const parseDeadCodeWorkerResult = (value) => {
37548
37776
  if (!isRecord(value)) throw new Error("Dead-code worker returned an invalid result.");
37777
+ const summaryCacheStats = parseSummaryCacheStats(value.summaryCacheStats);
37549
37778
  return {
37550
37779
  unusedFiles: parseUnusedFiles(value.unusedFiles),
37551
37780
  unusedExports: parseUnusedExports(value.unusedExports),
37552
37781
  unusedDependencies: parseUnusedDependencies(value.unusedDependencies),
37553
- circularDependencies: parseCircularDependencies(value.circularDependencies)
37782
+ circularDependencies: parseCircularDependencies(value.circularDependencies),
37783
+ ...summaryCacheStats ? { summaryCacheStats } : {}
37554
37784
  };
37555
37785
  };
37556
37786
  const parseDeadCodeWorkerError = (value) => {
@@ -37664,17 +37894,41 @@ const checkDeadCode = async (options) => {
37664
37894
  if (!NFS.existsSync(Path.join(rootDirectory, "package.json"))) return [];
37665
37895
  const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
37666
37896
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory);
37897
+ const tsConfigPath = resolveTsConfigPath(rootDirectory);
37898
+ const deslopJsModuleSpecifier = options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js");
37899
+ const fileStatsSnapshot = options.cacheEnabled ? collectAnalyzedFileStats(rootDirectory) : null;
37900
+ const cacheKey = fileStatsSnapshot === null ? null : computeDeadCodeCacheKey({
37901
+ rootDirectory,
37902
+ entryPatterns,
37903
+ ignorePatterns,
37904
+ tsConfigPath,
37905
+ deslopJsModuleSpecifier,
37906
+ coreVersion: CORE_PACKAGE_VERSION
37907
+ });
37908
+ if (cacheKey !== null && fileStatsSnapshot !== null) {
37909
+ const cachedDiagnostics = lookupDeadCodeResultCache({
37910
+ cacheDirectory: resolveReactDoctorCacheDir(rootDirectory),
37911
+ cacheKey,
37912
+ rootDirectory,
37913
+ currentFileStats: fileStatsSnapshot
37914
+ });
37915
+ options.onCacheOutcome?.(cachedDiagnostics !== null);
37916
+ if (cachedDiagnostics !== null) return [...cachedDiagnostics];
37917
+ }
37918
+ const incrementalCachePath = options.cacheEnabled === true ? Path.join(resolveReactDoctorCacheDir(rootDirectory), DEAD_CODE_SUMMARY_CACHE_FILENAME) : void 0;
37667
37919
  const spawnAndRun = () => {
37668
37920
  return runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
37669
37921
  rootDirectory,
37670
37922
  entryPatterns,
37671
- tsConfigPath: resolveTsConfigPath(rootDirectory),
37923
+ tsConfigPath,
37672
37924
  ignorePatterns,
37673
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
37674
- parseConcurrency: options.parseConcurrency
37925
+ deslopJsModuleSpecifier,
37926
+ parseConcurrency: options.parseConcurrency,
37927
+ incrementalCachePath
37675
37928
  }), options.workerTimeoutMs ?? 12e4, options.abortSignal);
37676
37929
  };
37677
37930
  const result = parseDeadCodeWorkerResult(options.createWorker === void 0 ? await withDeadCodeWorkerSlot(spawnAndRun, options.abortSignal) : await spawnAndRun());
37931
+ if (result.summaryCacheStats !== void 0) options.onSummaryCacheStats?.(result.summaryCacheStats);
37678
37932
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
37679
37933
  const diagnostics = [];
37680
37934
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -37731,6 +37985,13 @@ const checkDeadCode = async (options) => {
37731
37985
  category: DEAD_CODE_CATEGORY
37732
37986
  });
37733
37987
  }
37988
+ if (cacheKey !== null && fileStatsSnapshot !== null) storeDeadCodeResultCache({
37989
+ cacheDirectory: resolveReactDoctorCacheDir(rootDirectory),
37990
+ cacheKey,
37991
+ rootDirectory,
37992
+ snapshotFileStats: fileStatsSnapshot,
37993
+ diagnostics
37994
+ });
37734
37995
  return diagnostics;
37735
37996
  };
37736
37997
  const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
@@ -37833,12 +38094,16 @@ var Config = class Config extends Service()("react-doctor/Config") {
37833
38094
  */
37834
38095
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
37835
38096
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
38097
+ const cacheEnabled = yield* DeadCodeResultCacheEnabled;
37836
38098
  return yield* tryPromise({
37837
38099
  try: (signal) => checkDeadCode({
37838
38100
  rootDirectory: input.rootDirectory,
37839
38101
  parseConcurrency: input.parseConcurrency,
37840
38102
  workerTimeoutMs: input.workerTimeoutMs,
37841
- abortSignal: signal
38103
+ abortSignal: signal,
38104
+ cacheEnabled,
38105
+ onCacheOutcome: input.onCacheOutcome,
38106
+ onSummaryCacheStats: input.onSummaryCacheStats
37842
38107
  }),
37843
38108
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
37844
38109
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -38708,7 +38973,7 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
38708
38973
  }
38709
38974
  return enabled;
38710
38975
  };
38711
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection }) => {
38976
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection, sidecarRuleIdFilter }) => {
38712
38977
  const reactHooksJsPlugin = disableReactHooksJsPlugin || ruleSelection === "sidecar" ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
38713
38978
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
38714
38979
  const jsPlugins = [];
@@ -38720,6 +38985,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38720
38985
  if (!rule) continue;
38721
38986
  if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38722
38987
  if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38988
+ if (ruleSelection === "sidecar" && sidecarRuleIdFilter !== void 0 && !sidecarRuleIdFilter.has(registryEntry.id)) continue;
38723
38989
  if (rule.scan !== void 0) continue;
38724
38990
  if (customRulesOnly && registryEntry.originallyExternal) continue;
38725
38991
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -38764,33 +39030,145 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38764
39030
  }
38765
39031
  };
38766
39032
  };
38767
- const atomicWriteJson = (filePath, value) => {
39033
+ const validateDiagnostic$1 = decodeUnknownSync(Diagnostic);
39034
+ const decodeFileDiagnostics = (raw) => {
39035
+ if (!Array.isArray(raw)) return null;
38768
39036
  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);
39037
+ for (const entry of raw) validateDiagnostic$1(entry);
39038
+ return raw;
38773
39039
  } catch {
38774
- return;
39040
+ return null;
38775
39041
  }
38776
39042
  };
38777
- const failOpenReadJson = (filePath, fallback) => {
38778
- try {
38779
- return JSON.parse(NFS.readFileSync(filePath, "utf8"));
38780
- } catch {
38781
- return fallback;
39043
+ const emptyCache$1 = () => ({
39044
+ version: 1,
39045
+ rulesets: {}
39046
+ });
39047
+ const loadRulesetEntries$1 = (cacheFilePath, rulesetHash) => {
39048
+ const entries = /* @__PURE__ */ new Map();
39049
+ const persisted = failOpenReadJson(cacheFilePath, emptyCache$1());
39050
+ if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
39051
+ const bucket = persisted.rulesets[rulesetHash];
39052
+ if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
39053
+ for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
39054
+ const decoded = decodeFileDiagnostics(rawDiagnostics);
39055
+ if (decoded !== null) entries.set(fileKey, decoded);
38782
39056
  }
39057
+ return entries;
39058
+ };
39059
+ const createFileLintCache = (cacheDirectory, rulesetHash) => {
39060
+ const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
39061
+ const entries = loadRulesetEntries$1(cacheFilePath, rulesetHash);
39062
+ return {
39063
+ lookup: (fileKey) => entries.get(fileKey) ?? null,
39064
+ store: (fileKey, diagnostics) => {
39065
+ entries.delete(fileKey);
39066
+ entries.set(fileKey, diagnostics);
39067
+ },
39068
+ persist: () => {
39069
+ const onDisk = failOpenReadJson(cacheFilePath, emptyCache$1());
39070
+ const rulesets = onDisk.version === 1 && isRecord(onDisk.rulesets) ? { ...onDisk.rulesets } : {};
39071
+ const existingBucket = rulesets[rulesetHash];
39072
+ const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
39073
+ const ourFiles = {};
39074
+ for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
39075
+ const cappedEntries = Object.entries({
39076
+ ...existingFiles,
39077
+ ...ourFiles
39078
+ }).slice(-FILE_LINT_CACHE_MAX_FILE_COUNT);
39079
+ rulesets[rulesetHash] = {
39080
+ updatedAtMs: Date.now(),
39081
+ files: Object.fromEntries(cappedEntries)
39082
+ };
39083
+ atomicWriteJson(cacheFilePath, {
39084
+ version: 1,
39085
+ rulesets: Object.fromEntries(Object.entries(rulesets).filter(([, bucket]) => isRecord(bucket) && typeof bucket.updatedAtMs === "number").sort(([, first], [, second]) => second.updatedAtMs - first.updatedAtMs).slice(0, 8))
39086
+ });
39087
+ }
39088
+ };
39089
+ };
39090
+ const ABSENT_CONTENT_ANSWER = "absent";
39091
+ /**
39092
+ * Answers dependency probes for the sidecar lint cache — used both when
39093
+ * STORING an entry (turn the collector's probe paths into answers) and when
39094
+ * VERIFYING one (re-answer each stored probe against the current tree).
39095
+ * Store and verify must agree exactly, so both go through this one resolver.
39096
+ *
39097
+ * - `content` — SHA-1 of the file's bytes, `"absent"` when unreadable
39098
+ * (missing, a directory, permission failure). Source files reuse the
39099
+ * hashes the cache-partition loop already computed
39100
+ * (`contentHashByRelativePath`); everything else (package.json,
39101
+ * tsconfig, ignored files) is hashed on demand.
39102
+ * - `exists` — the existence class a module-resolution candidate probe
39103
+ * sees: `"file"`, `"dir"`, or `"none"` (missing or an exotic entry —
39104
+ * `statSync` follows symlinks, matching the resolvers).
39105
+ *
39106
+ * Answers are memoized per scan: the filesystem is frozen for the run, and
39107
+ * hot dependency paths (a barrel imported by hundreds of files, the shared
39108
+ * tsconfig chain) repeat across many files' probe sets.
39109
+ */
39110
+ const createSidecarProbeAnswerResolver = (input) => {
39111
+ const contentAnswerByPath = /* @__PURE__ */ new Map();
39112
+ const existsAnswerByPath = /* @__PURE__ */ new Map();
39113
+ const toRelativePath = (absolutePath) => Path.relative(input.rootDirectory, absolutePath).replaceAll("\\", "/");
39114
+ const contentAnswer = (relativePath) => {
39115
+ const memoized = contentAnswerByPath.get(relativePath);
39116
+ if (memoized !== void 0) return memoized;
39117
+ const answer = input.contentHashByRelativePath.get(relativePath) ?? hashFileContents(Path.resolve(input.rootDirectory, relativePath)) ?? ABSENT_CONTENT_ANSWER;
39118
+ contentAnswerByPath.set(relativePath, answer);
39119
+ return answer;
39120
+ };
39121
+ const existsAnswer = (relativePath) => {
39122
+ const memoized = existsAnswerByPath.get(relativePath);
39123
+ if (memoized !== void 0) return memoized;
39124
+ let answer;
39125
+ try {
39126
+ const stat = NFS.statSync(Path.resolve(input.rootDirectory, relativePath));
39127
+ answer = stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "none";
39128
+ } catch {
39129
+ answer = "none";
39130
+ }
39131
+ existsAnswerByPath.set(relativePath, answer);
39132
+ return answer;
39133
+ };
39134
+ return {
39135
+ answerFor: (kind, relativePath) => kind === "content" ? contentAnswer(relativePath) : existsAnswer(relativePath),
39136
+ toRelativePath
39137
+ };
38783
39138
  };
38784
39139
  const validateDiagnostic = decodeUnknownSync(Diagnostic);
38785
- const decodeFileDiagnostics = (raw) => {
38786
- if (!Array.isArray(raw)) return null;
39140
+ const isProbeKind = (value) => value === "content" || value === "exists";
39141
+ const decodeSidecarFileEntry = (raw) => {
39142
+ if (!isRecord(raw) || !Array.isArray(raw.probes) || !Array.isArray(raw.diagnostics)) return null;
39143
+ const probes = [];
39144
+ for (const rawProbe of raw.probes) {
39145
+ if (!Array.isArray(rawProbe) || rawProbe.length !== 3) return null;
39146
+ const [kind, probePath, answer] = rawProbe;
39147
+ if (!isProbeKind(kind) || typeof probePath !== "string" || typeof answer !== "string") return null;
39148
+ probes.push({
39149
+ kind,
39150
+ path: probePath,
39151
+ answer
39152
+ });
39153
+ }
38787
39154
  try {
38788
- for (const entry of raw) validateDiagnostic(entry);
38789
- return raw;
39155
+ for (const entry of raw.diagnostics) validateDiagnostic(entry);
38790
39156
  } catch {
38791
39157
  return null;
38792
39158
  }
39159
+ return {
39160
+ probes,
39161
+ diagnostics: raw.diagnostics
39162
+ };
38793
39163
  };
39164
+ const encodeSidecarFileEntry = (entry) => ({
39165
+ probes: entry.probes.map((probe) => [
39166
+ probe.kind,
39167
+ probe.path,
39168
+ probe.answer
39169
+ ]),
39170
+ diagnostics: entry.diagnostics
39171
+ });
38794
39172
  const emptyCache = () => ({
38795
39173
  version: 1,
38796
39174
  rulesets: {}
@@ -38801,20 +39179,20 @@ const loadRulesetEntries = (cacheFilePath, rulesetHash) => {
38801
39179
  if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
38802
39180
  const bucket = persisted.rulesets[rulesetHash];
38803
39181
  if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
38804
- for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
38805
- const decoded = decodeFileDiagnostics(rawDiagnostics);
39182
+ for (const [fileKey, rawEntry] of Object.entries(bucket.files)) {
39183
+ const decoded = decodeSidecarFileEntry(rawEntry);
38806
39184
  if (decoded !== null) entries.set(fileKey, decoded);
38807
39185
  }
38808
39186
  return entries;
38809
39187
  };
38810
- const createFileLintCache = (cacheDirectory, rulesetHash) => {
38811
- const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
39188
+ const createSidecarLintCache = (cacheDirectory, rulesetHash) => {
39189
+ const cacheFilePath = Path.join(cacheDirectory, SIDECAR_LINT_CACHE_FILENAME);
38812
39190
  const entries = loadRulesetEntries(cacheFilePath, rulesetHash);
38813
39191
  return {
38814
39192
  lookup: (fileKey) => entries.get(fileKey) ?? null,
38815
- store: (fileKey, diagnostics) => {
39193
+ store: (fileKey, entry) => {
38816
39194
  entries.delete(fileKey);
38817
- entries.set(fileKey, diagnostics);
39195
+ entries.set(fileKey, entry);
38818
39196
  },
38819
39197
  persist: () => {
38820
39198
  const onDisk = failOpenReadJson(cacheFilePath, emptyCache());
@@ -38822,7 +39200,7 @@ const createFileLintCache = (cacheDirectory, rulesetHash) => {
38822
39200
  const existingBucket = rulesets[rulesetHash];
38823
39201
  const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
38824
39202
  const ourFiles = {};
38825
- for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
39203
+ for (const [fileKey, entry] of entries) ourFiles[fileKey] = encodeSidecarFileEntry(entry);
38826
39204
  const cappedEntries = Object.entries({
38827
39205
  ...existingFiles,
38828
39206
  ...ourFiles
@@ -39837,13 +40215,6 @@ const validateRuleRegistration = () => {
39837
40215
  ].filter((entry) => entry !== null).join("; ");
39838
40216
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
39839
40217
  };
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
40218
  /**
39848
40219
  * Balanced LPT (longest-processing-time-first) lint batch planner. Where
39849
40220
  * `batchIncludePaths` greedily fills 100-file batches and leaves a small
@@ -39908,21 +40279,6 @@ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
39908
40279
  }
39909
40280
  return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
39910
40281
  };
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
40282
  /**
39927
40283
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39928
40284
  * the runner: once for the primary scan, once for the
@@ -39940,6 +40296,106 @@ const writeOxlintConfig = (configPath, configToWrite) => {
39940
40296
  NFS.closeSync(fileHandle);
39941
40297
  }
39942
40298
  };
40299
+ /**
40300
+ * Attributes diagnostics back to the file that produced them by the
40301
+ * normalized path oxlint echoes. Returns `null` when ANY diagnostic can't be
40302
+ * attributed — the path forms don't align, so the caller must skip its cache
40303
+ * store rather than risk caching a wrong empty result for a file that
40304
+ * actually had diagnostics.
40305
+ */
40306
+ const attributeDiagnosticsToFiles = (diagnostics, files) => {
40307
+ const fileByNormalizedPath = /* @__PURE__ */ new Map();
40308
+ for (const file of files) fileByNormalizedPath.set(file.replaceAll("\\", "/"), file);
40309
+ const diagnosticsByFile = /* @__PURE__ */ new Map();
40310
+ for (const diagnostic of diagnostics) {
40311
+ const file = fileByNormalizedPath.get(diagnostic.filePath);
40312
+ if (file === void 0) return null;
40313
+ const fileDiagnostics = diagnosticsByFile.get(file) ?? [];
40314
+ fileDiagnostics.push(diagnostic);
40315
+ diagnosticsByFile.set(file, fileDiagnostics);
40316
+ }
40317
+ return diagnosticsByFile;
40318
+ };
40319
+ /**
40320
+ * Collects each file's cross-file dependency probes (via the plugin's
40321
+ * collectors) with their current answers. Fail-open per file: an unreadable /
40322
+ * unparseable / collector-failing file maps to `null` (no entry stored — it
40323
+ * re-lints next scan). The filesystem is frozen for the scan, so this can run
40324
+ * CONCURRENTLY with an oxlint child-process await — the caller starts it
40325
+ * unawaited before a lint pass and awaits it at store time, hiding the
40326
+ * in-process parse + resolution cost under the subprocess wait. The loop
40327
+ * yields on the cooperative time budget so the child's stdout handling (and
40328
+ * sibling fibers) keep flowing; each per-file collection is synchronous, so
40329
+ * the module-level probe recorder never sees interleaved use. Never rejects.
40330
+ */
40331
+ const collectSidecarProbesForFiles = async (input) => {
40332
+ const buildProbes = (file) => {
40333
+ const absoluteFilePath = Path.resolve(input.rootDirectory, file);
40334
+ let trace;
40335
+ try {
40336
+ trace = collectCrossFileDependencyProbes({
40337
+ absoluteFilePath,
40338
+ sourceText: NFS.readFileSync(absoluteFilePath, "utf8"),
40339
+ ruleIds: input.boundedSidecarRuleIds
40340
+ });
40341
+ } catch {
40342
+ return null;
40343
+ }
40344
+ if (trace === null) return null;
40345
+ const probes = [];
40346
+ for (const contentPath of trace.contentPaths) {
40347
+ const relativePath = input.probeAnswers.toRelativePath(contentPath);
40348
+ probes.push({
40349
+ kind: "content",
40350
+ path: relativePath,
40351
+ answer: input.probeAnswers.answerFor("content", relativePath)
40352
+ });
40353
+ }
40354
+ for (const existencePath of trace.existencePaths) {
40355
+ const relativePath = input.probeAnswers.toRelativePath(existencePath);
40356
+ probes.push({
40357
+ kind: "exists",
40358
+ path: relativePath,
40359
+ answer: input.probeAnswers.answerFor("exists", relativePath)
40360
+ });
40361
+ }
40362
+ return probes;
40363
+ };
40364
+ const probesByFile = /* @__PURE__ */ new Map();
40365
+ let collectSliceStartedAt = performance.now();
40366
+ for (const file of input.files) {
40367
+ probesByFile.set(file, buildProbes(file));
40368
+ if (performance.now() - collectSliceStartedAt >= 12) {
40369
+ await yieldToEventLoop();
40370
+ collectSliceStartedAt = performance.now();
40371
+ }
40372
+ }
40373
+ return probesByFile;
40374
+ };
40375
+ /**
40376
+ * Stores each freshly-linted file's probe set + cross-file diagnostics so
40377
+ * the next scan can replay instead of re-linting.
40378
+ */
40379
+ const storeSidecarEntries = (input) => {
40380
+ let didStoreAnyEntry = false;
40381
+ for (const pass of input.storablePasses) {
40382
+ if (!pass.isTrusted) continue;
40383
+ const diagnosticsByFile = attributeDiagnosticsToFiles(pass.diagnostics, pass.files);
40384
+ if (diagnosticsByFile === null) continue;
40385
+ for (const file of pass.files) {
40386
+ const cacheKey = input.cacheKeyByFile.get(file);
40387
+ if (cacheKey === void 0) continue;
40388
+ const probes = pass.probesByFile.get(file);
40389
+ if (probes === void 0 || probes === null) continue;
40390
+ input.sidecarCache.store(cacheKey, {
40391
+ probes,
40392
+ diagnostics: diagnosticsByFile.get(file) ?? []
40393
+ });
40394
+ didStoreAnyEntry = true;
40395
+ }
40396
+ }
40397
+ if (didStoreAnyEntry) input.sidecarCache.persist();
40398
+ };
39943
40399
  const REACT_HOOKS_JS_DROP_PREFIX = "React Compiler rules (react-hooks-js/*) skipped — eslint-plugin-react-hooks failed to load in this environment";
39944
40400
  /**
39945
40401
  * Detects an oxlint config-load crash caused by the optional
@@ -39981,7 +40437,7 @@ const reactHooksJsPluginDropNote = (error) => {
39981
40437
  * 6. always restore disable directives + clean up the temp dir
39982
40438
  */
39983
40439
  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;
40440
+ 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
40441
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39986
40442
  const severityControls = buildRuleSeverityControls(userConfig);
39987
40443
  validateRuleRegistration();
@@ -39999,7 +40455,8 @@ const runOxlint = async (options) => {
39999
40455
  severityControls,
40000
40456
  userPlugins,
40001
40457
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
40002
- ruleSelection: overrides.ruleSelection
40458
+ ruleSelection: overrides.ruleSelection,
40459
+ sidecarRuleIdFilter: overrides.sidecarRuleIdFilter
40003
40460
  });
40004
40461
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
40005
40462
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
@@ -40102,15 +40559,18 @@ const runOxlint = async (options) => {
40102
40559
  });
40103
40560
  const cache = createFileLintCache(resolveReactDoctorCacheDir(rootDirectory), rulesetHash);
40104
40561
  const cacheKeyByFile = /* @__PURE__ */ new Map();
40562
+ const contentHashByRelativePath = /* @__PURE__ */ new Map();
40105
40563
  const missFiles = [];
40106
40564
  const replayedDiagnostics = [];
40107
40565
  let hashSliceStartedAt = performance.now();
40108
40566
  for (const candidateFile of candidateFiles) {
40109
- const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
40567
+ const absoluteCandidatePath = Path.resolve(rootDirectory, candidateFile);
40568
+ const contentHash = hashFileContents(absoluteCandidatePath);
40110
40569
  if (contentHash === null) {
40111
40570
  missFiles.push(candidateFile);
40112
40571
  continue;
40113
40572
  }
40573
+ contentHashByRelativePath.set(Path.relative(rootDirectory, absoluteCandidatePath).replaceAll("\\", "/"), contentHash);
40114
40574
  const cacheKey = `${candidateFile.replaceAll("\\", "/")}\u0000${contentHash}`;
40115
40575
  cacheKeyByFile.set(candidateFile, cacheKey);
40116
40576
  const cachedDiagnostics = cache.lookup(cacheKey);
@@ -40122,43 +40582,111 @@ const runOxlint = async (options) => {
40122
40582
  }
40123
40583
  }
40124
40584
  const cacheHitFileCount = candidateFiles.length - missFiles.length;
40585
+ const enabledSidecarRuleIds = Object.keys(buildConfig({
40586
+ extendsPaths: [],
40587
+ ruleSelection: "sidecar"
40588
+ }).rules).map((ruleKey) => ruleKey.slice(ruleKey.lastIndexOf("/") + 1)).filter((ruleId) => CROSS_FILE_RULE_IDS.has(ruleId));
40589
+ const boundedSidecarRuleIds = enabledSidecarRuleIds.filter((ruleId) => CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId));
40590
+ const boundedSidecarRuleIdSet = new Set(boundedSidecarRuleIds);
40591
+ const unboundedSidecarRuleIds = enabledSidecarRuleIds.filter((ruleId) => !CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId));
40592
+ const useSidecarCache = sidecarLintCacheEnabled && boundedSidecarRuleIds.length > 0;
40593
+ const sidecarCache = useSidecarCache ? createSidecarLintCache(resolveReactDoctorCacheDir(rootDirectory), computeRulesetHash({
40594
+ config: buildConfig({
40595
+ extendsPaths: [],
40596
+ ruleSelection: "sidecar",
40597
+ sidecarRuleIdFilter: boundedSidecarRuleIdSet
40598
+ }),
40599
+ toolchainVersions: resolveOxlintToolchainVersions(nodeBinaryPath),
40600
+ ignorePatterns: combinedPatterns,
40601
+ tsconfigContent
40602
+ })) : null;
40603
+ const probeAnswers = createSidecarProbeAnswerResolver({
40604
+ rootDirectory,
40605
+ contentHashByRelativePath
40606
+ });
40607
+ const missProbesTask = sidecarCache === null ? null : collectSidecarProbesForFiles({
40608
+ files: missFiles,
40609
+ rootDirectory,
40610
+ boundedSidecarRuleIds,
40611
+ probeAnswers
40612
+ });
40125
40613
  const fullResult = await runConfigOverFiles((overrides) => buildConfig({
40126
40614
  extendsPaths: [],
40127
40615
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
40128
40616
  }), "oxlintrc.full.json", missFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(scannedFileCount, candidateFiles.length)));
40129
40617
  const missFileSet = new Set(missFiles);
40130
- const sidecarResult = await runConfigOverFiles(() => buildConfig({
40618
+ const hitFiles = candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile));
40619
+ const sidecarReplayedDiagnostics = [];
40620
+ const sidecarLintFiles = [];
40621
+ if (sidecarCache === null) sidecarLintFiles.push(...hitFiles);
40622
+ else {
40623
+ let verifySliceStartedAt = performance.now();
40624
+ for (const hitFile of hitFiles) {
40625
+ const cacheKey = cacheKeyByFile.get(hitFile);
40626
+ const entry = cacheKey === void 0 ? null : sidecarCache.lookup(cacheKey);
40627
+ if (entry !== null && entry.probes.every((probe) => probeAnswers.answerFor(probe.kind, probe.path) === probe.answer)) sidecarReplayedDiagnostics.push(...entry.diagnostics);
40628
+ else sidecarLintFiles.push(hitFile);
40629
+ if (performance.now() - verifySliceStartedAt >= 12) {
40630
+ await yieldToEventLoop();
40631
+ verifySliceStartedAt = performance.now();
40632
+ }
40633
+ }
40634
+ }
40635
+ const sidecarProbesTask = sidecarCache === null ? null : collectSidecarProbesForFiles({
40636
+ files: sidecarLintFiles,
40637
+ rootDirectory,
40638
+ boundedSidecarRuleIds,
40639
+ probeAnswers
40640
+ });
40641
+ const sidecarReplayedFileCount = hitFiles.length - sidecarLintFiles.length;
40642
+ if (sidecarReplayedFileCount > 0) options.onFileProgress?.(missFiles.length + sidecarReplayedFileCount, candidateFiles.length);
40643
+ const boundedSidecarResult = await runConfigOverFiles(() => buildConfig({
40131
40644
  extendsPaths: [],
40132
- ruleSelection: "sidecar"
40133
- }), "oxlintrc.sidecar.json", candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile)), options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + scannedFileCount, candidateFiles.length)));
40645
+ ruleSelection: "sidecar",
40646
+ ...useSidecarCache ? { sidecarRuleIdFilter: boundedSidecarRuleIdSet } : {}
40647
+ }), "oxlintrc.sidecar.json", sidecarLintFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + sidecarReplayedFileCount + scannedFileCount, candidateFiles.length)));
40648
+ const unboundedSidecarResult = useSidecarCache && unboundedSidecarRuleIds.length > 0 ? await runConfigOverFiles(() => buildConfig({
40649
+ extendsPaths: [],
40650
+ ruleSelection: "sidecar",
40651
+ sidecarRuleIdFilter: new Set(unboundedSidecarRuleIds)
40652
+ }), "oxlintrc.sidecar-unbounded.json", hitFiles, void 0) : {
40653
+ diagnostics: [],
40654
+ didDropReactHooksJsPlugin: false,
40655
+ hadPartialFailure: false
40656
+ };
40134
40657
  const freshCacheableDiagnostics = [];
40135
40658
  for (const diagnostic of fullResult.diagnostics) if (!CROSS_FILE_RULE_IDS.has(diagnostic.rule)) freshCacheableDiagnostics.push(diagnostic);
40136
40659
  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) {
40660
+ if (sidecarCache !== null) onSidecarStats?.(hitFiles.length - sidecarLintFiles.length, hitFiles.length);
40661
+ const freshDiagnosticsByFile = attributeDiagnosticsToFiles(freshCacheableDiagnostics, missFiles);
40662
+ if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && freshDiagnosticsByFile !== null) {
40152
40663
  for (const missFile of missFiles) {
40153
40664
  const cacheKey = cacheKeyByFile.get(missFile);
40154
40665
  if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
40155
40666
  }
40156
40667
  cache.persist();
40157
40668
  }
40669
+ if (sidecarCache !== null && missProbesTask !== null && sidecarProbesTask !== null) storeSidecarEntries({
40670
+ sidecarCache,
40671
+ cacheKeyByFile,
40672
+ storablePasses: [{
40673
+ files: sidecarLintFiles,
40674
+ diagnostics: boundedSidecarResult.diagnostics,
40675
+ isTrusted: !boundedSidecarResult.hadPartialFailure,
40676
+ probesByFile: await sidecarProbesTask
40677
+ }, {
40678
+ files: missFiles,
40679
+ diagnostics: fullResult.diagnostics.filter((diagnostic) => boundedSidecarRuleIdSet.has(diagnostic.rule)),
40680
+ isTrusted: !fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure,
40681
+ probesByFile: await missProbesTask
40682
+ }]
40683
+ });
40158
40684
  return dedupeDiagnostics([
40159
40685
  ...replayedDiagnostics,
40160
40686
  ...fullResult.diagnostics,
40161
- ...sidecarResult.diagnostics
40687
+ ...sidecarReplayedDiagnostics,
40688
+ ...boundedSidecarResult.diagnostics,
40689
+ ...unboundedSidecarResult.diagnostics
40162
40690
  ]);
40163
40691
  }
40164
40692
  const baseArgs = makeBaseArgs(configPath);
@@ -40256,6 +40784,7 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
40256
40784
  const concurrency = yield* OxlintConcurrency;
40257
40785
  const lintBatchOrdering = yield* LintBatchOrdering;
40258
40786
  const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
40787
+ const sidecarLintCacheEnabled = yield* SidecarLintCacheEnabled;
40259
40788
  const collectedFailures = [];
40260
40789
  const diagnostics = yield* tryPromise({
40261
40790
  try: (signal) => runOxlint({
@@ -40274,7 +40803,9 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
40274
40803
  },
40275
40804
  onFileProgress: input.onFileProgress,
40276
40805
  perFileLintCacheEnabled,
40806
+ sidecarLintCacheEnabled,
40277
40807
  onCacheStats: input.onCacheStats,
40808
+ onSidecarStats: input.onSidecarStats,
40278
40809
  spawnTimeoutMs,
40279
40810
  outputMaxBytes,
40280
40811
  concurrency,
@@ -40696,6 +41227,20 @@ const writeCachedSocketBody = (cacheFile, body) => {
40696
41227
  }));
40697
41228
  } catch {}
40698
41229
  };
41230
+ const pruneExpiredSocketCache = (cacheDirectory) => {
41231
+ try {
41232
+ const supplyChainCacheDirectory = Path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR);
41233
+ const expiryThresholdMs = Date.now() - SUPPLY_CHAIN_CACHE_TTL_MS;
41234
+ for (const entryName of NFS.readdirSync(supplyChainCacheDirectory)) {
41235
+ const entryPath = Path.join(supplyChainCacheDirectory, entryName);
41236
+ try {
41237
+ if (NFS.statSync(entryPath).mtimeMs < expiryThresholdMs) NFS.rmSync(entryPath);
41238
+ } catch {
41239
+ continue;
41240
+ }
41241
+ }
41242
+ } catch {}
41243
+ };
40699
41244
  const fetchSocketArtifact = (dependency, cacheDirectory) => tryPromise(async (signal) => {
40700
41245
  const cacheFile = cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency);
40701
41246
  if (cacheFile !== null) {
@@ -40823,6 +41368,7 @@ const checkSupplyChain = (input) => gen(function* () {
40823
41368
  const dependencies = collectDependenciesToScore(readPackageJson(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
40824
41369
  if (dependencies.length === 0) return [];
40825
41370
  const cacheDirectory = isSupplyChainCacheDisabled() ? null : resolveReactDoctorCacheDir(input.rootDirectory);
41371
+ if (cacheDirectory !== null) pruneExpiredSocketCache(cacheDirectory);
40826
41372
  const artifacts = yield* forEach$1(dependencies, (dependency) => fetchSocketArtifact(dependency, cacheDirectory), { concurrency: 8 }).pipe(timeoutOption(input.totalTimeoutMs ?? 9e4), map$3((maybeArtifacts) => getOrElse$1(maybeArtifacts, () => [])));
40827
41373
  const diagnostics = [];
40828
41374
  for (let index = 0; index < dependencies.length; index += 1) {
@@ -41049,7 +41595,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41049
41595
  const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
41050
41596
  rootDirectory: scanDirectory,
41051
41597
  parseConcurrency: deadCodeParseConcurrency,
41052
- workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
41598
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs,
41599
+ onCacheOutcome: (didHitCache) => {
41600
+ deadCodeCacheHit = didHitCache;
41601
+ },
41602
+ onSummaryCacheStats: (stats) => {
41603
+ deadCodeSummaryCacheHits = stats.hits;
41604
+ deadCodeSummaryCacheMisses = stats.misses;
41605
+ }
41053
41606
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
41054
41607
  yield* set(deadCodeFailure, {
41055
41608
  didFail: true,
@@ -41077,6 +41630,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41077
41630
  let lastReportedTotalFileCount = 0;
41078
41631
  let lintCacheHitFileCount = null;
41079
41632
  let lintCacheTotalFileCount = null;
41633
+ let lintSidecarReplayedFileCount = null;
41634
+ let lintSidecarTotalFileCount = null;
41635
+ let deadCodeCacheHit = null;
41636
+ let deadCodeSummaryCacheHits = null;
41637
+ let deadCodeSummaryCacheMisses = null;
41080
41638
  const baseLintStream = linterService.run({
41081
41639
  rootDirectory: scanDirectory,
41082
41640
  project,
@@ -41096,6 +41654,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41096
41654
  lintCacheHitFileCount = cacheHitFileCount;
41097
41655
  lintCacheTotalFileCount = totalConsideredFileCount;
41098
41656
  },
41657
+ onSidecarStats: (sidecarReplayedFileCount, sidecarConsideredFileCount) => {
41658
+ lintSidecarReplayedFileCount = sidecarReplayedFileCount;
41659
+ lintSidecarTotalFileCount = sidecarConsideredFileCount;
41660
+ },
41099
41661
  deadlineEpochMs: input.deadlineEpochMs
41100
41662
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
41101
41663
  yield* set(lintFailure, {
@@ -41206,6 +41768,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41206
41768
  securityScanFailed,
41207
41769
  lintCacheHitFileCount,
41208
41770
  lintCacheTotalFileCount,
41771
+ lintSidecarReplayedFileCount,
41772
+ lintSidecarTotalFileCount,
41773
+ deadCodeCacheHit: lintFailureState.didFail ? null : deadCodeCacheHit,
41774
+ deadCodeSummaryCacheHits: lintFailureState.didFail ? null : deadCodeSummaryCacheHits,
41775
+ deadCodeSummaryCacheMisses: lintFailureState.didFail ? null : deadCodeSummaryCacheMisses,
41209
41776
  suppressedRuleCounts: transform.summarizeSuppressions()
41210
41777
  };
41211
41778
  }).pipe(withSpan("runInspect", { attributes: {
@@ -41846,4 +42413,4 @@ const toJsonReport = (result, options) => buildJsonReport({
41846
42413
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
41847
42414
 
41848
42415
  //# sourceMappingURL=index.js.map
41849
- //# debugId=8565c6ea-39f3-51e6-895a-ae9f7c026d0e
42416
+ //# debugId=5c52dece-7b5e-5102-9381-7d05fa45734f