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/lsp.js CHANGED
@@ -8,13 +8,14 @@ import path from "node:path";
8
8
  import * as NodeChildProcess from "node:child_process";
9
9
  import { execFileSync, spawn, spawnSync } from "node:child_process";
10
10
  import * as ts from "typescript";
11
- import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, CROSS_FILE_RULE_IDS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, REACT_DOCTOR_RULES, classifySecurityScanFile, shouldReadSecurityScanContent } from "oxlint-plugin-react-doctor";
11
+ import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, CROSS_FILE_DEPENDENCY_COLLECTORS, CROSS_FILE_RULE_IDS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, REACT_DOCTOR_RULES, classifySecurityScanFile, collectCrossFileDependencyProbes, shouldReadSecurityScanContent } from "oxlint-plugin-react-doctor";
12
12
  import { parseJSON5 } from "confbox";
13
13
  import * as NodeUrl from "node:url";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { createJiti } from "jiti";
16
16
  import * as Crypto from "node:crypto";
17
17
  import crypto, { createHash } from "node:crypto";
18
+ import { ANALYZED_MANIFEST_FILENAMES, DEFAULT_EXTENSIONS } from "deslop-js/analyzed-inputs";
18
19
  import { gzipSync } from "node:zlib";
19
20
  import { CodeActionKind, CodeActionTriggerKind, DidChangeWatchedFilesNotification, DocumentDiagnosticReportKind, FileChangeType, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node.js";
20
21
  import { TextDocument } from "vscode-languageserver-textdocument";
@@ -32986,10 +32987,15 @@ const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
32986
32987
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
32987
32988
  const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
32988
32989
  const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
32990
+ const SIDECAR_LINT_CACHE_FILENAME = "sidecar-lint-cache.json";
32991
+ const CORE_PACKAGE_VERSION = "0.7.0";
32992
+ const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json";
32993
+ const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json";
32989
32994
  const SUPPLY_CHAIN_PLUGIN = "socket";
32990
32995
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
32991
32996
  const SUPPLY_CHAIN_CATEGORY = "Security";
32992
32997
  const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
32998
+ const SUPPLY_CHAIN_CACHE_TTL_MS = 864e5;
32993
32999
  const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
32994
33000
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
32995
33001
  const hasIgnoredPathSegment = (relativePath) => relativePath.split(/[/\\]/).slice(0, -1).some((segment) => IGNORED_DIRECTORIES.has(segment));
@@ -35646,6 +35652,48 @@ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintC
35646
35652
  if (CACHE_DISABLED_VALUES.has(noFileCache)) return false;
35647
35653
  return true;
35648
35654
  } }) {};
