react-doctor 0.6.2 → 0.6.3-dev.20d81f6

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/cli.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]="a9894975-1858-5b46-8a22-ae1680ad9a8a")}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]="d7bda084-a223-5aa7-8605-578888bd1b33")}catch(e){}}();
3
3
  import { createRequire } from "node:module";
4
4
  import * as NodeChildProcess from "node:child_process";
5
5
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
@@ -18,6 +18,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { createJiti } from "jiti";
19
19
  import * as Crypto from "node:crypto";
20
20
  import crypto, { createHash, randomUUID } from "node:crypto";
21
+ import { ANALYZED_MANIFEST_FILENAMES, DEFAULT_EXTENSIONS } from "deslop-js/analyzed-inputs";
21
22
  import { gzipSync } from "node:zlib";
22
23
  import * as Sentry from "@sentry/node";
23
24
  import { performance as performance$1 } from "node:perf_hooks";
@@ -36128,6 +36129,8 @@ const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
36128
36129
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
36129
36130
  const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
36130
36131
  const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
36132
+ const CORE_PACKAGE_VERSION = "0.6.3";
36133
+ const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json";
36131
36134
  const SUPPLY_CHAIN_PLUGIN = "socket";
36132
36135
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
36133
36136
  const SUPPLY_CHAIN_CATEGORY = "Security";
@@ -38678,7 +38681,7 @@ const resolveAutoScanConcurrency = (facts = readSystemFacts$1()) => {
38678
38681
  return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
38679
38682
  };
38680
38683
  const resolveLintBatchOrdering = () => {
38681
- return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "cost" ? "cost" : "arrival";
38684
+ return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "arrival" ? "arrival" : "cost";
38682
38685
  };
38683
38686
  /**
38684
38687
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -38775,17 +38778,19 @@ var DeadCodeOverlap = class extends Reference("react-doctor/DeadCodeOverlap", {
38775
38778
  return "auto";
38776
38779
  } }) {};
38777
38780
  /**
38778
- * How the full-scan lint pass orders its file batches. `"arrival"` (the
38779
- * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
38780
- * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
38781
- * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
38782
- * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
38783
- * batch is a straggler (and can trip the per-batch timeout + split), measurably
38784
- * regressing the common full-scan case. LPT needs the heavy files SPREAD across
38785
- * batches before `cost` earns the default. Tests override via
38786
- * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
38787
- * — they pass user-scoped `includePaths` that skip discovery and stay in
38788
- * arrival order; only the full-scan branch reads it.
38781
+ * How the full-scan lint pass plans its file batches. `"cost"` (the default)
38782
+ * builds size-balanced LPT batches (`planLintBatches`): the same mandatory
38783
+ * batch count as greedy chunking (`ceil(files / 100)`), but every batch gets
38784
+ * an even share of files AND bytes, so no 100-file chunk is a straggler while
38785
+ * the remainder-batch worker idles — and the heavy files are SPREAD across
38786
+ * batches, the precondition the old sort-desc-then-chunk-100 `cost` mode
38787
+ * lacked (it packed the heaviest files into one wave-1 straggler batch,
38788
+ * measurably regressing size-skewed repos, which is why it never earned the
38789
+ * default). `"arrival"` (`REACT_DOCTOR_LINT_BATCH_ORDERING=arrival`) is the
38790
+ * rollback hatch to plain greedy 100-file chunking in discovery order. Tests
38791
+ * override via `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans
38792
+ * never reach this — they pass user-scoped `includePaths` that skip discovery
38793
+ * and stay in arrival order; only the full-scan branch reads it.
38789
38794
  */
38790
38795
  var LintBatchOrdering = class extends Reference("react-doctor/LintBatchOrdering", { defaultValue: resolveLintBatchOrdering }) {};
38791
38796
  const CACHE_DISABLED_VALUES$1 = new Set(["1", "true"]);
@@ -38810,6 +38815,25 @@ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintC
38810
38815
  if (CACHE_DISABLED_VALUES$1.has(noFileCache)) return false;
38811
38816
  return true;
38812
38817
  } }) {};
38818
+ /**
38819
+ * Whether the whole-project dead-code result cache
38820
+ * (`dead-code/dead-code-result-cache.ts`) is active. Defaults ON — a rescan
38821
+ * whose inputs (source tree, manifests, configs, analyzer version) are
38822
+ * unchanged replays the stored diagnostics instead of re-running the
38823
+ * analysis worker. Opt-OUT, two knobs (matching the per-file lint cache):
38824
+ *
38825
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch.
38826
+ * - `REACT_DOCTOR_NO_DEAD_CODE_CACHE` — granular: bust only this cache.
38827
+ *
38828
+ * Tests override via `Layer.succeed(DeadCodeResultCacheEnabled, false)`.
38829
+ */
38830
+ var DeadCodeResultCacheEnabled = class extends Reference("react-doctor/DeadCodeResultCacheEnabled", { defaultValue: () => {
38831
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
38832
+ const noDeadCodeCache = process.env["REACT_DOCTOR_NO_DEAD_CODE_CACHE"]?.toLowerCase() ?? "";
38833
+ if (CACHE_DISABLED_VALUES$1.has(noCache)) return false;
38834
+ if (CACHE_DISABLED_VALUES$1.has(noDeadCodeCache)) return false;
38835
+ return true;
38836
+ } }) {};
38813
38837
  const DIAGNOSTIC_SURFACES = [
38814
38838
  "cli",
38815
38839
  "prComment",
@@ -40170,6 +40194,7 @@ function* collectSecurityScanFiles(rootDirectory) {
40170
40194
  isGeneratedBundleByName: classification.isGeneratedBundleByName
40171
40195
  });