35655
+ /**
35656
+ * Whether the sidecar lint cache (`runners/oxlint/sidecar-lint-cache.ts`) is
35657
+ * active. Defaults ON — warm rescans replay the cross-file rules' cached
35658
+ * diagnostics for every file whose dependency fingerprint (the probe set the
35659
+ * plugin's collectors recorded) still matches the tree, and re-lint only the
35660
+ * rest. Only reachable when the per-file lint cache itself is on (the
35661
+ * sidecar covers its cache hits), so `REACT_DOCTOR_NO_CACHE` /
35662
+ * `REACT_DOCTOR_NO_FILE_CACHE` implicitly disable it too. Opt-OUT knobs:
35663
+ *
35664
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
35665
+ * - `REACT_DOCTOR_NO_SIDECAR_CACHE` — granular rollback hatch: keep the
35666
+ * per-file cache but run the always-fresh sidecar over every hit
35667
+ * (the pre-cache behavior).
35668
+ *
35669
+ * Tests override via `Layer.succeed(SidecarLintCacheEnabled, false)`.
35670
+ */
35671
+ var SidecarLintCacheEnabled = class extends Reference("react-doctor/SidecarLintCacheEnabled", { defaultValue: () => {
35672
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35673
+ const noSidecarCache = process.env["REACT_DOCTOR_NO_SIDECAR_CACHE"]?.toLowerCase() ?? "";
35674
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35675
+ if (CACHE_DISABLED_VALUES.has(noSidecarCache)) return false;
35676
+ return true;
35677
+ } }) {};
35678
+ /**
35679
+ * Whether the whole-project dead-code result cache
35680
+ * (`dead-code/dead-code-result-cache.ts`) is active. Defaults ON — a rescan
35681
+ * whose inputs (source tree, manifests, configs, analyzer version) are
35682
+ * unchanged replays the stored diagnostics instead of re-running the
35683
+ * analysis worker. Opt-OUT, two knobs (matching the per-file lint cache):
35684
+ *
35685
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
35686
+ * - `REACT_DOCTOR_NO_DEAD_CODE_CACHE` — granular: bust only this cache.
35687
+ *
35688
+ * Tests override via `Layer.succeed(DeadCodeResultCacheEnabled, false)`.
35689
+ */
35690
+ var DeadCodeResultCacheEnabled = class extends Reference("react-doctor/DeadCodeResultCacheEnabled", { defaultValue: () => {
35691
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35692
+ const noDeadCodeCache = process.env["REACT_DOCTOR_NO_DEAD_CODE_CACHE"]?.toLowerCase() ?? "";
35693
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35694
+ if (CACHE_DISABLED_VALUES.has(noDeadCodeCache)) return false;
35695
+ return true;
35696
+ } }) {};
35649
35697
  const DIAGNOSTIC_SURFACES = [
35650
35698
  "cli",
35651
35699
  "prComment",
@@ -37286,6 +37334,146 @@ const collectDeadCodeIgnorePatterns = (rootDirectory) => {
37286
37334
  return [...seen].filter((pattern) => pattern.length > 0);
37287
37335
  };
37288
37336
  const collectDeadCodeEntryPatterns = (rootDirectory) => [...new Set(collectKnipPatterns(rootDirectory, "entry"))].filter((pattern) => pattern.length > 0);
37337
+ const atomicWriteJson = (filePath, value) => {
37338
+ try {
37339
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
37340
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
37341
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
37342
+ NFS.renameSync(temporaryPath, filePath);
37343
+ } catch {
37344
+ return;
37345
+ }
37346
+ };
37347
+ const failOpenReadJson = (filePath, fallback) => {
37348
+ try {
37349
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
37350
+ } catch {
37351
+ return fallback;
37352
+ }
37353
+ };
37354
+ const hashFileContents = (filePath) => {
37355
+ try {
37356
+ return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
37357
+ } catch {
37358
+ return null;
37359
+ }
37360
+ };
37361
+ const ANALYZED_FILE_EXTENSIONS = new Set(DEFAULT_EXTENSIONS);
37362
+ const CORE_SIDE_MANIFEST_NAMES = ["knip.json", "deno.lock"];
37363
+ const ANALYZED_MANIFEST_NAMES = new Set([...ANALYZED_MANIFEST_FILENAMES, ...CORE_SIDE_MANIFEST_NAMES]);
37364
+ const isTsConfigLikeFile = (fileName) => (fileName.startsWith("tsconfig") || fileName.startsWith("jsconfig")) && fileName.endsWith(".json");
37365
+ const isFingerprintedFile = (fileName) => ANALYZED_FILE_EXTENSIONS.has(Path.extname(fileName).toLowerCase()) || ANALYZED_MANIFEST_NAMES.has(fileName) || isTsConfigLikeFile(fileName);
37366
+ /**
37367
+ * Stat snapshot of every file the analysis reads, keyed by root-relative
37368
+ * `/`-separated path. Taken BEFORE the (long) analysis so a stored result is
37369
+ * verified against the tree it started from.
37370
+ */
37371
+ const collectAnalyzedFileStats = (rootDirectory) => {
37372
+ const statByRelativePath = /* @__PURE__ */ new Map();
37373
+ for (const { absolutePath, name } of walkSourceTreeFiles(rootDirectory)) {
37374
+ if (!isFingerprintedFile(name)) continue;
37375
+ try {
37376
+ const fileStat = NFS.statSync(absolutePath);
37377
+ const relativePath = Path.relative(rootDirectory, absolutePath).replace(/\\/g, "/");
37378
+ statByRelativePath.set(relativePath, {
37379
+ mtimeMs: fileStat.mtimeMs,
37380
+ size: fileStat.size
37381
+ });
37382
+ } catch {}
37383
+ }
37384
+ return statByRelativePath;
37385
+ };
37386
+ const bundledRequire$2 = createRequire(import.meta.url);
37387
+ const resolveDeslopVersion = () => {
37388
+ try {
37389
+ const packageJson = JSON.parse(NFS.readFileSync(bundledRequire$2.resolve("deslop-js/package.json"), "utf8"));
37390
+ return isRecord(packageJson) && typeof packageJson.version === "string" ? packageJson.version : "unknown";
37391
+ } catch {
37392
+ return "unknown";
37393
+ }
37394
+ };
37395
+ const computeDeadCodeCacheKey = (input) => crypto.createHash("sha1").update(JSON.stringify({
37396
+ schemaVersion: 2,
37397
+ coreVersion: input.coreVersion,
37398
+ deslopVersion: resolveDeslopVersion(),
37399
+ deslopJsModuleSpecifier: input.deslopJsModuleSpecifier,
37400
+ entryPatterns: input.entryPatterns,
37401
+ ignorePatterns: input.ignorePatterns,
37402
+ tsConfigFile: input.tsConfigPath === void 0 ? null : Path.relative(input.rootDirectory, input.tsConfigPath).replace(/\\/g, "/")
37403
+ })).digest("hex");
37404
+ const validateDiagnostic$2 = decodeUnknownSync(Diagnostic);
37405
+ const decodeCachedDiagnostics = (raw) => {
37406
+ try {
37407
+ for (const entry of raw) validateDiagnostic$2(entry);
37408
+ return raw;
37409
+ } catch {
37410
+ return null;
37411
+ }
37412
+ };
37413
+ const isPersistedFileIdentity = (value) => Array.isArray(value) && value.length === 3 && typeof value[0] === "number" && typeof value[1] === "number" && typeof value[2] === "string";
37414
+ const lookupDeadCodeResultCache = (input) => {
37415
+ const cacheFilePath = Path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME);
37416
+ const persisted = failOpenReadJson(cacheFilePath, null);
37417
+ if (persisted === null || !isRecord(persisted) || persisted.version !== 2 || persisted.key !== input.cacheKey || !isRecord(persisted.files) || !Array.isArray(persisted.diagnostics)) return null;
37418
+ const storedFileEntries = Object.entries(persisted.files);
37419
+ if (storedFileEntries.length !== input.currentFileStats.size) return null;
37420
+ const repairedFiles = {};
37421
+ let repairedCount = 0;
37422
+ for (const [relativePath, storedIdentity] of storedFileEntries) {
37423
+ if (!isPersistedFileIdentity(storedIdentity)) return null;
37424
+ const currentStat = input.currentFileStats.get(relativePath);
37425
+ if (currentStat === void 0) return null;
37426
+ const [storedMtimeMs, storedSize, storedContentHash] = storedIdentity;
37427
+ if (currentStat.mtimeMs === storedMtimeMs && currentStat.size === storedSize) {
37428
+ repairedFiles[relativePath] = storedIdentity;
37429
+ continue;
37430
+ }
37431
+ if (currentStat.size !== storedSize) return null;
37432
+ const currentContentHash = hashFileContents(Path.join(input.rootDirectory, relativePath));
37433
+ if (currentContentHash === null || currentContentHash !== storedContentHash) return null;
37434
+ repairedFiles[relativePath] = [
37435
+ currentStat.mtimeMs,
37436
+ currentStat.size,
37437
+ storedContentHash
37438
+ ];
37439
+ repairedCount += 1;
37440
+ }
37441
+ const diagnostics = decodeCachedDiagnostics(persisted.diagnostics);
37442
+ if (diagnostics === null) return null;
37443
+ if (repairedCount > 0) atomicWriteJson(cacheFilePath, {
37444
+ version: 2,
37445
+ key: input.cacheKey,
37446
+ files: repairedFiles,
37447
+ diagnostics: persisted.diagnostics
37448
+ });
37449
+ return diagnostics;
37450
+ };
37451
+ const storeDeadCodeResultCache = (input) => {
37452
+ const persistedFiles = {};
37453
+ for (const [relativePath, snapshotStat] of input.snapshotFileStats) {
37454
+ const absolutePath = Path.join(input.rootDirectory, relativePath);
37455
+ const contentHash = hashFileContents(absolutePath);
37456
+ if (contentHash === null) return;
37457
+ let currentStat;
37458
+ try {
37459
+ currentStat = NFS.statSync(absolutePath);
37460
+ } catch {
37461
+ return;
37462
+ }
37463
+ if (currentStat.mtimeMs !== snapshotStat.mtimeMs || currentStat.size !== snapshotStat.size) return;
37464
+ persistedFiles[relativePath] = [
37465
+ snapshotStat.mtimeMs,
37466
+ snapshotStat.size,
37467
+ contentHash
37468
+ ];
37469
+ }
37470
+ atomicWriteJson(Path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME), {
37471
+ version: 2,
37472
+ key: input.cacheKey,
37473
+ files: persistedFiles,
37474
+ diagnostics: input.diagnostics
37475
+ });
37476
+ };
37289
37477
  const readSystemFacts = () => ({
37290
37478
  availableCores: os.availableParallelism(),
37291
37479
  totalMemoryBytes: os.totalmem(),
@@ -37354,6 +37542,21 @@ const withDeadCodeWorkerSlot = async (task, abortSignal) => {
37354
37542
  releaseSlot();
37355
37543
  }
37356
37544
  };
37545
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
37546
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
37547
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
37548
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
37549
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
37550
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
37551
+ return Path.join(os.tmpdir(), `react-doctor-cache-${resolveUserCacheScope()}`, projectCacheSubdir(projectDirectory));
37552
+ };
37553
+ const resolveUserCacheScope = () => {
37554
+ try {
37555
+ return String(process.getuid?.() ?? os.userInfo().username);
37556
+ } catch {
37557
+ return "shared";
37558
+ }
37559
+ };
37357
37560
  /**
37358
37561
  * Resolves a path to its canonical, symlink-free form, falling back to
37359
37562
  * the input when it cannot be realpath'd (broken symlink, permission
@@ -37407,6 +37610,14 @@ process.stdin.on("end", () => {
37407
37610
  circularDependencies: result.circularDependencies.map((cycle) => ({
37408
37611
  files: cycle.files,
37409
37612
  })),
37613
+ ...(result.incrementalCacheStats
37614
+ ? {
37615
+ summaryCacheStats: {
37616
+ hits: result.incrementalCacheStats.summaryHits,
37617
+ misses: result.incrementalCacheStats.summaryMisses,
37618
+ },
37619
+ }
37620
+ : {}),
37410
37621
  });
37411
37622
 
37412
37623
  const serializeError = (error) =>
@@ -37430,6 +37641,9 @@ process.stdin.on("end", () => {
37430
37641
  ...(workerInput.ignorePatterns.length > 0
37431
37642
  ? { ignorePatterns: workerInput.ignorePatterns }
37432
37643
  : {}),
37644
+ ...(workerInput.incrementalCachePath
37645
+ ? { incrementalCachePath: workerInput.incrementalCachePath }
37646
+ : {}),
37433
37647
  // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
37434
37648
  // unusedDependencies, circularDependencies). Everything else deslop can compute
37435
37649
  // is pure wasted work for us, and it's the bulk of the runtime:
@@ -37440,12 +37654,18 @@ process.stdin.on("end", () => {
37440
37654
  // are the single most expensive pass — duplicate-block detection alone was
37441
37655
  // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
37442
37656
  // speedup on a large repo.
37443
- // Both are provably safe: the consumed graph findings are computed by their own
37657
+ // - reportRedundancy: the DRY-pattern detectors (duplicate types/constants,
37658
+ // simplifiable functions, identity wrappers, …) — ~120 ms of discarded
37659
+ // output per scan, and skipping them lets the incremental cache drop the
37660
+ // DRY-pattern summary fields (the largest slice of the cache file).
37661
+ // All are provably safe: the consumed graph findings are computed by their own
37444
37662
  // detectors, independent of these passes (confirmed byte-identical on
37445
- // excalidraw + mui-material + sentry). tsConfigPath stays — the module resolver
37446
- // needs it for path-alias resolution in the import graph.
37663
+ // excalidraw + mui-material + sentry; re-verified on sentry after the
37664
+ // reportRedundancy flip). tsConfigPath stays — the module resolver needs it
37665
+ // for path-alias resolution in the import graph.
37447
37666
  semantic: { enabled: false },
37448
37667
  reportCodeQuality: false,
37668
+ reportRedundancy: false,
37449
37669
  };
37450
37670
  const result = await analyze(defineConfig(config));
37451
37671
  emit({ ok: true, result: normalizeResult(result) });
@@ -37529,13 +37749,23 @@ const parseCircularDependencies = (value) => {
37529
37749
  }
37530
37750
  return circularDependencies;
37531
37751
  };
37752
+ const parseSummaryCacheStats = (value) => {
37753
+ if (!isRecord(value)) return void 0;
37754
+ if (typeof value.hits !== "number" || typeof value.misses !== "number") return void 0;
37755
+ return {
37756
+ hits: value.hits,
37757
+ misses: value.misses
37758
+ };
37759
+ };
37532
37760
  const parseDeadCodeWorkerResult = (value) => {
37533
37761
  if (!isRecord(value)) throw new Error("Dead-code worker returned an invalid result.");
37762
+ const summaryCacheStats = parseSummaryCacheStats(value.summaryCacheStats);
37534
37763
  return {
37535
37764
  unusedFiles: parseUnusedFiles(value.unusedFiles),
37536
37765
  unusedExports: parseUnusedExports(value.unusedExports),
37537
37766
  unusedDependencies: parseUnusedDependencies(value.unusedDependencies),
37538
- circularDependencies: parseCircularDependencies(value.circularDependencies)
37767
+ circularDependencies: parseCircularDependencies(value.circularDependencies),
37768
+ ...summaryCacheStats ? { summaryCacheStats } : {}
37539
37769
  };
37540
37770
  };
37541
37771
  const parseDeadCodeWorkerError = (value) => {
@@ -37649,17 +37879,41 @@ const checkDeadCode = async (options) => {
37649
37879
  if (!NFS.existsSync(Path.join(rootDirectory, "package.json"))) return [];
37650
37880
  const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
37651
37881
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory);
37882
+ const tsConfigPath = resolveTsConfigPath(rootDirectory);
37883
+ const deslopJsModuleSpecifier = options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js");
37884
+ const fileStatsSnapshot = options.cacheEnabled ? collectAnalyzedFileStats(rootDirectory) : null;
37885
+ const cacheKey = fileStatsSnapshot === null ? null : computeDeadCodeCacheKey({
37886
+ rootDirectory,
37887
+ entryPatterns,
37888
+ ignorePatterns,
37889
+ tsConfigPath,
37890
+ deslopJsModuleSpecifier,
37891
+ coreVersion: CORE_PACKAGE_VERSION
37892
+ });
37893
+ if (cacheKey !== null && fileStatsSnapshot !== null) {
37894
+ const cachedDiagnostics = lookupDeadCodeResultCache({
37895
+ cacheDirectory: resolveReactDoctorCacheDir(rootDirectory),
37896
+ cacheKey,
37897
+ rootDirectory,
37898
+ currentFileStats: fileStatsSnapshot
37899
+ });
37900
+ options.onCacheOutcome?.(cachedDiagnostics !== null);
37901
+ if (cachedDiagnostics !== null) return [...cachedDiagnostics];
37902
+ }
37903
+ const incrementalCachePath = options.cacheEnabled === true ? Path.join(resolveReactDoctorCacheDir(rootDirectory), DEAD_CODE_SUMMARY_CACHE_FILENAME) : void 0;
37652
37904
  const spawnAndRun = () => {
37653
37905
  return runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
37654
37906
  rootDirectory,
37655
37907
  entryPatterns,
37656
- tsConfigPath: resolveTsConfigPath(rootDirectory),
37908
+ tsConfigPath,
37657
37909
  ignorePatterns,
37658
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
37659
- parseConcurrency: options.parseConcurrency
37910
+ deslopJsModuleSpecifier,
37911
+ parseConcurrency: options.parseConcurrency,
37912
+ incrementalCachePath
37660
37913
  }), options.workerTimeoutMs ?? 12e4, options.abortSignal);
37661
37914
  };
37662
37915
  const result = parseDeadCodeWorkerResult(options.createWorker === void 0 ? await withDeadCodeWorkerSlot(spawnAndRun, options.abortSignal) : await spawnAndRun());
37916
+ if (result.summaryCacheStats !== void 0) options.onSummaryCacheStats?.(result.summaryCacheStats);
37663
37917
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
37664
37918
  const diagnostics = [];
37665
37919
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -37716,6 +37970,13 @@ const checkDeadCode = async (options) => {
37716
37970
  category: DEAD_CODE_CATEGORY
37717
37971
  });
37718
37972
  }
37973
+ if (cacheKey !== null && fileStatsSnapshot !== null) storeDeadCodeResultCache({
37974
+ cacheDirectory: resolveReactDoctorCacheDir(rootDirectory),
37975
+ cacheKey,
37976
+ rootDirectory,
37977
+ snapshotFileStats: fileStatsSnapshot,
37978
+ diagnostics
37979
+ });
37719
37980
  return diagnostics;
37720
37981
  };
37721
37982
  const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
@@ -37818,12 +38079,16 @@ var Config = class Config extends Service()("react-doctor/Config") {
37818
38079
  */
37819
38080
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
37820
38081
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
38082
+ const cacheEnabled = yield* DeadCodeResultCacheEnabled;
37821
38083
  return yield* tryPromise({
37822
38084
  try: (signal) => checkDeadCode({
37823
38085
  rootDirectory: input.rootDirectory,
37824
38086
  parseConcurrency: input.parseConcurrency,
37825
38087
  workerTimeoutMs: input.workerTimeoutMs,
37826
- abortSignal: signal
38088
+ abortSignal: signal,
38089
+ cacheEnabled,
38090
+ onCacheOutcome: input.onCacheOutcome,
38091
+ onSummaryCacheStats: input.onSummaryCacheStats
37827
38092
  }),
37828
38093
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
37829
38094
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -38693,7 +38958,7 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
38693
38958
  }
38694
38959
  return enabled;
38695
38960
  };
38696
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection }) => {
38961
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection, sidecarRuleIdFilter }) => {
38697
38962
  const reactHooksJsPlugin = disableReactHooksJsPlugin || ruleSelection === "sidecar" ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
38698
38963
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
38699
38964
  const jsPlugins = [];
@@ -38705,6 +38970,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38705
38970
  if (!rule) continue;
38706
38971
  if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38707
38972
  if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38973
+ if (ruleSelection === "sidecar" && sidecarRuleIdFilter !== void 0 && !sidecarRuleIdFilter.has(registryEntry.id)) continue;
38708
38974
  if (rule.scan !== void 0) continue;
38709
38975
  if (customRulesOnly && registryEntry.originallyExternal) continue;
38710
38976
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -38749,33 +39015,145 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38749
39015
  }