40172
40196
  }
40197
+ yield null;
40173
40198
  }
40174
40199
  for (const candidates of [
40175
40200
  priorityCandidates,
@@ -40295,9 +40320,18 @@ const checkSecurityScanCooperative = async (rootDirectory, options = {}) => {
40295
40320
  const session = createSecurityScanSession(rootDirectory, options);
40296
40321
  if (session === null) return [];
40297
40322
  let sliceStartedAt = performance.now();
40298
- for (const file of collectSecurityScanFiles(rootDirectory)) for (const _ruleStep of session.scanFileByRule(file)) if (performance.now() - sliceStartedAt >= 12) {
40299
- await yieldToEventLoop();
40300
- sliceStartedAt = performance.now();
40323
+ for (const file of collectSecurityScanFiles(rootDirectory)) {
40324
+ if (file === null) {
40325
+ if (performance.now() - sliceStartedAt >= 12) {
40326
+ await yieldToEventLoop();
40327
+ sliceStartedAt = performance.now();
40328
+ }
40329
+ continue;
40330
+ }
40331
+ for (const _ruleStep of session.scanFileByRule(file)) if (performance.now() - sliceStartedAt >= 12) {
40332
+ await yieldToEventLoop();
40333
+ sliceStartedAt = performance.now();
40334
+ }
40301
40335
  }
40302
40336
  return session.diagnostics;
40303
40337
  };
@@ -40532,6 +40566,80 @@ const collectDeadCodeIgnorePatterns = (rootDirectory) => {
40532
40566
  return [...seen].filter((pattern) => pattern.length > 0);
40533
40567
  };
40534
40568
  const collectDeadCodeEntryPatterns = (rootDirectory) => [...new Set(collectKnipPatterns(rootDirectory, "entry"))].filter((pattern) => pattern.length > 0);
40569
+ const atomicWriteJson = (filePath, value) => {
40570
+ try {
40571
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
40572
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
40573
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
40574
+ NFS.renameSync(temporaryPath, filePath);
40575
+ } catch {
40576
+ return;
40577
+ }
40578
+ };
40579
+ const failOpenReadJson = (filePath, fallback) => {
40580
+ try {
40581
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
40582
+ } catch {
40583
+ return fallback;
40584
+ }
40585
+ };
40586
+ const ANALYZED_FILE_EXTENSIONS = new Set(DEFAULT_EXTENSIONS);
40587
+ const CORE_SIDE_MANIFEST_NAMES = ["knip.json", "deno.lock"];
40588
+ const ANALYZED_MANIFEST_NAMES = new Set([...ANALYZED_MANIFEST_FILENAMES, ...CORE_SIDE_MANIFEST_NAMES]);
40589
+ const isTsConfigLikeFile = (fileName) => (fileName.startsWith("tsconfig") || fileName.startsWith("jsconfig")) && fileName.endsWith(".json");
40590
+ const isFingerprintedFile = (fileName) => ANALYZED_FILE_EXTENSIONS.has(Path.extname(fileName).toLowerCase()) || ANALYZED_MANIFEST_NAMES.has(fileName) || isTsConfigLikeFile(fileName);
40591
+ const collectAnalyzedFileFingerprints = (rootDirectory) => {
40592
+ const fingerprints = [];
40593
+ for (const { absolutePath, name } of walkSourceTreeFiles(rootDirectory)) {
40594
+ if (!isFingerprintedFile(name)) continue;
40595
+ try {
40596
+ const fileStat = NFS.statSync(absolutePath);
40597
+ const relativePath = Path.relative(rootDirectory, absolutePath).replace(/\\/g, "/");
40598
+ fingerprints.push(`${relativePath}:${fileStat.mtimeMs}:${fileStat.size}`);
40599
+ } catch {}
40600
+ }
40601
+ return fingerprints.sort();
40602
+ };
40603
+ const bundledRequire$2 = createRequire(import.meta.url);
40604
+ const resolveDeslopVersion = () => {
40605
+ try {
40606
+ const packageJson = JSON.parse(NFS.readFileSync(bundledRequire$2.resolve("deslop-js/package.json"), "utf8"));
40607
+ return isRecord$1(packageJson) && typeof packageJson.version === "string" ? packageJson.version : "unknown";
40608
+ } catch {
40609
+ return "unknown";
40610
+ }
40611
+ };
40612
+ const computeDeadCodeCacheKey = (input) => crypto.createHash("sha1").update(JSON.stringify({
40613
+ schemaVersion: 1,
40614
+ coreVersion: input.coreVersion,
40615
+ deslopVersion: resolveDeslopVersion(),
40616
+ deslopJsModuleSpecifier: input.deslopJsModuleSpecifier,
40617
+ entryPatterns: input.entryPatterns,
40618
+ ignorePatterns: input.ignorePatterns,
40619
+ tsConfigFile: input.tsConfigPath === void 0 ? null : Path.relative(input.rootDirectory, input.tsConfigPath).replace(/\\/g, "/"),
40620
+ files: collectAnalyzedFileFingerprints(input.rootDirectory)
40621
+ })).digest("hex");
40622
+ const validateDiagnostic$1 = decodeUnknownSync(Diagnostic);
40623
+ const decodeCachedDiagnostics = (raw) => {
40624
+ try {
40625
+ for (const entry of raw) validateDiagnostic$1(entry);
40626
+ return raw;
40627
+ } catch {
40628
+ return null;
40629
+ }
40630
+ };
40631
+ const lookupDeadCodeResultCache = (cacheDirectory, cacheKey) => {
40632
+ const persisted = failOpenReadJson(Path.join(cacheDirectory, DEAD_CODE_CACHE_FILENAME), null);
40633
+ if (persisted === null || !isRecord$1(persisted) || persisted.version !== 1 || persisted.key !== cacheKey || !Array.isArray(persisted.diagnostics)) return null;
40634
+ return decodeCachedDiagnostics(persisted.diagnostics);
40635
+ };
40636
+ const storeDeadCodeResultCache = (cacheDirectory, cacheKey, diagnostics) => {
40637
+ atomicWriteJson(Path.join(cacheDirectory, DEAD_CODE_CACHE_FILENAME), {
40638
+ version: 1,
40639
+ key: cacheKey,
40640
+ diagnostics
40641
+ });
40642
+ };
40535
40643
  const readSystemFacts = () => ({
40536
40644
  availableCores: os.availableParallelism(),
40537
40645
  totalMemoryBytes: os.totalmem(),
@@ -40600,6 +40708,21 @@ const withDeadCodeWorkerSlot = async (task, abortSignal) => {
40600
40708
  releaseSlot();
40601
40709
  }
40602
40710
  };
40711
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
40712
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
40713
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
40714
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
40715
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
40716
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
40717
+ return Path.join(os.tmpdir(), `react-doctor-cache-${resolveUserCacheScope()}`, projectCacheSubdir(projectDirectory));
40718
+ };
40719
+ const resolveUserCacheScope = () => {
40720
+ try {
40721
+ return String(process.getuid?.() ?? os.userInfo().username);
40722
+ } catch {
40723
+ return "shared";
40724
+ }
40725
+ };
40603
40726
  /**
40604
40727
  * Resolves a path to its canonical, symlink-free form, falling back to
40605
40728
  * the input when it cannot be realpath'd (broken symlink, permission
@@ -40895,13 +41018,28 @@ const checkDeadCode = async (options) => {
40895
41018
  if (!NFS.existsSync(Path.join(rootDirectory, "package.json"))) return [];
40896
41019
  const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
40897
41020
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory);
41021
+ const tsConfigPath = resolveTsConfigPath(rootDirectory);
41022
+ const deslopJsModuleSpecifier = options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js");
41023
+ const cacheKey = options.cacheEnabled ? computeDeadCodeCacheKey({
41024
+ rootDirectory,
41025
+ entryPatterns,
41026
+ ignorePatterns,
41027
+ tsConfigPath,
41028
+ deslopJsModuleSpecifier,
41029
+ coreVersion: CORE_PACKAGE_VERSION
41030
+ }) : null;
41031
+ if (cacheKey !== null) {
41032
+ const cachedDiagnostics = lookupDeadCodeResultCache(resolveReactDoctorCacheDir(rootDirectory), cacheKey);
41033
+ options.onCacheOutcome?.(cachedDiagnostics !== null);
41034
+ if (cachedDiagnostics !== null) return [...cachedDiagnostics];
41035
+ }
40898
41036
  const spawnAndRun = () => {
40899
41037
  return runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
40900
41038
  rootDirectory,
40901
41039
  entryPatterns,
40902
- tsConfigPath: resolveTsConfigPath(rootDirectory),
41040
+ tsConfigPath,
40903
41041
  ignorePatterns,
40904
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
41042
+ deslopJsModuleSpecifier,
40905
41043
  parseConcurrency: options.parseConcurrency
40906
41044
  }), options.workerTimeoutMs ?? 12e4, options.abortSignal);
40907
41045
  };
@@ -40962,6 +41100,7 @@ const checkDeadCode = async (options) => {
40962
41100
  category: DEAD_CODE_CATEGORY
40963
41101
  });
40964
41102
  }
41103
+ if (cacheKey !== null) storeDeadCodeResultCache(resolveReactDoctorCacheDir(rootDirectory), cacheKey, diagnostics);
40965
41104
  return diagnostics;
40966
41105
  };
40967
41106
  const DEAD_CODE_RULE_KEY_PREFIX = `${DEAD_CODE_PLUGIN}/`;
@@ -41064,12 +41203,15 @@ var Config = class Config extends Service()("react-doctor/Config") {
41064
41203
  */
41065
41204
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
41066
41205
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
41206
+ const cacheEnabled = yield* DeadCodeResultCacheEnabled;
41067
41207
  return yield* tryPromise({
41068
41208
  try: (signal) => checkDeadCode({
41069
41209
  rootDirectory: input.rootDirectory,
41070
41210
  parseConcurrency: input.parseConcurrency,
41071
41211
  workerTimeoutMs: input.workerTimeoutMs,
41072
- abortSignal: signal
41212
+ abortSignal: signal,
41213
+ cacheEnabled,
41214
+ onCacheOutcome: input.onCacheOutcome
41073
41215
  }),
41074
41216
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
41075
41217
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -41995,23 +42137,6 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41995
42137
  }
41996
42138
  };
41997
42139
  };
41998
- const atomicWriteJson = (filePath, value) => {
41999
- try {
42000
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
42001
- const temporaryPath = `${filePath}.${process.pid}.tmp`;
42002
- NFS.writeFileSync(temporaryPath, JSON.stringify(value));
42003
- NFS.renameSync(temporaryPath, filePath);
42004
- } catch {
42005
- return;
42006
- }
42007
- };
42008
- const failOpenReadJson = (filePath, fallback) => {
42009
- try {
42010
- return JSON.parse(NFS.readFileSync(filePath, "utf8"));
42011
- } catch {
42012
- return fallback;
42013
- }
42014
- };
42015
42140
  const validateDiagnostic = decodeUnknownSync(Diagnostic);
42016
42141
  const decodeFileDiagnostics = (raw) => {
42017
42142
  if (!Array.isArray(raw)) return null;
@@ -42069,7 +42194,7 @@ const createFileLintCache = (cacheDirectory, rulesetHash) => {
42069
42194
  }
42070
42195
  };
42071
42196
  };
42072
- const bundledRequire$2 = createRequire(import.meta.url);
42197
+ const bundledRequire$3 = createRequire(import.meta.url);
42073
42198
  const childNodeVersionByBinaryPath = /* @__PURE__ */ new Map();
42074
42199
  const resolveChildNodeVersion = (nodeBinaryPath) => {
42075
42200
  if (nodeBinaryPath === process.execPath) return process.version;
@@ -42095,7 +42220,7 @@ const TOOLCHAIN_PACKAGE_SPECIFIERS$1 = [
42095
42220
  const resolveOxlintToolchainVersions = (nodeBinaryPath = process.execPath) => {
42096
42221
  const versions = [`node=${resolveChildNodeVersion(nodeBinaryPath)}`];
42097
42222
  for (const specifier of TOOLCHAIN_PACKAGE_SPECIFIERS$1) try {
42098
- const packageJson = bundledRequire$2(specifier);
42223
+ const packageJson = bundledRequire$3(specifier);
42099
42224
  const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
42100
42225
  versions.push(`${specifier}=${version}`);
42101
42226
  } catch {
@@ -43075,22 +43200,70 @@ const hashFileContents = (filePath) => {
43075
43200
  return null;
43076
43201
  }
43077
43202
  };
43078
- const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
43079
- const resolveReactDoctorCacheDir = (projectDirectory) => {
43080
- const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
43081
- if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
43082
- const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
43083
- if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
43084
- return Path.join(os.tmpdir(), `react-doctor-cache-${resolveUserCacheScope()}`, projectCacheSubdir(projectDirectory));
43085
- };
43086
- const resolveUserCacheScope = () => {
43087
- try {
43088
- return String(process.getuid?.() ?? os.userInfo().username);
43089
- } catch {
43090
- return "shared";
43203
+ /**
43204
+ * Balanced LPT (longest-processing-time-first) lint batch planner. Where
43205
+ * `batchIncludePaths` greedily fills 100-file batches and leaves a small
43206
+ * remainder batch (617 files → 6×100 + 17, so the parallel pool's wall clock
43207
+ * is set by whichever 100-file chunk happened to collect the most work while
43208
+ * the remainder's worker idles), this planner keeps the SAME mandatory batch
43209
+ * count — `ceil(files / OXLINT_MAX_FILES_PER_BATCH)`, the JS-plugin
43210
+ * perf-cliff / native-binding SIGABRT guard — and assigns files largest-first,
43211
+ * each to the least-loaded batch by cumulative byte size. Every batch ends up
43212
+ * with an even share of files AND bytes, so no batch is a straggler; heavy
43213
+ * files are SPREAD across batches (the documented precondition for `cost`
43214
+ * ordering to beat `arrival`) instead of concentrated in one.
43215
+ *
43216
+ * Deliberately NOT worker-count-aware: splitting into extra batches to feed
43217
+ * idle workers was measured (619-file corpus, 10 cores) to REGRESS the lint
43218
+ * wall — each additional concurrent subprocess pays a contended cold start
43219
+ * (node boot + plugin import + native dlopen, ~180 ms uncontended and 2-3×
43220
+ * that in a full wave) that outweighs the smaller per-batch file share.
43221
+ *
43222
+ * Every batch also respects the spawn-args char budget; a file that fits no
43223
+ * existing batch opens a new one, mirroring `batchIncludePaths`. Within a
43224
+ * batch, files return in arrival order, and the assignment is fully
43225
+ * deterministic (ties broken by lowest batch index), so a plan is
43226
+ * reproducible for a fixed (files, sizes) input. Batch composition cannot
43227
+ * change diagnostics — rules that look beyond one file (`CROSS_FILE_RULE_IDS`)
43228
+ * read the OTHER files via the filesystem, never via batch-mates — so the
43229
+ * plan only moves wall-clock time.
43230
+ *
43231
+ * O(files × batches); the linear least-loaded scan is fine at real-world
43232
+ * scale (a 3,500-file monorepo is 36 batches ≈ 126k comparisons).
43233
+ */
43234
+ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
43235
+ if (files.length === 0) return [];
43236
+ const batchCount = Math.ceil(files.length / 100);
43237
+ const maxFilesPerBatch = Math.ceil(files.length / batchCount);
43238
+ const baseArgsLengthChars = estimateArgsLength(baseArgs);
43239
+ const descendingSizeIndexes = [...files.keys()].sort((leftIndex, rightIndex) => (sizeByFile.get(files[rightIndex]) ?? 0) - (sizeByFile.get(files[leftIndex]) ?? 0));
43240
+ const batches = Array.from({ length: batchCount }, () => ({
43241
+ fileIndexes: [],
43242
+ totalSizeBytes: 0,
43243
+ argsLengthChars: baseArgsLengthChars
43244
+ }));
43245
+ for (const fileIndex of descendingSizeIndexes) {
43246
+ const entryLengthChars = files[fileIndex].length + 1;
43247
+ let targetBatch = null;
43248
+ for (const batch of batches) {
43249
+ if (batch.fileIndexes.length >= maxFilesPerBatch) continue;
43250
+ if (batch.fileIndexes.length > 0 && batch.argsLengthChars + entryLengthChars > 24e3) continue;
43251
+ if (targetBatch === null || batch.totalSizeBytes < targetBatch.totalSizeBytes || batch.totalSizeBytes === targetBatch.totalSizeBytes && batch.fileIndexes.length < targetBatch.fileIndexes.length) targetBatch = batch;
43252
+ }
43253
+ if (targetBatch === null) {
43254
+ targetBatch = {
43255
+ fileIndexes: [],
43256
+ totalSizeBytes: 0,
43257
+ argsLengthChars: baseArgsLengthChars
43258
+ };
43259
+ batches.push(targetBatch);
43260
+ }
43261
+ targetBatch.fileIndexes.push(fileIndex);
43262
+ targetBatch.totalSizeBytes += sizeByFile.get(files[fileIndex]) ?? 0;
43263
+ targetBatch.argsLengthChars += entryLengthChars;
43091
43264
  }
43265
+ return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
43092
43266
  };
43093
- const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
43094
43267
  /**
43095
43268
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
43096
43269
  * the runner: once for the primary scan, once for the
@@ -43149,7 +43322,7 @@ const reactHooksJsPluginDropNote = (error) => {
43149
43322
  * 6. always restore disable directives + clean up the temp dir
43150
43323
  */
43151
43324
  const runOxlint = async (options) => {
43152
- 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 = "arrival" } = options;
43325
+ 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;
43153
43326
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
43154
43327
  const severityControls = buildRuleSeverityControls(userConfig);
43155
43328
  validateRuleRegistration();
@@ -43201,8 +43374,15 @@ const runOxlint = async (options) => {
43201
43374
  "json",
43202
43375
  ...sharedArgs
43203
43376
  ];
43204
- const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
43205
- const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
43377
+ await yieldToEventLoop();
43378
+ const sizedScanFiles = includePaths === void 0 ? listSourceFilesWithSize(rootDirectory) : null;
43379
+ const candidateFiles = includePaths !== void 0 ? includePaths : (sizedScanFiles ?? []).map((entry) => entry.path);
43380
+ const sizeByFile = sizedScanFiles !== null && lintBatchOrdering === "cost" ? new Map(sizedScanFiles.map((entry) => [entry.path, entry.sizeBytes])) : null;
43381
+ const buildFileBatches = (passBaseArgs, passFiles) => sizeByFile !== null ? planLintBatches({
43382
+ baseArgs: passBaseArgs,
43383
+ files: passFiles,
43384
+ sizeByFile
43385
+ }) : batchIncludePaths(passBaseArgs, passFiles);
43206
43386
  const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
43207
43387
  if (files.length === 0) return {
43208
43388
  diagnostics: [],
@@ -43216,7 +43396,7 @@ const runOxlint = async (options) => {
43216
43396
  };
43217
43397
  const passConfigPath = Path.join(configDirectory, configFileName);
43218
43398
  const passBaseArgs = makeBaseArgs(passConfigPath);
43219
- const passFileBatches = batchIncludePaths(passBaseArgs, files);
43399
+ const passFileBatches = buildFileBatches(passBaseArgs, files);
43220
43400
  const spawnPass = () => spawnLintBatches({
43221
43401
  baseArgs: passBaseArgs,
43222
43402
  fileBatches: passFileBatches,
@@ -43265,6 +43445,7 @@ const runOxlint = async (options) => {
43265
43445
  const cacheKeyByFile = /* @__PURE__ */ new Map();
43266
43446
  const missFiles = [];
43267
43447
  const replayedDiagnostics = [];
43448
+ let hashSliceStartedAt = performance.now();
43268
43449
  for (const candidateFile of candidateFiles) {
43269
43450
  const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
43270
43451
  if (contentHash === null) {
@@ -43276,6 +43457,10 @@ const runOxlint = async (options) => {
43276
43457
  const cachedDiagnostics = cache.lookup(cacheKey);
43277
43458
  if (cachedDiagnostics === null) missFiles.push(candidateFile);
43278
43459
  else replayedDiagnostics.push(...cachedDiagnostics);
43460
+ if (performance.now() - hashSliceStartedAt >= 12) {
43461
+ await yieldToEventLoop();
43462
+ hashSliceStartedAt = performance.now();
43463
+ }
43279
43464
  }
43280
43465
  const cacheHitFileCount = candidateFiles.length - missFiles.length;
43281
43466
  const fullResult = await runConfigOverFiles((overrides) => buildConfig({
@@ -43318,7 +43503,7 @@ const runOxlint = async (options) => {
43318
43503
  ]);
43319
43504
  }
43320
43505
  const baseArgs = makeBaseArgs(configPath);
43321
- const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
43506
+ const fileBatches = buildFileBatches(baseArgs, candidateFiles);
43322
43507
  const runBatches = () => spawnLintBatches({
43323
43508
  baseArgs,
43324
43509
  fileBatches,
@@ -44205,7 +44390,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
44205
44390
  const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
44206
44391
  rootDirectory: scanDirectory,
44207
44392
  parseConcurrency: deadCodeParseConcurrency,
44208
- workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
44393
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs,
44394
+ onCacheOutcome: (didHitCache) => {
44395
+ deadCodeCacheHit = didHitCache;
44396
+ }
44209
44397
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
44210
44398
  yield* set(deadCodeFailure, {
44211
44399
  didFail: true,
@@ -44233,6 +44421,7 @@ const runInspect = (input, hooks = {}) => gen(function* () {
44233
44421
  let lastReportedTotalFileCount = 0;
44234
44422
  let lintCacheHitFileCount = null;
44235
44423
  let lintCacheTotalFileCount = null;
44424
+ let deadCodeCacheHit = null;
44236
44425
  const baseLintStream = linterService.run({
44237
44426
  rootDirectory: scanDirectory,
44238
44427
  project,
@@ -44362,6 +44551,7 @@ const runInspect = (input, hooks = {}) => gen(function* () {
44362
44551
  securityScanFailed,
44363
44552
  lintCacheHitFileCount,
44364
44553
  lintCacheTotalFileCount,
44554
+ deadCodeCacheHit: lintFailureState.didFail ? null : deadCodeCacheHit,
44365
44555
  suppressedRuleCounts: transform.summarizeSuppressions()
44366
44556
  };
44367
44557
  }).pipe(withSpan("runInspect", { attributes: {
@@ -45143,6 +45333,7 @@ const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.
45143
45333
  const REACT_DOCTOR_CONFIG_PROJECT_NAME = "react-doctor";
45144
45334
  const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
45145
45335
  const BASELINE_FILES_TEMP_DIR_PREFIX = "react-doctor-baseline-";
45336
+ const RUN_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
45146
45337
  const GH_DEFAULT_BRANCH_PROBE_TIMEOUT_MS = 5e3;
45147
45338
  const CI_TRUST_COMPANIES = "PayPal, Rippling, and Alibaba";
45148
45339
  const WELCOME_EXPLANATION_HOLD_MS = 1e3;
@@ -45233,7 +45424,7 @@ const makeNoopConsole = () => ({
45233
45424
  });
45234
45425
  //#endregion
45235
45426
  //#region src/cli/utils/version.ts
45236
- const VERSION = "0.6.2";
45427
+ const VERSION = "0.6.3-dev.20d81f6";
45237
45428
  //#endregion
45238
45429
  //#region src/cli/utils/json-mode.ts
45239
45430
  let context = null;
@@ -45602,13 +45793,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
45602
45793
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
45603
45794
  * standard `SENTRY_RELEASE` override.
45604
45795
  */
45605
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.6.2`;
45796
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.6.3-dev.20d81f6`;
45606
45797
  /**
45607
45798
  * Deployment environment shown in Sentry's environment filter. Defaults to
45608
45799
  * `production` for tagged releases and `development` for dev/unbuilt versions,
45609
45800
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
45610
45801
  */
45611
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.6.2") ? "development" : "production");
45802
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.6.3-dev.20d81f6") ? "development" : "production");
45612
45803
  /**
45613
45804
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
45614
45805
  * (set to `0` to disable tracing) and falls back to
@@ -45809,7 +46000,8 @@ const runGit = (projectRoot, args) => {
45809
46000
  "ignore",
45810
46001
  "pipe",
45811
46002
  "ignore"
45812
- ]
46003
+ ],
46004
+ maxBuffer: RUN_GIT_MAX_BUFFER_BYTES
45813
46005
  }).trim();
45814
46006
  } catch {
45815
46007
  return null;
@@ -57835,7 +58027,8 @@ const buildOutcomeAttributes = (input) => {
57835
58027
  }),
57836
58028
  ...withNamespace("deadCode", {
57837
58029
  failed: input.didDeadCodeFail ?? null,
57838
- overlapped: input.deadCodeOverlapped ?? null
58030
+ overlapped: input.deadCodeOverlapped ?? null,
58031
+ cacheHit: result.deadCodeCacheHit ?? null
57839
58032
  }),
57840
58033
  ...withNamespace("supplyChain", { overlapTimedOut: input.supplyChainOverlapTimedOut ?? null }),
57841
58034
  ...withNamespace("securityScan", { failed: input.securityScanFailed ?? null }),
@@ -59158,13 +59351,89 @@ const stringifyStableJson = (value) => {
59158
59351
  };
59159
59352
  const hashString = (value) => crypto.createHash("sha1").update(value).digest("hex");
59160
59353
  const readHeadSha = (projectDirectory) => runGit(projectDirectory, ["rev-parse", "HEAD"]);
59161
- const isWorktreeClean = (projectDirectory) => {
59162
- const status = runGit(projectDirectory, [
59354
+ const parseWorktreeStatusRecords = (statusOutput) => {
59355
+ const entries = [];
59356
+ const tokens = statusOutput.split("\0");
59357
+ for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) {
59358
+ let record = tokens[tokenIndex];
59359
+ if (record.length === 0) continue;
59360
+ if (record.length < 3 || record[2] !== " ") {
59361
+ if (tokenIndex !== 0) return null;
59362
+ record = ` ${record}`;
59363
+ }
59364
+ if (record.length < 4 || record[2] !== " ") return null;
59365
+ const statusCode = record.slice(0, 2);
59366
+ const recordPath = record.slice(3);
59367
+ if (statusCode.includes("R") || statusCode.includes("C")) {
59368
+ tokenIndex += 1;
59369
+ const originPath = tokens[tokenIndex];
59370
+ if (originPath === void 0 || originPath.length === 0) return null;
59371
+ entries.push({
59372
+ statusCode,
59373
+ path: recordPath,
59374
+ originPath
59375
+ });
59376
+ continue;
59377
+ }
59378
+ entries.push({
59379
+ statusCode,
59380
+ path: recordPath
59381
+ });
59382
+ }
59383
+ return entries;
59384
+ };
59385
+ const buildDirtyPathContentFingerprint = (absolutePath, statusCode) => {
59386
+ let stats;
59387
+ try {
59388
+ stats = fs.lstatSync(absolutePath);
59389
+ } catch {
59390
+ return statusCode.includes("D") ? "deleted" : null;
59391
+ }
59392
+ if (stats.isSymbolicLink()) try {
59393
+ return `link:${fs.readlinkSync(absolutePath)}`;
59394
+ } catch {
59395
+ return null;
59396
+ }
59397
+ if (!stats.isFile()) return null;
59398
+ if (stats.size > 10485760) return `stat:${stats.mtimeMs}:${stats.size}`;
59399
+ return hashFileContents(absolutePath);
59400
+ };
59401
+ const buildWorktreeFingerprint = (projectDirectory) => {
59402
+ const statusOutput = runGit(projectDirectory, [
59163
59403
  "status",
59164
59404
  "--porcelain=v1",
59165
- "--untracked-files=normal"
59405
+ "-z",
59406
+ "--untracked-files=all"
59166
59407
  ]);
59167
- return status !== null && status.length === 0;
59408
+ if (statusOutput === null) return null;
59409
+ if (statusOutput.length === 0) return [];
59410
+ const statusEntries = parseWorktreeStatusRecords(statusOutput);
59411
+ if (statusEntries === null) return null;
59412
+ if (statusEntries.length > 300) return null;
59413
+ const repositoryRoot = runGit(projectDirectory, ["rev-parse", "--show-toplevel"]);
59414
+ if (repositoryRoot === null) return null;
59415
+ const dirtyEntries = [];
59416
+ for (const statusEntry of statusEntries) {
59417
+ const contentFingerprint = buildDirtyPathContentFingerprint(path.join(repositoryRoot, statusEntry.path), statusEntry.statusCode);
59418
+ if (contentFingerprint === null) return null;
59419
+ dirtyEntries.push({
59420
+ ...statusEntry,
59421
+ contentFingerprint
59422
+ });
59423
+ }
59424
+ return dirtyEntries.sort((firstEntry, secondEntry) => {
59425
+ const firstSortKey = `${firstEntry.path}${firstEntry.statusCode}`;
59426
+ const secondSortKey = `${secondEntry.path}${secondEntry.statusCode}`;
59427
+ return firstSortKey < secondSortKey ? -1 : firstSortKey > secondSortKey ? 1 : 0;
59428
+ });
59429
+ };
59430
+ const DOTENV_FILE_NAME_PATTERN = /^\.env(\.|$)/;
59431
+ const resolveDotenvFingerprint = (projectDirectory) => {
59432
+ try {
59433
+ return fs.readdirSync(projectDirectory).filter((entryName) => DOTENV_FILE_NAME_PATTERN.test(entryName)).sort().map((entryName) => `${entryName}=${fileFingerprint(path.join(projectDirectory, entryName)) ?? "unreadable"}`);
59434
+ } catch {
59435
+ return [];
59436
+ }
59168
59437
  };
59169
59438
  const hasHiddenTrackedFileState = (projectDirectory) => {
59170
59439
  const output = runGit(projectDirectory, ["ls-files", "-v"]);
@@ -59247,8 +59516,9 @@ const resolveToolchainFingerprint = (nodeBinaryPath) => {
59247
59516
  };
59248
59517
  const buildScanResultCacheKey = (input) => {
59249
59518
  if (isScanResultCacheDisabled()) return null;
59250
- if (!isWorktreeClean(input.projectDirectory)) return null;
59251
59519
  if (hasHiddenTrackedFileState(input.projectDirectory)) return null;
59520
+ const worktreeFingerprint = buildWorktreeFingerprint(input.projectDirectory);
59521
+ if (worktreeFingerprint === null) return null;
59252
59522
  const headSha = readHeadSha(input.projectDirectory);
59253
59523
  if (headSha === null) return null;
59254
59524
  if (stringifyStableJson(input.userConfig) === null) return null;
@@ -59256,6 +59526,8 @@ const buildScanResultCacheKey = (input) => {
59256
59526
  schemaVersion: 3,
59257
59527
  projectIdentity: resolveProjectIdentity(input.projectDirectory),
59258
59528
  headSha,
59529
+ worktreeFingerprint,
59530
+ dotenvFingerprint: resolveDotenvFingerprint(input.projectDirectory),
59259
59531
  reactDoctorVersion: input.version,
59260
59532
  nodeVersion: process.version,
59261
59533
  toolchainFingerprint: resolveToolchainFingerprint(input.nodeBinaryPath),
@@ -59648,7 +59920,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
59648
59920
  scanMode: baselineDelta ? "baseline" : isDiffMode ? "diff" : "full",
59649
59921
  baselineDegraded,
59650
59922
  lintCacheHitFileCount: output.lintCacheHitFileCount,
59651
- lintCacheTotalFileCount: output.lintCacheTotalFileCount
59923
+ lintCacheTotalFileCount: output.lintCacheTotalFileCount,
59924
+ deadCodeCacheHit: output.deadCodeCacheHit
59652
59925
  });
59653
59926
  recordOnboardingCompletion(options);
59654
59927
  return result;
@@ -59683,6 +59956,7 @@ const renderAndRecordScan = async (input) => {
59683
59956
  scanElapsedMilliseconds: input.payload.scanElapsedMilliseconds,
59684
59957
  lintCacheHitFileCount: input.lintCacheHitFileCount ?? null,
59685
59958
  lintCacheTotalFileCount: input.lintCacheTotalFileCount ?? null,
59959
+ deadCodeCacheHit: input.deadCodeCacheHit ?? null,
59686
59960
  baselineDelta: input.payload.baselineDelta
59687
59961
  }), input.options.silent));
59688
59962
  const { workerCount: resolvedWorkerCount, parallel } = resolveWorkerTelemetry(input.payload.scanConcurrency, input.options.concurrency);
@@ -59721,7 +59995,7 @@ const renderAndRecordScan = async (input) => {
59721
59995
  return result;
59722
59996
  };
59723
59997
  const finalizeAndRender = (input) => gen(function* () {
59724
- const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds, lintCacheHitFileCount, lintCacheTotalFileCount, baselineDelta } = input;
59998
+ const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds, lintCacheHitFileCount, lintCacheTotalFileCount, deadCodeCacheHit, baselineDelta } = input;
59725
59999
  const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
59726
60000
  didLintFail,
59727
60001
  lintFailureReason,
@@ -59745,6 +60019,7 @@ const finalizeAndRender = (input) => gen(function* () {
59745
60019
  lintCacheHitFileCount,
59746
60020
  lintCacheTotalFileCount
59747
60021
  } : {},
60022
+ ...deadCodeCacheHit !== null ? { deadCodeCacheHit } : {},
59748
60023
  ...baselineDelta ? { baselineDelta } : {}
59749
60024
  });
59750
60025
  if (options.suppressRendering) return buildResult();
@@ -63591,4 +63866,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
63591
63866
  export {};
63592
63867
 
63593
63868
  //# sourceMappingURL=cli.js.map
63594
- //# debugId=a9894975-1858-5b46-8a22-ae1680ad9a8a
63869
+ //# debugId=d7bda084-a223-5aa7-8605-578888bd1b33