38750
39016
  };
38751
39017
  };
38752
- const atomicWriteJson = (filePath, value) => {
39018
+ const validateDiagnostic$1 = decodeUnknownSync(Diagnostic);
39019
+ const decodeFileDiagnostics = (raw) => {
39020
+ if (!Array.isArray(raw)) return null;
38753
39021
  try {
38754
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
38755
- const temporaryPath = `${filePath}.${process.pid}.tmp`;
38756
- NFS.writeFileSync(temporaryPath, JSON.stringify(value));
38757
- NFS.renameSync(temporaryPath, filePath);
39022
+ for (const entry of raw) validateDiagnostic$1(entry);
39023
+ return raw;
38758
39024
  } catch {
38759
- return;
39025
+ return null;
38760
39026
  }
38761
39027
  };
38762
- const failOpenReadJson = (filePath, fallback) => {
38763
- try {
38764
- return JSON.parse(NFS.readFileSync(filePath, "utf8"));
38765
- } catch {
38766
- return fallback;
39028
+ const emptyCache$1 = () => ({
39029
+ version: 1,
39030
+ rulesets: {}
39031
+ });
39032
+ const loadRulesetEntries$1 = (cacheFilePath, rulesetHash) => {
39033
+ const entries = /* @__PURE__ */ new Map();
39034
+ const persisted = failOpenReadJson(cacheFilePath, emptyCache$1());
39035
+ if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
39036
+ const bucket = persisted.rulesets[rulesetHash];
39037
+ if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
39038
+ for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
39039
+ const decoded = decodeFileDiagnostics(rawDiagnostics);
39040
+ if (decoded !== null) entries.set(fileKey, decoded);
38767
39041
  }
39042
+ return entries;
39043
+ };
39044
+ const createFileLintCache = (cacheDirectory, rulesetHash) => {
39045
+ const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
39046
+ const entries = loadRulesetEntries$1(cacheFilePath, rulesetHash);
39047
+ return {
39048
+ lookup: (fileKey) => entries.get(fileKey) ?? null,
39049
+ store: (fileKey, diagnostics) => {
39050
+ entries.delete(fileKey);
39051
+ entries.set(fileKey, diagnostics);
39052
+ },
39053
+ persist: () => {
39054
+ const onDisk = failOpenReadJson(cacheFilePath, emptyCache$1());
39055
+ const rulesets = onDisk.version === 1 && isRecord(onDisk.rulesets) ? { ...onDisk.rulesets } : {};
39056
+ const existingBucket = rulesets[rulesetHash];
39057
+ const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
39058
+ const ourFiles = {};
39059
+ for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
39060
+ const cappedEntries = Object.entries({
39061
+ ...existingFiles,
39062
+ ...ourFiles
39063
+ }).slice(-FILE_LINT_CACHE_MAX_FILE_COUNT);
39064
+ rulesets[rulesetHash] = {
39065
+ updatedAtMs: Date.now(),
39066
+ files: Object.fromEntries(cappedEntries)
39067
+ };
39068
+ atomicWriteJson(cacheFilePath, {
39069
+ version: 1,
39070
+ rulesets: Object.fromEntries(Object.entries(rulesets).filter(([, bucket]) => isRecord(bucket) && typeof bucket.updatedAtMs === "number").sort(([, first], [, second]) => second.updatedAtMs - first.updatedAtMs).slice(0, 8))
39071
+ });
39072
+ }
39073
+ };
39074
+ };
39075
+ const ABSENT_CONTENT_ANSWER = "absent";
39076
+ /**
39077
+ * Answers dependency probes for the sidecar lint cache — used both when
39078
+ * STORING an entry (turn the collector's probe paths into answers) and when
39079
+ * VERIFYING one (re-answer each stored probe against the current tree).
39080
+ * Store and verify must agree exactly, so both go through this one resolver.
39081
+ *
39082
+ * - `content` — SHA-1 of the file's bytes, `"absent"` when unreadable
39083
+ * (missing, a directory, permission failure). Source files reuse the
39084
+ * hashes the cache-partition loop already computed
39085
+ * (`contentHashByRelativePath`); everything else (package.json,
39086
+ * tsconfig, ignored files) is hashed on demand.
39087
+ * - `exists` — the existence class a module-resolution candidate probe
39088
+ * sees: `"file"`, `"dir"`, or `"none"` (missing or an exotic entry —
39089
+ * `statSync` follows symlinks, matching the resolvers).
39090
+ *
39091
+ * Answers are memoized per scan: the filesystem is frozen for the run, and
39092
+ * hot dependency paths (a barrel imported by hundreds of files, the shared
39093
+ * tsconfig chain) repeat across many files' probe sets.
39094
+ */
39095
+ const createSidecarProbeAnswerResolver = (input) => {
39096
+ const contentAnswerByPath = /* @__PURE__ */ new Map();
39097
+ const existsAnswerByPath = /* @__PURE__ */ new Map();
39098
+ const toRelativePath = (absolutePath) => Path.relative(input.rootDirectory, absolutePath).replaceAll("\\", "/");
39099
+ const contentAnswer = (relativePath) => {
39100
+ const memoized = contentAnswerByPath.get(relativePath);
39101
+ if (memoized !== void 0) return memoized;
39102
+ const answer = input.contentHashByRelativePath.get(relativePath) ?? hashFileContents(Path.resolve(input.rootDirectory, relativePath)) ?? ABSENT_CONTENT_ANSWER;
39103
+ contentAnswerByPath.set(relativePath, answer);
39104
+ return answer;
39105
+ };
39106
+ const existsAnswer = (relativePath) => {
39107
+ const memoized = existsAnswerByPath.get(relativePath);
39108
+ if (memoized !== void 0) return memoized;
39109
+ let answer;
39110
+ try {
39111
+ const stat = NFS.statSync(Path.resolve(input.rootDirectory, relativePath));
39112
+ answer = stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "none";
39113
+ } catch {
39114
+ answer = "none";
39115
+ }
39116
+ existsAnswerByPath.set(relativePath, answer);
39117
+ return answer;
39118
+ };
39119
+ return {
39120
+ answerFor: (kind, relativePath) => kind === "content" ? contentAnswer(relativePath) : existsAnswer(relativePath),
39121
+ toRelativePath
39122
+ };
38768
39123
  };
38769
39124
  const validateDiagnostic = decodeUnknownSync(Diagnostic);
38770
- const decodeFileDiagnostics = (raw) => {
38771
- if (!Array.isArray(raw)) return null;
39125
+ const isProbeKind = (value) => value === "content" || value === "exists";
39126
+ const decodeSidecarFileEntry = (raw) => {
39127
+ if (!isRecord(raw) || !Array.isArray(raw.probes) || !Array.isArray(raw.diagnostics)) return null;
39128
+ const probes = [];
39129
+ for (const rawProbe of raw.probes) {
39130
+ if (!Array.isArray(rawProbe) || rawProbe.length !== 3) return null;
39131
+ const [kind, probePath, answer] = rawProbe;
39132
+ if (!isProbeKind(kind) || typeof probePath !== "string" || typeof answer !== "string") return null;
39133
+ probes.push({
39134
+ kind,
39135
+ path: probePath,
39136
+ answer
39137
+ });
39138
+ }
38772
39139
  try {
38773
- for (const entry of raw) validateDiagnostic(entry);
38774
- return raw;
39140
+ for (const entry of raw.diagnostics) validateDiagnostic(entry);
38775
39141
  } catch {
38776
39142
  return null;
38777
39143
  }
39144
+ return {
39145
+ probes,
39146
+ diagnostics: raw.diagnostics
39147
+ };
38778
39148
  };
39149
+ const encodeSidecarFileEntry = (entry) => ({
39150
+ probes: entry.probes.map((probe) => [
39151
+ probe.kind,
39152
+ probe.path,
39153
+ probe.answer
39154
+ ]),
39155
+ diagnostics: entry.diagnostics
39156
+ });
38779
39157
  const emptyCache = () => ({
38780
39158
  version: 1,
38781
39159
  rulesets: {}
@@ -38786,20 +39164,20 @@ const loadRulesetEntries = (cacheFilePath, rulesetHash) => {
38786
39164
  if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
38787
39165
  const bucket = persisted.rulesets[rulesetHash];
38788
39166
  if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
38789
- for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
38790
- const decoded = decodeFileDiagnostics(rawDiagnostics);
39167
+ for (const [fileKey, rawEntry] of Object.entries(bucket.files)) {
39168
+ const decoded = decodeSidecarFileEntry(rawEntry);
38791
39169
  if (decoded !== null) entries.set(fileKey, decoded);
38792
39170
  }
38793
39171
  return entries;
38794
39172
  };
38795
- const createFileLintCache = (cacheDirectory, rulesetHash) => {
38796
- const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
39173
+ const createSidecarLintCache = (cacheDirectory, rulesetHash) => {
39174
+ const cacheFilePath = Path.join(cacheDirectory, SIDECAR_LINT_CACHE_FILENAME);
38797
39175
  const entries = loadRulesetEntries(cacheFilePath, rulesetHash);
38798
39176
  return {
38799
39177
  lookup: (fileKey) => entries.get(fileKey) ?? null,
38800
- store: (fileKey, diagnostics) => {
39178
+ store: (fileKey, entry) => {
38801
39179
  entries.delete(fileKey);
38802
- entries.set(fileKey, diagnostics);
39180
+ entries.set(fileKey, entry);
38803
39181
  },
38804
39182
  persist: () => {
38805
39183
  const onDisk = failOpenReadJson(cacheFilePath, emptyCache());
@@ -38807,7 +39185,7 @@ const createFileLintCache = (cacheDirectory, rulesetHash) => {
38807
39185
  const existingBucket = rulesets[rulesetHash];
38808
39186
  const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
38809
39187
  const ourFiles = {};
38810
- for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
39188
+ for (const [fileKey, entry] of entries) ourFiles[fileKey] = encodeSidecarFileEntry(entry);
38811
39189
  const cappedEntries = Object.entries({
38812
39190
  ...existingFiles,
38813
39191
  ...ourFiles
@@ -39822,13 +40200,6 @@ const validateRuleRegistration = () => {
39822
40200
  ].filter((entry) => entry !== null).join("; ");
39823
40201
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
39824
40202
  };
39825
- const hashFileContents = (filePath) => {
39826
- try {
39827
- return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
39828
- } catch {
39829
- return null;
39830
- }
39831
- };
39832
40203
  /**
39833
40204
  * Balanced LPT (longest-processing-time-first) lint batch planner. Where
39834
40205
  * `batchIncludePaths` greedily fills 100-file batches and leaves a small
@@ -39893,21 +40264,6 @@ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
39893
40264
  }
39894
40265
  return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
39895
40266
  };
39896
- const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
39897
- const resolveReactDoctorCacheDir = (projectDirectory) => {
39898
- const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
39899
- if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
39900
- const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
39901
- if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
39902
- return Path.join(os.tmpdir(), `react-doctor-cache-${resolveUserCacheScope()}`, projectCacheSubdir(projectDirectory));
39903
- };
39904
- const resolveUserCacheScope = () => {
39905
- try {
39906
- return String(process.getuid?.() ?? os.userInfo().username);
39907
- } catch {
39908
- return "shared";
39909
- }
39910
- };
39911
40267
  /**
39912
40268
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39913
40269
  * the runner: once for the primary scan, once for the
@@ -39925,6 +40281,106 @@ const writeOxlintConfig = (configPath, configToWrite) => {
39925
40281
  NFS.closeSync(fileHandle);
39926
40282
  }
39927
40283
  };
40284
+ /**
40285
+ * Attributes diagnostics back to the file that produced them by the
40286
+ * normalized path oxlint echoes. Returns `null` when ANY diagnostic can't be
40287
+ * attributed — the path forms don't align, so the caller must skip its cache
40288
+ * store rather than risk caching a wrong empty result for a file that
40289
+ * actually had diagnostics.
40290
+ */
40291
+ const attributeDiagnosticsToFiles = (diagnostics, files) => {
40292
+ const fileByNormalizedPath = /* @__PURE__ */ new Map();
40293
+ for (const file of files) fileByNormalizedPath.set(file.replaceAll("\\", "/"), file);
40294
+ const diagnosticsByFile = /* @__PURE__ */ new Map();
40295
+ for (const diagnostic of diagnostics) {
40296
+ const file = fileByNormalizedPath.get(diagnostic.filePath);
40297
+ if (file === void 0) return null;
40298
+ const fileDiagnostics = diagnosticsByFile.get(file) ?? [];
40299
+ fileDiagnostics.push(diagnostic);
40300
+ diagnosticsByFile.set(file, fileDiagnostics);
40301
+ }
40302
+ return diagnosticsByFile;
40303
+ };
40304
+ /**
40305
+ * Collects each file's cross-file dependency probes (via the plugin's
40306
+ * collectors) with their current answers. Fail-open per file: an unreadable /
40307
+ * unparseable / collector-failing file maps to `null` (no entry stored — it
40308
+ * re-lints next scan). The filesystem is frozen for the scan, so this can run
40309
+ * CONCURRENTLY with an oxlint child-process await — the caller starts it
40310
+ * unawaited before a lint pass and awaits it at store time, hiding the
40311
+ * in-process parse + resolution cost under the subprocess wait. The loop
40312
+ * yields on the cooperative time budget so the child's stdout handling (and
40313
+ * sibling fibers) keep flowing; each per-file collection is synchronous, so
40314
+ * the module-level probe recorder never sees interleaved use. Never rejects.
40315
+ */
40316
+ const collectSidecarProbesForFiles = async (input) => {
40317
+ const buildProbes = (file) => {
40318
+ const absoluteFilePath = Path.resolve(input.rootDirectory, file);
40319
+ let trace;
40320
+ try {
40321
+ trace = collectCrossFileDependencyProbes({
40322
+ absoluteFilePath,
40323
+ sourceText: NFS.readFileSync(absoluteFilePath, "utf8"),
40324
+ ruleIds: input.boundedSidecarRuleIds
40325
+ });
40326
+ } catch {
40327
+ return null;
40328
+ }
40329
+ if (trace === null) return null;
40330
+ const probes = [];
40331
+ for (const contentPath of trace.contentPaths) {
40332
+ const relativePath = input.probeAnswers.toRelativePath(contentPath);
40333
+ probes.push({
40334
+ kind: "content",
40335
+ path: relativePath,
40336
+ answer: input.probeAnswers.answerFor("content", relativePath)
40337
+ });
40338
+ }
40339
+ for (const existencePath of trace.existencePaths) {
40340
+ const relativePath = input.probeAnswers.toRelativePath(existencePath);
40341
+ probes.push({
40342
+ kind: "exists",
40343
+ path: relativePath,
40344
+ answer: input.probeAnswers.answerFor("exists", relativePath)
40345
+ });
40346
+ }
40347
+ return probes;
40348
+ };
40349
+ const probesByFile = /* @__PURE__ */ new Map();
40350
+ let collectSliceStartedAt = performance.now();
40351
+ for (const file of input.files) {
40352
+ probesByFile.set(file, buildProbes(file));
40353
+ if (performance.now() - collectSliceStartedAt >= 12) {
40354
+ await yieldToEventLoop();
40355
+ collectSliceStartedAt = performance.now();
40356
+ }
40357
+ }
40358
+ return probesByFile;
40359
+ };
40360
+ /**
40361
+ * Stores each freshly-linted file's probe set + cross-file diagnostics so
40362
+ * the next scan can replay instead of re-linting.
40363
+ */
40364
+ const storeSidecarEntries = (input) => {
40365
+ let didStoreAnyEntry = false;
40366
+ for (const pass of input.storablePasses) {
40367
+ if (!pass.isTrusted) continue;
40368
+ const diagnosticsByFile = attributeDiagnosticsToFiles(pass.diagnostics, pass.files);
40369
+ if (diagnosticsByFile === null) continue;
40370
+ for (const file of pass.files) {
40371
+ const cacheKey = input.cacheKeyByFile.get(file);
40372
+ if (cacheKey === void 0) continue;
40373
+ const probes = pass.probesByFile.get(file);
40374
+ if (probes === void 0 || probes === null) continue;
40375
+ input.sidecarCache.store(cacheKey, {
40376
+ probes,
40377
+ diagnostics: diagnosticsByFile.get(file) ?? []
40378
+ });
40379
+ didStoreAnyEntry = true;
40380
+ }
40381
+ }
40382
+ if (didStoreAnyEntry) input.sidecarCache.persist();
40383
+ };
39928
40384
  const REACT_HOOKS_JS_DROP_PREFIX = "React Compiler rules (react-hooks-js/*) skipped — eslint-plugin-react-hooks failed to load in this environment";
39929
40385
  /**
39930
40386
  * Detects an oxlint config-load crash caused by the optional
@@ -39966,7 +40422,7 @@ const reactHooksJsPluginDropNote = (error) => {
39966
40422
  * 6. always restore disable directives + clean up the temp dir
39967
40423
  */
39968
40424
  const runOxlint = async (options) => {
39969
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, onCacheStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "cost" } = options;
40425
+ const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, sidecarLintCacheEnabled = false, onCacheStats, onSidecarStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "cost" } = options;
39970
40426
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39971
40427
  const severityControls = buildRuleSeverityControls(userConfig);
39972
40428
  validateRuleRegistration();
@@ -39984,7 +40440,8 @@ const runOxlint = async (options) => {
39984
40440
  severityControls,
39985
40441
  userPlugins,
39986
40442
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
39987
- ruleSelection: overrides.ruleSelection
40443
+ ruleSelection: overrides.ruleSelection,
40444
+ sidecarRuleIdFilter: overrides.sidecarRuleIdFilter
39988
40445
  });
39989
40446
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
39990
40447
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
@@ -40087,15 +40544,18 @@ const runOxlint = async (options) => {
40087
40544
  });
40088
40545
  const cache = createFileLintCache(resolveReactDoctorCacheDir(rootDirectory), rulesetHash);
40089
40546
  const cacheKeyByFile = /* @__PURE__ */ new Map();
40547
+ const contentHashByRelativePath = /* @__PURE__ */ new Map();
40090
40548
  const missFiles = [];
40091
40549
  const replayedDiagnostics = [];
40092
40550
  let hashSliceStartedAt = performance.now();
40093
40551
  for (const candidateFile of candidateFiles) {
40094
- const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
40552
+ const absoluteCandidatePath = Path.resolve(rootDirectory, candidateFile);
40553
+ const contentHash = hashFileContents(absoluteCandidatePath);
40095
40554
  if (contentHash === null) {
40096
40555
  missFiles.push(candidateFile);
40097
40556
  continue;
40098
40557
  }
40558
+ contentHashByRelativePath.set(Path.relative(rootDirectory, absoluteCandidatePath).replaceAll("\\", "/"), contentHash);
40099
40559
  const cacheKey = `${candidateFile.replaceAll("\\", "/")}\u0000${contentHash}`;
40100
40560
  cacheKeyByFile.set(candidateFile, cacheKey);
40101
40561
  const cachedDiagnostics = cache.lookup(cacheKey);
@@ -40107,43 +40567,111 @@ const runOxlint = async (options) => {
40107
40567
  }
40108
40568
  }
40109
40569
  const cacheHitFileCount = candidateFiles.length - missFiles.length;
40570
+ const enabledSidecarRuleIds = Object.keys(buildConfig({
40571
+ extendsPaths: [],
40572
+ ruleSelection: "sidecar"
40573
+ }).rules).map((ruleKey) => ruleKey.slice(ruleKey.lastIndexOf("/") + 1)).filter((ruleId) => CROSS_FILE_RULE_IDS.has(ruleId));
40574
+ const boundedSidecarRuleIds = enabledSidecarRuleIds.filter((ruleId) => CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId));
40575
+ const boundedSidecarRuleIdSet = new Set(boundedSidecarRuleIds);
40576
+ const unboundedSidecarRuleIds = enabledSidecarRuleIds.filter((ruleId) => !CROSS_FILE_DEPENDENCY_COLLECTORS.has(ruleId));
40577
+ const useSidecarCache = sidecarLintCacheEnabled && boundedSidecarRuleIds.length > 0;
40578
+ const sidecarCache = useSidecarCache ? createSidecarLintCache(resolveReactDoctorCacheDir(rootDirectory), computeRulesetHash({
40579
+ config: buildConfig({
40580
+ extendsPaths: [],
40581
+ ruleSelection: "sidecar",
40582
+ sidecarRuleIdFilter: boundedSidecarRuleIdSet
40583
+ }),
40584
+ toolchainVersions: resolveOxlintToolchainVersions(nodeBinaryPath),
40585
+ ignorePatterns: combinedPatterns,
40586
+ tsconfigContent
40587
+ })) : null;
40588
+ const probeAnswers = createSidecarProbeAnswerResolver({
40589
+ rootDirectory,
40590
+ contentHashByRelativePath
40591
+ });
40592
+ const missProbesTask = sidecarCache === null ? null : collectSidecarProbesForFiles({
40593
+ files: missFiles,
40594
+ rootDirectory,
40595
+ boundedSidecarRuleIds,
40596
+ probeAnswers
40597
+ });
40110
40598
  const fullResult = await runConfigOverFiles((overrides) => buildConfig({
40111
40599
  extendsPaths: [],
40112
40600
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
40113
40601
  }), "oxlintrc.full.json", missFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(scannedFileCount, candidateFiles.length)));
40114
40602
  const missFileSet = new Set(missFiles);
40115
- const sidecarResult = await runConfigOverFiles(() => buildConfig({
40603
+ const hitFiles = candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile));
40604
+ const sidecarReplayedDiagnostics = [];
40605
+ const sidecarLintFiles = [];
40606
+ if (sidecarCache === null) sidecarLintFiles.push(...hitFiles);
40607
+ else {
40608
+ let verifySliceStartedAt = performance.now();
40609
+ for (const hitFile of hitFiles) {
40610
+ const cacheKey = cacheKeyByFile.get(hitFile);
40611
+ const entry = cacheKey === void 0 ? null : sidecarCache.lookup(cacheKey);
40612
+ if (entry !== null && entry.probes.every((probe) => probeAnswers.answerFor(probe.kind, probe.path) === probe.answer)) sidecarReplayedDiagnostics.push(...entry.diagnostics);
40613
+ else sidecarLintFiles.push(hitFile);
40614
+ if (performance.now() - verifySliceStartedAt >= 12) {
40615
+ await yieldToEventLoop();
40616
+ verifySliceStartedAt = performance.now();
40617
+ }
40618
+ }
40619
+ }
40620
+ const sidecarProbesTask = sidecarCache === null ? null : collectSidecarProbesForFiles({
40621
+ files: sidecarLintFiles,
40622
+ rootDirectory,
40623
+ boundedSidecarRuleIds,
40624
+ probeAnswers
40625
+ });
40626
+ const sidecarReplayedFileCount = hitFiles.length - sidecarLintFiles.length;
40627
+ if (sidecarReplayedFileCount > 0) options.onFileProgress?.(missFiles.length + sidecarReplayedFileCount, candidateFiles.length);
40628
+ const boundedSidecarResult = await runConfigOverFiles(() => buildConfig({
40116
40629
  extendsPaths: [],
40117
- ruleSelection: "sidecar"
40118
- }), "oxlintrc.sidecar.json", candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile)), options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + scannedFileCount, candidateFiles.length)));
40630
+ ruleSelection: "sidecar",
40631
+ ...useSidecarCache ? { sidecarRuleIdFilter: boundedSidecarRuleIdSet } : {}
40632
+ }), "oxlintrc.sidecar.json", sidecarLintFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + sidecarReplayedFileCount + scannedFileCount, candidateFiles.length)));
40633
+ const unboundedSidecarResult = useSidecarCache && unboundedSidecarRuleIds.length > 0 ? await runConfigOverFiles(() => buildConfig({
40634
+ extendsPaths: [],
40635
+ ruleSelection: "sidecar",
40636
+ sidecarRuleIdFilter: new Set(unboundedSidecarRuleIds)
40637
+ }), "oxlintrc.sidecar-unbounded.json", hitFiles, void 0) : {
40638
+ diagnostics: [],
40639
+ didDropReactHooksJsPlugin: false,
40640
+ hadPartialFailure: false
40641
+ };
40119
40642
  const freshCacheableDiagnostics = [];
40120
40643
  for (const diagnostic of fullResult.diagnostics) if (!CROSS_FILE_RULE_IDS.has(diagnostic.rule)) freshCacheableDiagnostics.push(diagnostic);
40121
40644
  onCacheStats?.(cacheHitFileCount, candidateFiles.length);
40122
- const missFileByNormalizedPath = /* @__PURE__ */ new Map();
40123
- for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
40124
- const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
40125
- let isAttributionSound = true;
40126
- for (const diagnostic of freshCacheableDiagnostics) {
40127
- const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
40128
- if (missFile === void 0) {
40129
- isAttributionSound = false;
40130
- break;
40131
- }
40132
- const fileDiagnostics = freshDiagnosticsByFile.get(missFile) ?? [];
40133
- fileDiagnostics.push(diagnostic);
40134
- freshDiagnosticsByFile.set(missFile, fileDiagnostics);
40135
- }
40136
- if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && isAttributionSound) {
40645
+ if (sidecarCache !== null) onSidecarStats?.(hitFiles.length - sidecarLintFiles.length, hitFiles.length);
40646
+ const freshDiagnosticsByFile = attributeDiagnosticsToFiles(freshCacheableDiagnostics, missFiles);
40647
+ if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && freshDiagnosticsByFile !== null) {
40137
40648
  for (const missFile of missFiles) {
40138
40649
  const cacheKey = cacheKeyByFile.get(missFile);
40139
40650
  if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
40140
40651
  }
40141
40652
  cache.persist();
40142
40653
  }
40654
+ if (sidecarCache !== null && missProbesTask !== null && sidecarProbesTask !== null) storeSidecarEntries({
40655
+ sidecarCache,
40656
+ cacheKeyByFile,
40657
+ storablePasses: [{
40658
+ files: sidecarLintFiles,
40659
+ diagnostics: boundedSidecarResult.diagnostics,
40660
+ isTrusted: !boundedSidecarResult.hadPartialFailure,
40661
+ probesByFile: await sidecarProbesTask
40662
+ }, {
40663
+ files: missFiles,
40664
+ diagnostics: fullResult.diagnostics.filter((diagnostic) => boundedSidecarRuleIdSet.has(diagnostic.rule)),
40665
+ isTrusted: !fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure,
40666
+ probesByFile: await missProbesTask
40667
+ }]
40668
+ });
40143
40669
  return dedupeDiagnostics([
40144
40670
  ...replayedDiagnostics,
40145
40671
  ...fullResult.diagnostics,
40146
- ...sidecarResult.diagnostics
40672
+ ...sidecarReplayedDiagnostics,
40673
+ ...boundedSidecarResult.diagnostics,
40674
+ ...unboundedSidecarResult.diagnostics
40147
40675
  ]);
40148
40676
  }
40149
40677
  const baseArgs = makeBaseArgs(configPath);
@@ -40241,6 +40769,7 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
40241
40769
  const concurrency = yield* OxlintConcurrency;
40242
40770
  const lintBatchOrdering = yield* LintBatchOrdering;
40243
40771
  const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
40772
+ const sidecarLintCacheEnabled = yield* SidecarLintCacheEnabled;
40244
40773
  const collectedFailures = [];
40245
40774
  const diagnostics = yield* tryPromise({
40246
40775
  try: (signal) => runOxlint({
@@ -40259,7 +40788,9 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
40259
40788
  },
40260
40789
  onFileProgress: input.onFileProgress,
40261
40790
  perFileLintCacheEnabled,
40791
+ sidecarLintCacheEnabled,
40262
40792
  onCacheStats: input.onCacheStats,
40793
+ onSidecarStats: input.onSidecarStats,
40263
40794
  spawnTimeoutMs,
40264
40795
  outputMaxBytes,
40265
40796
  concurrency,
@@ -40681,6 +41212,20 @@ const writeCachedSocketBody = (cacheFile, body) => {
40681
41212
  }));
40682
41213
  } catch {}
40683
41214
  };
41215
+ const pruneExpiredSocketCache = (cacheDirectory) => {
41216
+ try {
41217
+ const supplyChainCacheDirectory = Path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR);
41218
+ const expiryThresholdMs = Date.now() - SUPPLY_CHAIN_CACHE_TTL_MS;
41219
+ for (const entryName of NFS.readdirSync(supplyChainCacheDirectory)) {
41220
+ const entryPath = Path.join(supplyChainCacheDirectory, entryName);
41221
+ try {
41222
+ if (NFS.statSync(entryPath).mtimeMs < expiryThresholdMs) NFS.rmSync(entryPath);
41223
+ } catch {
41224
+ continue;
41225
+ }
41226
+ }
41227
+ } catch {}
41228
+ };
40684
41229
  const fetchSocketArtifact = (dependency, cacheDirectory) => tryPromise(async (signal) => {
40685
41230
  const cacheFile = cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency);
40686
41231
  if (cacheFile !== null) {
@@ -40808,6 +41353,7 @@ const checkSupplyChain = (input) => gen(function* () {
40808
41353
  const dependencies = collectDependenciesToScore(readPackageJson(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
40809
41354
  if (dependencies.length === 0) return [];
40810
41355
  const cacheDirectory = isSupplyChainCacheDisabled() ? null : resolveReactDoctorCacheDir(input.rootDirectory);
41356
+ if (cacheDirectory !== null) pruneExpiredSocketCache(cacheDirectory);
40811
41357
  const artifacts = yield* forEach$1(dependencies, (dependency) => fetchSocketArtifact(dependency, cacheDirectory), { concurrency: 8 }).pipe(timeoutOption(input.totalTimeoutMs ?? 9e4), map$3((maybeArtifacts) => getOrElse$1(maybeArtifacts, () => [])));
40812
41358
  const diagnostics = [];
40813
41359
  for (let index = 0; index < dependencies.length; index += 1) {
@@ -41034,7 +41580,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41034
41580
  const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
41035
41581
  rootDirectory: scanDirectory,
41036
41582
  parseConcurrency: deadCodeParseConcurrency,
41037
- workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
41583
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs,
41584
+ onCacheOutcome: (didHitCache) => {
41585
+ deadCodeCacheHit = didHitCache;
41586
+ },
41587
+ onSummaryCacheStats: (stats) => {
41588
+ deadCodeSummaryCacheHits = stats.hits;
41589
+ deadCodeSummaryCacheMisses = stats.misses;
41590
+ }
41038
41591
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
41039
41592
  yield* set(deadCodeFailure, {
41040
41593
  didFail: true,
@@ -41062,6 +41615,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41062
41615
  let lastReportedTotalFileCount = 0;
41063
41616
  let lintCacheHitFileCount = null;
41064
41617
  let lintCacheTotalFileCount = null;
41618
+ let lintSidecarReplayedFileCount = null;
41619
+ let lintSidecarTotalFileCount = null;
41620
+ let deadCodeCacheHit = null;
41621
+ let deadCodeSummaryCacheHits = null;
41622
+ let deadCodeSummaryCacheMisses = null;
41065
41623
  const baseLintStream = linterService.run({
41066
41624
  rootDirectory: scanDirectory,
41067
41625
  project,
@@ -41081,6 +41639,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41081
41639
  lintCacheHitFileCount = cacheHitFileCount;
41082
41640
  lintCacheTotalFileCount = totalConsideredFileCount;
41083
41641
  },
41642
+ onSidecarStats: (sidecarReplayedFileCount, sidecarConsideredFileCount) => {
41643
+ lintSidecarReplayedFileCount = sidecarReplayedFileCount;
41644
+ lintSidecarTotalFileCount = sidecarConsideredFileCount;
41645
+ },
41084
41646
  deadlineEpochMs: input.deadlineEpochMs
41085
41647
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
41086
41648
  yield* set(lintFailure, {
@@ -41191,6 +41753,11 @@ const runInspect = (input, hooks = {}) => gen(function* () {
41191
41753
  securityScanFailed,
41192
41754
  lintCacheHitFileCount,
41193
41755
  lintCacheTotalFileCount,
41756
+ lintSidecarReplayedFileCount,
41757
+ lintSidecarTotalFileCount,
41758
+ deadCodeCacheHit: lintFailureState.didFail ? null : deadCodeCacheHit,
41759
+ deadCodeSummaryCacheHits: lintFailureState.didFail ? null : deadCodeSummaryCacheHits,
41760
+ deadCodeSummaryCacheMisses: lintFailureState.didFail ? null : deadCodeSummaryCacheMisses,
41194
41761
  suppressedRuleCounts: transform.summarizeSuppressions()
41195
41762
  };
41196
41763
  }).pipe(withSpan("runInspect", { attributes: {
@@ -41503,11 +42070,9 @@ const computeConfigFingerprint = (projectDirectory, version) => {
41503
42070
  const parts = [`v=${version}`];
41504
42071
  let directory = projectDirectory;
41505
42072
  for (;;) {
41506
- for (const filename of CONFIG_FINGERPRINT_FILENAMES) try {
41507
- const stat = fs.statSync(path.join(directory, filename));
41508
- parts.push(`${directory}/${filename}=${stat.mtimeMs}:${stat.size}`);
41509
- } catch {
41510
- continue;
42073
+ for (const filename of CONFIG_FINGERPRINT_FILENAMES) {
42074
+ const contentHash = hashFileContents(path.join(directory, filename));
42075
+ if (contentHash !== null) parts.push(`${directory}/${filename}=${contentHash}`);
41511
42076
  }
41512
42077
  const parent = path.dirname(directory);
41513
42078
  if (parent === directory) break;
@@ -41520,7 +42085,7 @@ const computeConfigFingerprint = (projectDirectory, version) => {
41520
42085
  /** Display name used in client-facing messages and progress titles. */
41521
42086
  const SERVER_DISPLAY_NAME = "React Doctor";
41522
42087
  /** Server version reported in `serverInfo`; injected at build, `dev` from source. */
41523
- const SERVER_VERSION = "0.6.3";
42088
+ const SERVER_VERSION = "0.7.0";
41524
42089
  /** `Diagnostic.source` shown next to every published diagnostic. */
41525
42090
  const DIAGNOSTIC_SOURCE = "react-doctor";
41526
42091
  /**
@@ -43570,5 +44135,5 @@ const startLanguageServer = () => {
43570
44135
  };
43571
44136
  //#endregion
43572
44137
  export { startLanguageServer };
43573
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="94a57ec3-d5a9-52ea-a743-e91d996065b8")}catch(e){}}();
43574
- //# debugId=94a57ec3-d5a9-52ea-a743-e91d996065b8
44138
+ !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]="1b40bd68-7ad4-55e0-a6bd-91b6cdcd0ba8")}catch(e){}}();
44139
+ //# debugId=1b40bd68-7ad4-55e0-a6bd-91b6cdcd0ba8