react-doctor 0.5.7 → 0.5.8-dev.229ea2e

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,7 +8,7 @@ import path from "node:path";
8
8
  import * as NodeChildProcess from "node:child_process";
9
9
  import { spawn, spawnSync } from "node:child_process";
10
10
  import * as ts from "typescript";
11
- import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, 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_RULE_IDS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, REACT_DOCTOR_RULES, classifySecurityScanFile, 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";
@@ -7224,7 +7224,7 @@ const provideContext$1 = /* @__PURE__ */ dual(2, (self, context) => {
7224
7224
  return updateContext$1(self, merge$3(context));
7225
7225
  });
7226
7226
  /** @internal */
7227
- const provideService$1 = function() {
7227
+ const provideService$3 = function() {
7228
7228
  if (arguments.length === 1) return dual(2, (self, impl) => provideServiceImpl(self, arguments[0], impl));
7229
7229
  return dual(3, (self, service, impl) => provideServiceImpl(self, service, impl)).apply(this, arguments);
7230
7230
  };
@@ -7445,7 +7445,7 @@ const constScopeEmpty = { _tag: "Empty" };
7445
7445
  /** @internal */
7446
7446
  const scope = scopeTag;
7447
7447
  /** @internal */
7448
- const provideScope = /* @__PURE__ */ provideService$1(scopeTag);
7448
+ const provideScope = /* @__PURE__ */ provideService$3(scopeTag);
7449
7449
  /** @internal */
7450
7450
  const scoped$1 = (self) => withFiber$1((fiber) => {
7451
7451
  const prev = fiber.context;
@@ -7888,7 +7888,7 @@ const makeLatchUnsafe = (open) => new Latch(open ?? false);
7888
7888
  /** @internal */
7889
7889
  const makeLatch = (open) => sync$2(() => makeLatchUnsafe(open));
7890
7890
  /** @internal */
7891
- const withTracerEnabled$1 = /* @__PURE__ */ provideService$1(TracerEnabled);
7891
+ const withTracerEnabled$1 = /* @__PURE__ */ provideService$3(TracerEnabled);
7892
7892
  const bigint0 = /* @__PURE__ */ BigInt(0);
7893
7893
  const NoopSpanProto = {
7894
7894
  _tag: "Span",
@@ -7969,7 +7969,7 @@ const useSpan$1 = (name, ...args) => {
7969
7969
  }));
7970
7970
  });
7971
7971
  };
7972
- const provideParentSpan = /* @__PURE__ */ provideService$1(ParentSpan);
7972
+ const provideParentSpan = /* @__PURE__ */ provideService$3(ParentSpan);
7973
7973
  /** @internal */
7974
7974
  const withParentSpan$1 = function() {
7975
7975
  const dataFirst = isEffect$1(arguments[0]);
@@ -9536,7 +9536,7 @@ var CurrentMemoMap = class extends Service()("effect/Layer/CurrentMemoMap") {
9536
9536
  * @category memo map
9537
9537
  * @since 2.0.0
9538
9538
  */
9539
- const buildWithMemoMap = /* @__PURE__ */ dual(3, (self, memoMap, scope) => provideService$1(map$4(self.build(memoMap, scope), add(CurrentMemoMap, memoMap)), CurrentMemoMap, memoMap));
9539
+ const buildWithMemoMap = /* @__PURE__ */ dual(3, (self, memoMap, scope) => provideService$3(map$4(self.build(memoMap, scope), add(CurrentMemoMap, memoMap)), CurrentMemoMap, memoMap));
9540
9540
  /**
9541
9541
  * Builds a layer into an `Effect` value. Any resources associated with this
9542
9542
  * layer will be released when the specified scope is closed unless their scope
@@ -10889,7 +10889,7 @@ const provide$1 = /* @__PURE__ */ dual((args) => isEffect$1(args[0]), (self, sou
10889
10889
  /** @internal */
10890
10890
  const repeatOrElse = /* @__PURE__ */ dual(3, (self, schedule, orElse) => flatMap$4(toStepWithMetadata(schedule), (step) => {
10891
10891
  let meta = CurrentMetadata.defaultValue();
10892
- return catch_$2(forever$2(tap$2(flatMap$4(suspend$3(() => provideService$1(self, CurrentMetadata, meta)), step), (meta_) => sync$2(() => {
10892
+ return catch_$2(forever$2(tap$2(flatMap$4(suspend$3(() => provideService$3(self, CurrentMetadata, meta)), step), (meta_) => sync$2(() => {
10893
10893
  meta = meta_;
10894
10894
  })), { disableYield: true }), (error) => isDone$2(error) ? succeed$5(error.value) : orElse(error, meta.attempt === 0 ? none() : some(meta)));
10895
10895
  }));
@@ -10897,7 +10897,7 @@ const repeatOrElse = /* @__PURE__ */ dual(3, (self, schedule, orElse) => flatMap
10897
10897
  const retryOrElse = /* @__PURE__ */ dual(3, (self, policy, orElse) => flatMap$4(toStepWithMetadata(policy), (step) => {
10898
10898
  let meta = CurrentMetadata.defaultValue();
10899
10899
  let lastError;
10900
- const loop = catch_$2(suspend$3(() => provideService$1(self, CurrentMetadata, meta)), (error) => {
10900
+ const loop = catch_$2(suspend$3(() => provideService$3(self, CurrentMetadata, meta)), (error) => {
10901
10901
  lastError = error;
10902
10902
  return flatMap$4(step(error), (meta_) => {
10903
10903
  meta = meta_;
@@ -12977,7 +12977,7 @@ const updateContext = updateContext$1;
12977
12977
  * @category Context
12978
12978
  * @since 2.0.0
12979
12979
  */
12980
- const provideService = provideService$1;
12980
+ const provideService$2 = provideService$3;
12981
12981
  /**
12982
12982
  * Scopes all resources used in this workflow to the lifetime of the workflow,
12983
12983
  * ensuring that their finalizers are run as soon as this workflow completes
@@ -18019,6 +18019,20 @@ function decodeUnknownOption$1(schema, options) {
18019
18019
  return asOption(decodeUnknownEffect(schema, options));
18020
18020
  }
18021
18021
  /**
18022
+ * Creates a synchronous decoder for `unknown` input.
18023
+ *
18024
+ * **Details**
18025
+ *
18026
+ * The returned function returns the decoded `Type` on success and throws an
18027
+ * `Error` with the `SchemaIssue.Issue` in its `cause` on decoding failure.
18028
+ *
18029
+ * @category decoding
18030
+ * @since 3.10.0
18031
+ */
18032
+ function decodeUnknownSync$1(schema, options) {
18033
+ return asSync(decodeUnknownEffect(schema, options));
18034
+ }
18035
+ /**
18022
18036
  * Creates an effectful encoder for `unknown` input.
18023
18037
  *
18024
18038
  * **Details**
@@ -18320,6 +18334,40 @@ function isSchemaError(u) {
18320
18334
  */
18321
18335
  const decodeUnknownOption = decodeUnknownOption$1;
18322
18336
  /**
18337
+ * Decodes an `unknown` input against a schema synchronously, returning the
18338
+ * decoded value or throwing an `Error` whose cause contains the schema issue.
18339
+ * Use this when you want to validate data at a boundary and treat a schema
18340
+ * mismatch as an exception. For typed input use `decodeSync`.
18341
+ *
18342
+ * **Details**
18343
+ *
18344
+ * Only service-free schemas can be decoded synchronously. For non-throwing
18345
+ * alternatives see `decodeUnknownOption`, `decodeUnknownExit`, or
18346
+ * `decodeUnknownEffect`. Options may be provided either when creating the
18347
+ * decoder or when applying it; application options override creation options.
18348
+ *
18349
+ * **Example** (Decoding with a transformation schema)
18350
+ *
18351
+ * ```ts
18352
+ * import { Schema } from "effect"
18353
+ *
18354
+ * const NumberFromString = Schema.NumberFromString
18355
+ *
18356
+ * console.log(Schema.decodeUnknownSync(NumberFromString)("42"))
18357
+ * // Output: 42
18358
+ *
18359
+ * Schema.decodeUnknownSync(NumberFromString)("not a number")
18360
+ * // throws SchemaError: NumberFromString
18361
+ * // └─ Encoded side transformation failure
18362
+ * // └─ NumberFromString
18363
+ * // └─ Expected a numeric string, actual "not a number"
18364
+ * ```
18365
+ *
18366
+ * @category decoding
18367
+ * @since 4.0.0
18368
+ */
18369
+ const decodeUnknownSync = decodeUnknownSync$1;
18370
+ /**
18323
18371
  * Encodes an `unknown` input against a schema synchronously, throwing a
18324
18372
  * {@link SchemaError} on failure. Use this when you want to serialize data at a
18325
18373
  * boundary and treat a schema mismatch as an unrecoverable error. For
@@ -25206,6 +25254,14 @@ const runWith = (self, f, onHalt) => suspend$2(() => {
25206
25254
  return catchDone(flatMap$2(toTransform(self)(done$1(), scope), f), onHalt ? onHalt : succeed$2).pipe(onExit$1((exit) => close(scope, exit)));
25207
25255
  });
25208
25256
  /**
25257
+ * Provides a concrete service for a context key, removing that service
25258
+ * requirement from the returned channel.
25259
+ *
25260
+ * @category services
25261
+ * @since 2.0.0
25262
+ */
25263
+ const provideService$1 = /* @__PURE__ */ dual(3, (self, key, service) => fromTransform$1((upstream, scope) => map$3(provideService$2(toTransform(self)(upstream, scope), key, service), provideService$2(key, service))));
25264
+ /**
25209
25265
  * Runs a channel and applies an effect to each output element.
25210
25266
  *
25211
25267
  * **Example** (Running effects for each output)
@@ -26594,6 +26650,44 @@ const splitLines = (self) => self.channel.pipe(pipeTo(splitLines$1()), fromChann
26594
26650
  */
26595
26651
  const ensuring = /* @__PURE__ */ dual(2, (self, finalizer) => fromChannel(ensuring$1(self.channel, finalizer)));
26596
26652
  /**
26653
+ * Provides the stream with a single required service, eliminating that
26654
+ * requirement from its environment.
26655
+ *
26656
+ * **Example** (Providing a stream service)
26657
+ *
26658
+ * ```ts
26659
+ * import { Console, Context, Effect, Stream } from "effect"
26660
+ *
26661
+ * class Greeter extends Context.Service<Greeter, {
26662
+ * greet: (name: string) => string
26663
+ * }>()("Greeter") {}
26664
+ *
26665
+ * const stream = Stream.fromEffect(
26666
+ * Effect.service(Greeter).pipe(
26667
+ * Effect.map((greeter) => greeter.greet("Ada"))
26668
+ * )
26669
+ * )
26670
+ *
26671
+ * const program = Effect.gen(function*() {
26672
+ * const collected = yield* Stream.runCollect(
26673
+ * stream.pipe(
26674
+ * Stream.provideService(Greeter, {
26675
+ * greet: (name) => `Hello, ${name}`
26676
+ * })
26677
+ * )
26678
+ * )
26679
+ * yield* Console.log(collected)
26680
+ * })
26681
+ *
26682
+ * Effect.runPromise(program)
26683
+ * //=> ["Hello, Ada"]
26684
+ * ```
26685
+ *
26686
+ * @category services
26687
+ * @since 2.0.0
26688
+ */
26689
+ const provideService = /* @__PURE__ */ dual(3, (self, key, service) => fromChannel(provideService$1(self.channel, key, service)));
26690
+ /**
26597
26691
  * Runs a stream with a sink and returns the sink result.
26598
26692
  *
26599
26693
  * **Example** (Running a stream with a sink)
@@ -30023,7 +30117,7 @@ const make$8 = /* @__PURE__ */ fnUntraced(function* (options) {
30023
30117
  const runFork = runForkWith(services);
30024
30118
  const exportInterval = max(fromInputUnsafe(options.exportInterval), zero);
30025
30119
  let disabledUntil = void 0;
30026
- const client = filterStatusOk(get$4(services, HttpClient)).pipe(transformResponse(provideService(TracerPropagationEnabled, false)), retryTransient({
30120
+ const client = filterStatusOk(get$4(services, HttpClient)).pipe(transformResponse(provideService$2(TracerPropagationEnabled, false)), retryTransient({
30027
30121
  schedule: policy,
30028
30122
  times: 3
30029
30123
  }));
@@ -32753,15 +32847,24 @@ const isMinifiedSource = (absolutePath) => {
32753
32847
  if (fileDescriptor !== void 0) NFS.closeSync(fileDescriptor);
32754
32848
  }
32755
32849
  };
32756
- const isLargeMinifiedFile = (absolutePath) => {
32757
- let sizeBytes;
32850
+ const cachedIsLargeMinifiedByPath = /* @__PURE__ */ new Map();
32851
+ const clearMinifiedFileCache = () => {
32852
+ cachedIsLargeMinifiedByPath.clear();
32853
+ };
32854
+ const statSourceFileSize = (absolutePath) => {
32758
32855
  try {
32759
- sizeBytes = NFS.statSync(absolutePath).size;
32856
+ return NFS.statSync(absolutePath).size;
32760
32857
  } catch {
32761
- return false;
32858
+ return null;
32762
32859
  }
32763
- if (sizeBytes < 2e4) return false;
32764
- return isMinifiedSource(absolutePath);
32860
+ };
32861
+ const isLargeMinifiedFile = (absolutePath, knownSizeBytes) => {
32862
+ const cached = cachedIsLargeMinifiedByPath.get(absolutePath);
32863
+ if (cached !== void 0) return cached;
32864
+ const sizeBytes = knownSizeBytes === void 0 ? statSourceFileSize(absolutePath) : knownSizeBytes;
32865
+ const result = sizeBytes !== null && sizeBytes >= 2e4 && isMinifiedSource(absolutePath);
32866
+ cachedIsLargeMinifiedByPath.set(absolutePath, result);
32867
+ return result;
32765
32868
  };
32766
32869
  const isErrnoException = (error) => error instanceof Error && "code" in error;
32767
32870
  const IGNORABLE_READDIR_ERROR_CODES = new Set([
@@ -33627,6 +33730,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
33627
33730
  const SCORE_API_URL = "https://www.react.doctor/api/score";
33628
33731
  const FETCH_TIMEOUT_MS = 1e4;
33629
33732
  const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
33733
+ const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
33734
+ const PER_WORKER_MEM_BUDGET_BYTES = 1024 * 1024 * 1024;
33630
33735
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
33631
33736
  const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
33632
33737
  const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
@@ -33699,7 +33804,16 @@ const CONFIG_FINGERPRINT_FILENAMES = [
33699
33804
  ];
33700
33805
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
33701
33806
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
33807
+ const NODE_COMPILE_CACHE_DIR_NAME = "node-compile-cache";
33808
+ const DEAD_CODE_WORKER_TIMEOUT_MS = 12e4;
33809
+ const OXLINT_SPLIT_TOTAL_BUDGET_MS = 18e4;
33810
+ const DEAD_CODE_PHASE_TIMEOUT_MS = 15e4;
33811
+ const LINT_PHASE_TIMEOUT_MS = 3e5;
33812
+ const SCAN_TOTAL_DEADLINE_MS = 9e5;
33702
33813
  const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192;
33814
+ const DEAD_CODE_TIMEOUT_CEILING_MS = 6e5;
33815
+ const DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS = 3e4;
33816
+ const DEAD_CODE_OVERLAP_PARSE_SHARE = .4;
33703
33817
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
33704
33818
  const REACT_SERVER_DOM_PACKAGES = [
33705
33819
  "react-server-dom-webpack",
@@ -33734,9 +33848,13 @@ const CONFIG_CACHE_TTL_MS = 300 * 1e3;
33734
33848
  const SOCKET_FREE_PURL_API_BASE = "https://firewall-api.socket.dev/purl";
33735
33849
  const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
33736
33850
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
33851
+ const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
33852
+ const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
33737
33853
  const SUPPLY_CHAIN_PLUGIN = "socket";
33738
33854
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
33739
33855
  const SUPPLY_CHAIN_CATEGORY = "Security";
33856
+ const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
33857
+ const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
33740
33858
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
33741
33859
  const TSCONFIG_FILENAME = "tsconfig.json";
33742
33860
  const isRelativeExtendsValue = (extendsValue) => extendsValue.startsWith("./") || extendsValue.startsWith("../") || Path.isAbsolute(extendsValue);
@@ -34429,7 +34547,10 @@ for (const [legacyRuleKey, nativeRuleKey] of Object.entries(LEGACY_RULE_KEY_TO_N
34429
34547
  NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.set(nativeRuleKey, aliases);
34430
34548
  }
34431
34549
  const getLegacyRuleKeysForNative = (ruleKey) => NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.get(ruleKey) ?? [];
34432
- const canonicalizeRuleKey = (ruleKey) => LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey] ?? ruleKey;
34550
+ const canonicalizeRuleKey = (ruleKey) => {
34551
+ const nativeRuleKey = LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey];
34552
+ return typeof nativeRuleKey === "string" ? nativeRuleKey : ruleKey;
34553
+ };
34433
34554
  const isReactDoctorShortIdOf = (bareRuleKey, qualifiedRuleKey) => !bareRuleKey.includes("/") && qualifiedRuleKey === `react-doctor/${bareRuleKey}`;
34434
34555
  const isSameRuleKey = (candidateRuleKey, targetRuleKey) => {
34435
34556
  const canonicalCandidate = canonicalizeRuleKey(candidateRuleKey);
@@ -35091,6 +35212,11 @@ var OxlintBatchExceeded = class extends TaggedErrorClass()("OxlintBatchExceeded"
35091
35212
  }
35092
35213
  }
35093
35214
  };
35215
+ var ScanDeadlineExceeded = class extends TaggedErrorClass()("ScanDeadlineExceeded", { detail: String$1 }) {
35216
+ get message() {
35217
+ return `Scan exceeded its overall time budget: ${this.detail}`;
35218
+ }
35219
+ };
35094
35220
  var OxlintSpawnFailed = class extends TaggedErrorClass()("OxlintSpawnFailed", { cause: Unknown }) {
35095
35221
  get message() {
35096
35222
  return `Failed to run oxlint: ${pretty(fail$6(this.cause))}`;
@@ -35154,6 +35280,7 @@ var GitBaseBranchInvalid = class extends TaggedErrorClass()("GitBaseBranchInvali
35154
35280
  const ReactDoctorErrorReason = Union([
35155
35281
  OxlintUnavailable,
35156
35282
  OxlintBatchExceeded,
35283
+ ScanDeadlineExceeded,
35157
35284
  OxlintSpawnFailed,
35158
35285
  OxlintOutputUnparseable,
35159
35286
  ConfigParseFailed,
@@ -35204,15 +35331,105 @@ const layerOtlp = unwrap$3(gen(function* () {
35204
35331
  }).pipe(provide$2(layer$8));
35205
35332
  }).pipe(orDie));
35206
35333
  /**
35207
- * Resolves a requested lint worker count to a clamped integer within
35208
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
35209
- * machine's CPU cores; out-of-range or non-finite requests degrade to
35334
+ * Read a positive-millisecond timeout from an env var, falling back to
35335
+ * `defaultMs` when the var is unset, non-finite, or not strictly positive.
35336
+ */
35337
+ const readPositiveEnvMs = (envVarName, defaultMs) => {
35338
+ const rawValue = process.env[envVarName];
35339
+ if (rawValue === void 0) return defaultMs;
35340
+ const parsedValue = Number(rawValue);
35341
+ if (!Number.isFinite(parsedValue) || parsedValue <= 0) return defaultMs;
35342
+ return parsedValue;
35343
+ };
35344
+ const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
35345
+ const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
35346
+ const CGROUP_UNLIMITED_SENTINEL_BYTES = Number.MAX_SAFE_INTEGER;
35347
+ /**
35348
+ * Parses one raw cgroup memory-limit file value into a positive byte count, or
35349
+ * `undefined` when it represents "no limit" (the v2 `"max"` literal, an empty
35350
+ * read, a non-positive / non-finite value, or v1's near-2^63 unlimited
35351
+ * sentinel). Pure and exported so the classification is unit-testable without
35352
+ * touching the filesystem.
35353
+ */
35354
+ const parseCgroupMemoryLimitBytes = (raw) => {
35355
+ if (raw === void 0) return void 0;
35356
+ const trimmed = raw.trim();
35357
+ if (trimmed === "" || trimmed === "max") return void 0;
35358
+ const parsed = Number(trimmed);
35359
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= CGROUP_UNLIMITED_SENTINEL_BYTES) return;
35360
+ return parsed;
35361
+ };
35362
+ const CGROUP_MEMORY_LIMIT_PATHS = [CGROUP_V2_MEMORY_MAX_PATH, CGROUP_V1_MEMORY_LIMIT_PATH];
35363
+ /**
35364
+ * Reads this process's cgroup memory limit in bytes from the first candidate
35365
+ * path that yields a real limit, or `undefined` when none does — no cgroup, no
35366
+ * limit, or the files are unreadable (e.g. macOS / Windows dev machines).
35367
+ * `os.totalmem()` reports the HOST total and ignores cgroup memory limits, so a
35368
+ * memory-constrained container over-reports total memory; `resolveAutoScan-
35369
+ * Concurrency` takes `min(totalmem, this)` to honor the limit.
35370
+ *
35371
+ * The cgroup v2 read is the mount-root `memory.max`, which IS the container's
35372
+ * limit under the standard cgroup-namespace setup CI runners use (the
35373
+ * container's own cgroup is the root of its namespaced view). A process in a
35374
+ * non-namespaced nested/delegated cgroup whose root reads `"max"` is not
35375
+ * detected here and falls back to the host total; the EAGAIN/ENOMEM serial
35376
+ * replay in `spawnLintBatches` remains the runtime backstop for that case.
35377
+ *
35378
+ * `candidatePaths` is injectable so tests exercise the v2-wins-over-v1
35379
+ * precedence, the skip-unreadable fallback, and the all-missing case without a
35380
+ * real `/sys/fs/cgroup`.
35381
+ */
35382
+ const readCgroupMemoryLimitBytes = (candidatePaths = CGROUP_MEMORY_LIMIT_PATHS) => {
35383
+ for (const limitPath of candidatePaths) {
35384
+ let raw;
35385
+ try {
35386
+ raw = fs.readFileSync(limitPath, "utf8");
35387
+ } catch {
35388
+ continue;
35389
+ }
35390
+ const limitBytes = parseCgroupMemoryLimitBytes(raw);
35391
+ if (limitBytes !== void 0) return limitBytes;
35392
+ }
35393
+ };
35394
+ /**
35395
+ * Clamps a requested lint worker count to `[MIN_SCAN_CONCURRENCY,
35396
+ * HARD_MAX_SCAN_CONCURRENCY]` as a finite integer. This is the explicit-pin and
35397
+ * spawn-boundary clamp — the memory-and-core-budgeted auto count comes from
35398
+ * `resolveAutoScanConcurrency`. Out-of-range or non-finite requests degrade to
35210
35399
  * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
35211
35400
  */
35212
35401
  const resolveScanConcurrency = (requested) => {
35213
- const desired = requested === "auto" ? os.availableParallelism() : requested;
35214
- if (!Number.isFinite(desired) || desired < 1) return 1;
35215
- return Math.max(1, Math.min(Math.floor(desired), 16));
35402
+ if (!Number.isFinite(requested) || requested < 1) return 1;
35403
+ return Math.min(Math.floor(requested), 32);
35404
+ };
35405
+ const readSystemFacts = () => ({
35406
+ availableCores: os.availableParallelism(),
35407
+ totalMemoryBytes: os.totalmem(),
35408
+ cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
35409
+ });
35410
+ /**
35411
+ * Auto lint-worker count: the smaller of the (cgroup-CPU-aware) core count and
35412
+ * the number of `PER_WORKER_MEM_BUDGET_BYTES` workers that fit in available
35413
+ * memory, then clamped to `[MIN, HARD_MAX]` by `resolveScanConcurrency`.
35414
+ *
35415
+ * `os.availableParallelism()` already respects cgroup CPU quotas, so the core
35416
+ * term needs no help. Available memory is `os.totalmem()` floored by the cgroup
35417
+ * memory limit — `os.freemem()` is deliberately NOT used: it excludes
35418
+ * reclaimable page cache and reads near-zero on macOS / cache-heavy Linux, which
35419
+ * would collapse the auto path to a single worker. `os.totalmem()` reports the
35420
+ * host total even inside a container, so the cgroup limit (read directly,
35421
+ * because Node doesn't fold it into `totalmem()`) is the real ceiling there.
35422
+ *
35423
+ * `facts` is injectable so tests exercise core-bound, memory-bound, cgroup-
35424
+ * limited, and ceiling cases without mocking `os` or the filesystem.
35425
+ */
35426
+ const resolveAutoScanConcurrency = (facts = readSystemFacts()) => {
35427
+ const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
35428
+ const memoryBoundedWorkers = Math.floor(availableMemoryBytes / PER_WORKER_MEM_BUDGET_BYTES);
35429
+ return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
35430
+ };
35431
+ const resolveLintBatchOrdering = () => {
35432
+ return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "cost" ? "cost" : "arrival";
35216
35433
  };
35217
35434
  /**
35218
35435
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -35220,11 +35437,38 @@ const resolveScanConcurrency = (requested) => {
35220
35437
  * microVMs without recompiling react-doctor. Tests override via
35221
35438
  * `Layer.succeed(OxlintSpawnTimeoutMs, ...)`.
35222
35439
  */
35223
- var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => {
35224
- const raw = process.env["REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS"];
35225
- if (raw === void 0) return OXLINT_SPAWN_TIMEOUT_MS;
35440
+ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS", OXLINT_SPAWN_TIMEOUT_MS) }) {};
35441
+ /**
35442
+ * Effect-side cap on the lint phase. The env var lets CI / eval runners
35443
+ * raise the phase budget for slow large repos without recompiling.
35444
+ * Tests override via `Layer.succeed(LintPhaseTimeoutMs, ...)`.
35445
+ */
35446
+ var LintPhaseTimeoutMs = class extends Reference("react-doctor/LintPhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_LINT_PHASE_TIMEOUT_MS", LINT_PHASE_TIMEOUT_MS) }) {};
35447
+ /**
35448
+ * Effect-side cap on the dead-code phase, sitting above the in-worker
35449
+ * timeout as a runtime-independent backstop. The env var raises it for
35450
+ * type-heavy projects; tests override via
35451
+ * `Layer.succeed(DeadCodePhaseTimeoutMs, ...)`.
35452
+ */
35453
+ var DeadCodePhaseTimeoutMs = class extends Reference("react-doctor/DeadCodePhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_DEAD_CODE_PHASE_TIMEOUT_MS", DEAD_CODE_PHASE_TIMEOUT_MS) }) {};
35454
+ /**
35455
+ * Overall scan deadline backstop, bounding everything the per-phase
35456
+ * timeouts don't (wedged git / IO). The env var raises it for very
35457
+ * large repos; tests override via `Layer.succeed(ScanDeadlineMs, ...)`.
35458
+ */
35459
+ var ScanDeadlineMs = class extends Reference("react-doctor/ScanDeadlineMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_SCAN_DEADLINE_MS", SCAN_TOTAL_DEADLINE_MS) }) {};
35460
+ /**
35461
+ * Wall-clock budget for the supply-chain check when it runs on a background
35462
+ * fiber overlapping the lint pass. Reads from the env var on startup so the
35463
+ * eval harness can raise the budget under sandbox microVMs (slower network)
35464
+ * without recompiling react-doctor. Tests override via
35465
+ * `Layer.succeed(SupplyChainOverlapTimeoutMs, ...)`.
35466
+ */
35467
+ var SupplyChainOverlapTimeoutMs = class extends Reference("react-doctor/SupplyChainOverlapTimeoutMs", { defaultValue: () => {
35468
+ const raw = process.env["REACT_DOCTOR_SUPPLY_CHAIN_TIMEOUT_MS"];
35469
+ if (raw === void 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
35226
35470
  const parsed = Number(raw);
35227
- if (!Number.isFinite(parsed) || parsed <= 0) return OXLINT_SPAWN_TIMEOUT_MS;
35471
+ if (!Number.isFinite(parsed) || parsed <= 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
35228
35472
  return parsed;
35229
35473
  } }) {};
35230
35474
  /**
@@ -35235,31 +35479,93 @@ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTime
35235
35479
  */
35236
35480
  var OxlintOutputMaxBytes = class extends Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
35237
35481
  /**
35238
- * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
35239
- * to auto-detected CPU cores (parallel) so large repos scan fast out of
35240
- * the box; `spawnLintBatches` transparently falls back to a single worker
35241
- * if a parallel run exhausts system resources. The CLI's `--no-parallel`
35242
- * flag forces serial via `Layer.succeed`; the `REACT_DOCTOR_PARALLEL` env
35243
- * var seeds the default for programmatic / CI callers that never touch the
35244
- * flag parallelism is opt-OUT, so only the explicit serial values pin
35245
- * one worker:
35246
- *
35247
- * - unset / `auto` / `true` / `on` → available CPU cores (clamped)
35482
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults to a
35483
+ * memory-and-core-budgeted auto count (`resolveAutoScanConcurrency`) so large
35484
+ * repos scan fast out of the box without OOMing the native binding on a
35485
+ * high-core / low-memory box; `spawnLintBatches` transparently falls back to a
35486
+ * single worker if a parallel run still exhausts system resources. The CLI's
35487
+ * `--no-parallel` flag forces serial via `Layer.succeed`; the
35488
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic / CI
35489
+ * callers that never touch the flag — parallelism is opt-OUT, so only the
35490
+ * explicit serial values pin one worker:
35491
+ *
35492
+ * - unset / `auto` / `true` / `on` → memory-and-core-budgeted auto count
35248
35493
  * - `0` / `false` / `off` → `1` (serial)
35249
35494
  * - a positive integer → that many workers (clamped)
35250
- * - any other value → available CPU cores (clamped)
35495
+ * - any other value → memory-and-core-budgeted auto count
35251
35496
  *
35252
35497
  * The resolved value is always within
35253
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
35498
+ * `[MIN_SCAN_CONCURRENCY, HARD_MAX_SCAN_CONCURRENCY]`.
35254
35499
  */
35255
35500
  var OxlintConcurrency = class extends Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
35256
35501
  const raw = process.env["REACT_DOCTOR_PARALLEL"];
35257
- if (raw === void 0) return resolveScanConcurrency("auto");
35502
+ if (raw === void 0) return resolveAutoScanConcurrency();
35258
35503
  const normalized = raw.trim().toLowerCase();
35259
35504
  if (normalized === "0" || normalized === "false" || normalized === "off") return 1;
35260
35505
  const parsed = Number.parseInt(normalized, 10);
35261
35506
  if (Number.isInteger(parsed) && parsed > 0) return resolveScanConcurrency(parsed);
35262
- return resolveScanConcurrency("auto");
35507
+ return resolveAutoScanConcurrency();
35508
+ } }) {};
35509
+ /**
35510
+ * Three-state control for overlapping the dead-code pass with the lint pass —
35511
+ * forking dead-code as a child fiber that runs DURING lint instead of strictly
35512
+ * after it.
35513
+ *
35514
+ * - `"auto"` (default) / `"off"` → strictly SEQUENTIAL: dead-code runs after
35515
+ * lint with the full core budget. Both deslop's parse pool and the oxlint
35516
+ * pool are CPU-bound and each size themselves to all cores, so overlapping
35517
+ * them only oversubscribes (~2x the cores) and starves the parse pass past
35518
+ * its timeout — for no wall-clock win, since there are no spare cores to
35519
+ * absorb the second pass. Sequential is both faster per-phase and safe.
35520
+ * - `"on"` → force the overlap anyway. The orchestrator then SPLITS the core
35521
+ * budget (`DEAD_CODE_OVERLAP_PARSE_SHARE`): deslop's parse pool is capped
35522
+ * and lint shrinks to the remainder, so the two sum to the cores instead of
35523
+ * doubling them, and the dead-code timeout scales up for the reduced share.
35524
+ *
35525
+ * Seeded from `REACT_DOCTOR_DEAD_CODE_OVERLAP` so operators get a redeploy-free
35526
+ * switch; tests pin it via `Layer.succeed(DeadCodeOverlap, ...)`.
35527
+ */
35528
+ var DeadCodeOverlap = class extends Reference("react-doctor/DeadCodeOverlap", { defaultValue: () => {
35529
+ const raw = process.env["REACT_DOCTOR_DEAD_CODE_OVERLAP"]?.trim().toLowerCase();
35530
+ if (raw === "on" || raw === "true" || raw === "1") return "on";
35531
+ if (raw === "off" || raw === "false" || raw === "0") return "off";
35532
+ return "auto";
35533
+ } }) {};
35534
+ /**
35535
+ * How the full-scan lint pass orders its file batches. `"arrival"` (the
35536
+ * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
35537
+ * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
35538
+ * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
35539
+ * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
35540
+ * batch is a straggler (and can trip the per-batch timeout + split), measurably
35541
+ * regressing the common full-scan case. LPT needs the heavy files SPREAD across
35542
+ * batches before `cost` earns the default. Tests override via
35543
+ * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
35544
+ * — they pass user-scoped `includePaths` that skip discovery and stay in
35545
+ * arrival order; only the full-scan branch reads it.
35546
+ */
35547
+ var LintBatchOrdering = class extends Reference("react-doctor/LintBatchOrdering", { defaultValue: resolveLintBatchOrdering }) {};
35548
+ const CACHE_DISABLED_VALUES = new Set(["1", "true"]);
35549
+ /**
35550
+ * Whether the per-file lint cache (`runners/oxlint/file-lint-cache.ts`) is
35551
+ * active. Defaults ON — repeat scans re-lint only the files whose content
35552
+ * changed, and correctness is guaranteed byte-identical to a cold scan by the
35553
+ * always-fresh cross-file sidecar. Opt-OUT, two knobs (matching the whole-repo
35554
+ * scan cache's `REACT_DOCTOR_NO_CACHE`):
35555
+ *
35556
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch; disables BOTH the
35557
+ * whole-repo scan cache and this per-file cache.
35558
+ * - `REACT_DOCTOR_NO_FILE_CACHE` — granular: bust only the per-file cache
35559
+ * while keeping the whole-repo short-circuit.
35560
+ *
35561
+ * Tests override via `Layer.succeed(PerFileLintCacheEnabled, false)`.
35562
+ */
35563
+ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintCacheEnabled", { defaultValue: () => {
35564
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35565
+ const noFileCache = process.env["REACT_DOCTOR_NO_FILE_CACHE"]?.toLowerCase() ?? "";
35566
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35567
+ if (CACHE_DISABLED_VALUES.has(noFileCache)) return false;
35568
+ return true;
35263
35569
  } }) {};
35264
35570
  const DIAGNOSTIC_SURFACES = [
35265
35571
  "cli",
@@ -35308,6 +35614,12 @@ const BOOLEAN_FIELD_NAMES = [
35308
35614
  "adoptExistingLintConfig"
35309
35615
  ];
35310
35616
  const STRING_FIELD_NAMES = ["rootDir"];
35617
+ const STRING_ARRAY_FIELD_NAMES = [
35618
+ "projects",
35619
+ "textComponents",
35620
+ "rawTextWrapperComponents",
35621
+ "serverAuthFunctionNames"
35622
+ ];
35311
35623
  const SURFACE_CONTROL_FIELD_NAMES = [
35312
35624
  "includeTags",
35313
35625
  "excludeTags",
@@ -35409,6 +35721,7 @@ const validateConfigTypes = (config) => {
35409
35721
  const validated = { ...config };
35410
35722
  for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
35411
35723
  for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
35724
+ for (const fieldName of STRING_ARRAY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateStringArrayField(fieldName, value));
35412
35725
  applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
35413
35726
  for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
35414
35727
  applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
@@ -35617,6 +35930,8 @@ const assignFixGroups = (diagnostics) => {
35617
35930
  };
35618
35931
  });
35619
35932
  };
35933
+ const compareStrings = (left, right) => left < right ? -1 : left > right ? 1 : 0;
35934
+ const sortDiagnosticsStable = (diagnostics) => [...diagnostics].sort((left, right) => compareStrings(left.filePath, right.filePath) || left.line - right.line || left.column - right.column || compareStrings(left.plugin, right.plugin) || compareStrings(left.rule, right.rule) || compareStrings(left.severity, right.severity) || compareStrings(left.message, right.message));
35620
35935
  const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
35621
35936
  const buildExpoCheckContext = (rootDirectory, expoVersion) => {
35622
35937
  const packageJson = readPackageJson(Path.join(rootDirectory, "package.json"));
@@ -36123,10 +36438,15 @@ const buildHardeningDiagnostic = (input) => ({
36123
36438
  column: input.column ?? 0,
36124
36439
  category: "Security"
36125
36440
  });
36126
- const checkPnpmHardening = (rootDirectory) => {
36127
- if (!isPnpmManagedProject(rootDirectory)) return [];
36128
- const workspacePath = Path.join(rootDirectory, PNPM_WORKSPACE_FILE);
36129
- const settings = parseHardeningSettings(isFile(workspacePath) ? NFS.readFileSync(workspacePath, "utf-8") : "");
36441
+ const checkPnpmHardening = (scanDirectory) => {
36442
+ if (!isPnpmManagedProject(scanDirectory)) return [];
36443
+ const workspacePath = Path.join(scanDirectory, PNPM_WORKSPACE_FILE);
36444
+ const hasWorkspaceFile = isFile(workspacePath);
36445
+ if (!hasWorkspaceFile) {
36446
+ const monorepoRoot = findMonorepoRoot(scanDirectory);
36447
+ if (monorepoRoot !== null && isFile(Path.join(monorepoRoot, PNPM_WORKSPACE_FILE))) return [];
36448
+ }
36449
+ const settings = parseHardeningSettings(hasWorkspaceFile ? NFS.readFileSync(workspacePath, "utf-8") : "");
36130
36450
  const diagnostics = [];
36131
36451
  if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
36132
36452
  message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
@@ -36911,6 +37231,22 @@ process.stdin.on("end", () => {
36911
37231
  ...(workerInput.ignorePatterns.length > 0
36912
37232
  ? { ignorePatterns: workerInput.ignorePatterns }
36913
37233
  : {}),
37234
+ // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
37235
+ // unusedDependencies, circularDependencies). Everything else deslop can compute
37236
+ // is pure wasted work for us, and it's the bulk of the runtime:
37237
+ // - semantic: a full TS Program for unusedTypes/enum/class-members/
37238
+ // misclassifiedDependencies (~37-45% of the phase).
37239
+ // - reportCodeQuality: the duplicate-block, complexity, feature-flag,
37240
+ // TypeScript-smell, private-type-leak and re-export-cycle detectors. These
37241
+ // are the single most expensive pass — duplicate-block detection alone was
37242
+ // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
37243
+ // speedup on a large repo.
37244
+ // Both are provably safe: the consumed graph findings are computed by their own
37245
+ // detectors, independent of these passes (confirmed byte-identical on
37246
+ // excalidraw + mui-material + sentry). tsConfigPath stays — the module resolver
37247
+ // needs it for path-alias resolution in the import graph.
37248
+ semantic: { enabled: false },
37249
+ reportCodeQuality: false,
36914
37250
  };
36915
37251
  const result = await analyze(defineConfig(config));
36916
37252
  emit({ ok: true, result: normalizeResult(result) });
@@ -37040,7 +37376,11 @@ const createDeadCodeWorker = (input) => {
37040
37376
  "pipe",
37041
37377
  "pipe"
37042
37378
  ],
37043
- windowsHide: true
37379
+ windowsHide: true,
37380
+ env: input.parseConcurrency === void 0 ? process.env : {
37381
+ ...process.env,
37382
+ DESLOP_PARSE_CONCURRENCY: String(input.parseConcurrency)
37383
+ }
37044
37384
  });
37045
37385
  const stdoutChunks = [];
37046
37386
  const stderrChunks = [];
@@ -37085,28 +37425,25 @@ const createDeadCodeWorker = (input) => {
37085
37425
  }
37086
37426
  };
37087
37427
  };
37088
- const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve, reject) => {
37428
+ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs, abortSignal) => new Promise((resolve, reject) => {
37089
37429
  let didSettle = false;
37090
- const timeoutHandle = setTimeout(() => {
37091
- if (didSettle) return;
37092
- didSettle = true;
37093
- handle.terminate?.();
37094
- reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`));
37095
- }, timeoutMs);
37096
- timeoutHandle.unref?.();
37097
- handle.result.then((value) => {
37098
- if (didSettle) return;
37099
- didSettle = true;
37100
- clearTimeout(timeoutHandle);
37101
- handle.terminate?.();
37102
- resolve(value);
37103
- }, (error) => {
37430
+ const settle = (finish) => {
37104
37431
  if (didSettle) return;
37105
37432
  didSettle = true;
37106
37433
  clearTimeout(timeoutHandle);
37434
+ abortSignal?.removeEventListener("abort", onAbort);
37107
37435
  handle.terminate?.();
37108
- reject(error);
37109
- });
37436
+ finish();
37437
+ };
37438
+ const onAbort = () => settle(() => reject(/* @__PURE__ */ new Error("Dead-code worker aborted.")));
37439
+ const timeoutHandle = setTimeout(() => settle(() => reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`))), timeoutMs);
37440
+ timeoutHandle.unref?.();
37441
+ if (abortSignal?.aborted) {
37442
+ onAbort();
37443
+ return;
37444
+ }
37445
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
37446
+ handle.result.then((value) => settle(() => resolve(value)), (error) => settle(() => reject(error)));
37110
37447
  });
37111
37448
  const checkDeadCode = async (options) => {
37112
37449
  const rootDirectory = toCanonicalPath(options.rootDirectory);
@@ -37118,8 +37455,9 @@ const checkDeadCode = async (options) => {
37118
37455
  entryPatterns,
37119
37456
  tsConfigPath: resolveTsConfigPath(rootDirectory),
37120
37457
  ignorePatterns,
37121
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js")
37122
- }), options.workerTimeoutMs ?? 12e4));
37458
+ deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
37459
+ parseConcurrency: options.parseConcurrency
37460
+ }), options.workerTimeoutMs ?? 12e4, options.abortSignal));
37123
37461
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
37124
37462
  const diagnostics = [];
37125
37463
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -37217,7 +37555,37 @@ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
37217
37555
  return true;
37218
37556
  };
37219
37557
  const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
37220
- const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(Path.resolve(rootDirectory, relativePath)));
37558
+ /**
37559
+ * Budget for the dead-code phase, scaled to the work. deslop's graph build is
37560
+ * CPU-bound and roughly linear in file count, so a fixed 120s cap is too tight
37561
+ * for a large repo (where the pass legitimately runs that long) and is then
37562
+ * tipped over by any concurrent load — silently dropping every dead-code
37563
+ * finding. Scaling the budget with file count (and inversely with the core
37564
+ * share when overlapped) lets the pass complete, while the ceiling still
37565
+ * reclaims a genuinely wedged worker. Returns the in-worker SIGKILL deadline
37566
+ * and the Effect-side phase backstop that sits a margin above it.
37567
+ */
37568
+ const resolveDeadCodeTimeout = (input) => {
37569
+ const coreShareFactor = Math.max(1, input.fullConcurrency / Math.max(1, input.deadCodeConcurrency));
37570
+ const workerTimeoutMs = Math.min(DEAD_CODE_TIMEOUT_CEILING_MS, Math.max(DEAD_CODE_WORKER_TIMEOUT_MS, Math.ceil(input.sourceFileCount * 30 * coreShareFactor)));
37571
+ return {
37572
+ workerTimeoutMs,
37573
+ phaseTimeoutMs: workerTimeoutMs + DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS
37574
+ };
37575
+ };
37576
+ const collectSizedSourceFiles = (rootDirectory, relativePaths) => {
37577
+ const entries = [];
37578
+ for (const relativePath of relativePaths) {
37579
+ const absolutePath = Path.resolve(rootDirectory, relativePath);
37580
+ const sizeBytes = statSourceFileSize(absolutePath);
37581
+ if (isLargeMinifiedFile(absolutePath, sizeBytes)) continue;
37582
+ entries.push({
37583
+ path: relativePath,
37584
+ sizeBytes: sizeBytes ?? 0
37585
+ });
37586
+ }
37587
+ return entries;
37588
+ };
37221
37589
  const listSourceFilesViaGit = (rootDirectory) => {
37222
37590
  const result = spawnSync("git", [
37223
37591
  "ls-files",
@@ -37250,7 +37618,8 @@ const listSourceFilesViaFilesystem = (rootDirectory) => {
37250
37618
  }
37251
37619
  return filePaths;
37252
37620
  };
37253
- const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
37621
+ const listSourceFilesWithSize = (rootDirectory) => collectSizedSourceFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
37622
+ const listSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).map((entry) => entry.path);
37254
37623
  const resolveLintIncludePaths = (rootDirectory, userConfig, project) => {
37255
37624
  if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
37256
37625
  const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
@@ -37293,9 +37662,12 @@ var Config = class Config extends Service()("react-doctor/Config") {
37293
37662
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
37294
37663
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
37295
37664
  return yield* tryPromise({
37296
- try: () => checkDeadCode({
37665
+ try: (signal) => checkDeadCode({
37297
37666
  rootDirectory: input.rootDirectory,
37298
- userConfig: input.userConfig
37667
+ userConfig: input.userConfig,
37668
+ parseConcurrency: input.parseConcurrency,
37669
+ workerTimeoutMs: input.workerTimeoutMs,
37670
+ abortSignal: signal
37299
37671
  }),
37300
37672
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
37301
37673
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -37486,43 +37858,46 @@ var Git = class Git extends Service()("react-doctor/Git") {
37486
37858
  * reason: GitInvocationFailed })` so the rest of the codebase
37487
37859
  * sees a single failure channel.
37488
37860
  */
37489
- const runCommand = (input) => scoped(gen(function* () {
37490
- const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
37491
- cwd: input.directory,
37492
- env: input.env,
37493
- extendEnv: true
37494
- }));
37495
- const maxStdoutBytes = input.maxStdoutBytes;
37496
- const stdoutByteCount = yield* make$13(0);
37497
- const [stdout, stderr, status] = yield* all([
37498
- mkString(decodeText(maxStdoutBytes === void 0 ? handle.stdout : handle.stdout.pipe(tap((chunk) => updateAndGet(stdoutByteCount, (total) => total + chunk.length).pipe(flatMap$2((total) => total > maxStdoutBytes ? fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
37499
- args: [...input.args],
37500
- directory: input.directory,
37501
- cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
37502
- }) })) : void_)))))),
37503
- mkString(decodeText(handle.stderr)),
37504
- handle.exitCode
37505
- ], { concurrency: 3 });
37506
- return {
37507
- status,
37508
- stdout,
37509
- stderr
37510
- };
37511
- })).pipe(catchTag$1("PlatformError", (cause) => {
37512
- if (input.command !== "git") return succeed$2({
37861
+ const runCommand = (input) => {
37862
+ const foldSpawnFailure = (cause) => input.command !== "git" ? succeed$2({
37513
37863
  status: 127,
37514
37864
  stdout: "",
37515
37865
  stderr: String(cause)
37516
- });
37517
- return new ReactDoctorError({ reason: new GitInvocationFailed({
37866
+ }) : fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
37518
37867
  args: [...input.args],
37519
37868
  directory: input.directory,
37520
37869
  cause
37521
- }) });
37522
- }), withSpan("git.exec", { attributes: {
37523
- "git.command": input.command,
37524
- "git.subcommand": input.args[0] ?? ""
37525
- } }));
37870
+ }) }));
37871
+ return scoped(gen(function* () {
37872
+ if (!isDirectory(input.directory)) return yield* foldSpawnFailure(`spawn ENOTDIR (cwd is not a directory: ${input.directory})`);
37873
+ const argvLengthChars = input.command.length + 1 + input.args.reduce((total, arg) => total + arg.length + 1, 0);
37874
+ if (argvLengthChars > 24e3) return yield* foldSpawnFailure(`spawn ENAMETOOLONG (${argvLengthChars} argv chars exceed ${SPAWN_ARGS_MAX_LENGTH_CHARS})`);
37875
+ const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
37876
+ cwd: input.directory,
37877
+ env: input.env,
37878
+ extendEnv: true
37879
+ }));
37880
+ const maxStdoutBytes = input.maxStdoutBytes;
37881
+ const stdoutByteCount = yield* make$13(0);
37882
+ const [stdout, stderr, status] = yield* all([
37883
+ mkString(decodeText(maxStdoutBytes === void 0 ? handle.stdout : handle.stdout.pipe(tap((chunk) => updateAndGet(stdoutByteCount, (total) => total + chunk.length).pipe(flatMap$2((total) => total > maxStdoutBytes ? fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
37884
+ args: [...input.args],
37885
+ directory: input.directory,
37886
+ cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
37887
+ }) })) : void_)))))),
37888
+ mkString(decodeText(handle.stderr)),
37889
+ handle.exitCode
37890
+ ], { concurrency: 3 });
37891
+ return {
37892
+ status,
37893
+ stdout,
37894
+ stderr
37895
+ };
37896
+ })).pipe(catchTag$1("PlatformError", foldSpawnFailure), withSpan("git.exec", { attributes: {
37897
+ "git.command": input.command,
37898
+ "git.subcommand": input.args[0] ?? ""
37899
+ } }));
37900
+ };
37526
37901
  const runGit = (directory, args) => runCommand({
37527
37902
  command: "git",
37528
37903
  args,
@@ -37555,7 +37930,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
37555
37930
  "rev-parse",
37556
37931
  "--verify",
37557
37932
  branch
37558
- ]).pipe(map$3((result) => result.status === 0));
37933
+ ]).pipe(map$3((result) => result.status === 0), catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(false) : fail$4(error)));
37559
37934
  const headSha = (directory) => runGit(directory, ["rev-parse", "HEAD"]).pipe(map$3((result) => result.status === 0 ? trimOrNull(result.stdout) : null));
37560
37935
  const mergeBase = (input) => isSafeGitRevision(input.ref) ? runGit(input.directory, [
37561
37936
  "merge-base",
@@ -37769,7 +38144,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
37769
38144
  ]);
37770
38145
  if (result.status !== 0) return null;
37771
38146
  return parseChangedLineRanges(result.stdout);
37772
- }).pipe(withSpan("Git.changedLineRanges"))
38147
+ }).pipe(catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(null) : fail$4(error)), withSpan("Git.changedLineRanges"))
37773
38148
  });
37774
38149
  })).pipe(provide$2(layer$2.pipe(provide$2(mergeAll$1(layer$1, layer)))));
37775
38150
  /**
@@ -38008,6 +38383,14 @@ const neutralizeDisableDirectives = async (rootDirectory, includePaths) => {
38008
38383
  process.removeListener("exit", onExit);
38009
38384
  };
38010
38385
  };
38386
+ const ROOT_DIRECTORY_PLACEHOLDER = "<root>";
38387
+ const normalizeConfigForHash = (config) => {
38388
+ const clone = JSON.parse(JSON.stringify(config));
38389
+ if (clone?.settings?.["react-doctor"]) clone.settings["react-doctor"].rootDirectory = ROOT_DIRECTORY_PLACEHOLDER;
38390
+ if (Array.isArray(clone?.jsPlugins)) clone.jsPlugins = clone.jsPlugins.map((_, index) => `<plugin:${index}>`);
38391
+ return clone;
38392
+ };
38393
+ const computeRulesetHash = (input) => crypto.createHash("sha1").update(JSON.stringify(normalizeConfigForHash(input.config))).update("\0").update([...input.toolchainVersions].join("\0")).update("\0").update([...input.ignorePatterns].join("\n")).update(" ").update(input.tsconfigContent ?? "").digest("hex");
38011
38394
  /**
38012
38395
  * Loads a plugin module via the local require resolver and extracts
38013
38396
  * `(name, ruleNames)` from either `module.exports.meta + rules` or
@@ -38034,16 +38417,16 @@ const readPluginShape = (pluginSpecifier, loadModule) => {
38034
38417
  ruleNames: new Set(Object.keys(rules))
38035
38418
  };
38036
38419
  };
38037
- const bundledRequire = createRequire(import.meta.url);
38420
+ const bundledRequire$1 = createRequire(import.meta.url);
38038
38421
  const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
38039
38422
  if (!hasReactCompiler || customRulesOnly) return null;
38040
38423
  let pluginSpecifier;
38041
38424
  try {
38042
- pluginSpecifier = bundledRequire.resolve("eslint-plugin-react-hooks");
38425
+ pluginSpecifier = bundledRequire$1.resolve("eslint-plugin-react-hooks");
38043
38426
  } catch {
38044
38427
  return null;
38045
38428
  }
38046
- const { ruleNames } = readPluginShape(pluginSpecifier, (spec) => bundledRequire(spec));
38429
+ const { ruleNames } = readPluginShape(pluginSpecifier, (spec) => bundledRequire$1(spec));
38047
38430
  return {
38048
38431
  entry: {
38049
38432
  name: "react-hooks-js",
@@ -38162,8 +38545,8 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
38162
38545
  }
38163
38546
  return enabled;
38164
38547
  };
38165
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false }) => {
38166
- const reactHooksJsPlugin = disableReactHooksJsPlugin ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
38548
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection }) => {
38549
+ const reactHooksJsPlugin = disableReactHooksJsPlugin || ruleSelection === "sidecar" ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
38167
38550
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
38168
38551
  const jsPlugins = [];
38169
38552
  if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
@@ -38172,6 +38555,8 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38172
38555
  for (const registryEntry of REACT_DOCTOR_RULES) {
38173
38556
  const rule = reactDoctorPlugin.rules[registryEntry.id];
38174
38557
  if (!rule) continue;
38558
+ if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38559
+ if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38175
38560
  if (rule.scan !== void 0) continue;
38176
38561
  if (customRulesOnly && registryEntry.originallyExternal) continue;
38177
38562
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -38186,7 +38571,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38186
38571
  enabledReactDoctorRules[registryEntry.key] = severity;
38187
38572
  }
38188
38573
  const userPluginRules = {};
38189
- for (const userPlugin of userPlugins) {
38574
+ if (ruleSelection !== "sidecar") for (const userPlugin of userPlugins) {
38190
38575
  Object.assign(userPluginRules, buildUserPluginRules(userPlugin, severityControls));
38191
38576
  jsPlugins.push(userPlugin.entry);
38192
38577
  }
@@ -38216,6 +38601,100 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38216
38601
  }
38217
38602
  };
38218
38603
  };
38604
+ const atomicWriteJson = (filePath, value) => {
38605
+ try {
38606
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
38607
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
38608
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
38609
+ NFS.renameSync(temporaryPath, filePath);
38610
+ } catch {
38611
+ return;
38612
+ }
38613
+ };
38614
+ const failOpenReadJson = (filePath, fallback) => {
38615
+ try {
38616
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
38617
+ } catch {
38618
+ return fallback;
38619
+ }
38620
+ };
38621
+ const validateDiagnostic = decodeUnknownSync(Diagnostic);
38622
+ const decodeFileDiagnostics = (raw) => {
38623
+ if (!Array.isArray(raw)) return null;
38624
+ try {
38625
+ for (const entry of raw) validateDiagnostic(entry);
38626
+ return raw;
38627
+ } catch {
38628
+ return null;
38629
+ }
38630
+ };
38631
+ const emptyCache = () => ({
38632
+ version: 1,
38633
+ rulesets: {}
38634
+ });
38635
+ const loadRulesetEntries = (cacheFilePath, rulesetHash) => {
38636
+ const entries = /* @__PURE__ */ new Map();
38637
+ const persisted = failOpenReadJson(cacheFilePath, emptyCache());
38638
+ if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
38639
+ const bucket = persisted.rulesets[rulesetHash];
38640
+ if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
38641
+ for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
38642
+ const decoded = decodeFileDiagnostics(rawDiagnostics);
38643
+ if (decoded !== null) entries.set(fileKey, decoded);
38644
+ }
38645
+ return entries;
38646
+ };
38647
+ const createFileLintCache = (cacheDirectory, rulesetHash) => {
38648
+ const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
38649
+ const entries = loadRulesetEntries(cacheFilePath, rulesetHash);
38650
+ return {
38651
+ lookup: (fileKey) => entries.get(fileKey) ?? null,
38652
+ store: (fileKey, diagnostics) => {
38653
+ entries.delete(fileKey);
38654
+ entries.set(fileKey, diagnostics);
38655
+ },
38656
+ persist: () => {
38657
+ const onDisk = failOpenReadJson(cacheFilePath, emptyCache());
38658
+ const rulesets = onDisk.version === 1 && isRecord(onDisk.rulesets) ? { ...onDisk.rulesets } : {};
38659
+ const existingBucket = rulesets[rulesetHash];
38660
+ const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
38661
+ const ourFiles = {};
38662
+ for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
38663
+ const cappedEntries = Object.entries({
38664
+ ...existingFiles,
38665
+ ...ourFiles
38666
+ }).slice(-FILE_LINT_CACHE_MAX_FILE_COUNT);
38667
+ rulesets[rulesetHash] = {
38668
+ updatedAtMs: Date.now(),
38669
+ files: Object.fromEntries(cappedEntries)
38670
+ };
38671
+ const keptHashes = Object.entries(rulesets).sort(([, first], [, second]) => second.updatedAtMs - first.updatedAtMs).slice(0, 8).map(([hash]) => hash);
38672
+ const prunedRulesets = {};
38673
+ for (const hash of keptHashes) prunedRulesets[hash] = rulesets[hash];
38674
+ atomicWriteJson(cacheFilePath, {
38675
+ version: 1,
38676
+ rulesets: prunedRulesets
38677
+ });
38678
+ }
38679
+ };
38680
+ };
38681
+ const bundledRequire = createRequire(import.meta.url);
38682
+ const TOOLCHAIN_PACKAGE_SPECIFIERS = [
38683
+ "oxlint/package.json",
38684
+ "oxlint-plugin-react-doctor/package.json",
38685
+ "eslint-plugin-react-hooks/package.json"
38686
+ ];
38687
+ const resolveOxlintToolchainVersions = () => {
38688
+ const versions = [`node=${process.version}`];
38689
+ for (const specifier of TOOLCHAIN_PACKAGE_SPECIFIERS) try {
38690
+ const packageJson = bundledRequire(specifier);
38691
+ const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
38692
+ versions.push(`${specifier}=${version}`);
38693
+ } catch {
38694
+ versions.push(`${specifier}=missing`);
38695
+ }
38696
+ return versions;
38697
+ };
38219
38698
  const esmRequire = createRequire(import.meta.url);
38220
38699
  const resolveOxlintBinary = () => {
38221
38700
  const oxlintMainPath = esmRequire.resolve("oxlint");
@@ -38897,15 +39376,19 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
38897
39376
  };
38898
39377
  });
38899
39378
  };
38900
- const SANITIZED_ENV = (() => {
38901
- const sanitized = {};
38902
- for (const [name, value] of Object.entries(process.env)) {
39379
+ const buildOxlintChildEnv = (sourceEnv) => {
39380
+ const childEnv = {};
39381
+ for (const [name, value] of Object.entries(sourceEnv)) {
38903
39382
  if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
38904
39383
  if (name.startsWith("npm_config_")) continue;
38905
- sanitized[name] = value;
39384
+ childEnv[name] = value;
38906
39385
  }
38907
- return sanitized;
38908
- })();
39386
+ const isCompileCacheDisabled = Boolean(sourceEnv.NODE_DISABLE_COMPILE_CACHE);
39387
+ const isCompileCacheAlreadySet = childEnv.NODE_COMPILE_CACHE !== void 0;
39388
+ if (!isCompileCacheDisabled && !isCompileCacheAlreadySet) childEnv.NODE_COMPILE_CACHE = Path.join(os.tmpdir(), NODE_COMPILE_CACHE_DIR_NAME);
39389
+ return childEnv;
39390
+ };
39391
+ const SANITIZED_ENV = buildOxlintChildEnv(process.env);
38909
39392
  /**
38910
39393
  * Spawn one oxlint subprocess with hard ceilings on wall time and
38911
39394
  * output size. Returns stdout on success; raises a tagged
@@ -38922,7 +39405,11 @@ const SANITIZED_ENV = (() => {
38922
39405
  * The first three are splittable (the caller's binary-split retry
38923
39406
  * shrinks the batch and re-spawns); the fourth isn't.
38924
39407
  */
38925
- const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES) => new Promise((resolve, reject) => {
39408
+ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES, abortSignal) => new Promise((resolve, reject) => {
39409
+ if (abortSignal?.aborted) {
39410
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
39411
+ return;
39412
+ }
38926
39413
  const child = spawn(nodeBinaryPath, args, {
38927
39414
  cwd: rootDirectory,
38928
39415
  env: SANITIZED_ENV,
@@ -38932,7 +39419,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
38932
39419
  "pipe"
38933
39420
  ]
38934
39421
  });
39422
+ const onAbort = () => {
39423
+ child.kill("SIGKILL");
39424
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
39425
+ };
39426
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
39427
+ const clearAbortListener = () => abortSignal?.removeEventListener("abort", onAbort);
38935
39428
  const timeoutHandle = setTimeout(() => {
39429
+ clearAbortListener();
38936
39430
  child.kill("SIGKILL");
38937
39431
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
38938
39432
  kind: "timeout",
@@ -38967,10 +39461,12 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
38967
39461
  });
38968
39462
  child.on("error", (error) => {
38969
39463
  clearTimeout(timeoutHandle);
39464
+ clearAbortListener();
38970
39465
  reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: error }) }));
38971
39466
  });
38972
39467
  child.on("close", (_code, signal) => {
38973
39468
  clearTimeout(timeoutHandle);
39469
+ clearAbortListener();
38974
39470
  if (didKillForSize) {
38975
39471
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
38976
39472
  kind: "output-too-large",
@@ -39037,26 +39533,28 @@ const isParallelismRelatedSpawnError = (error) => {
39037
39533
  * loop with a slimmer config in that case.
39038
39534
  */
39039
39535
  const spawnLintBatches = async (input) => {
39040
- const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
39536
+ const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes, splitTotalBudgetMs = OXLINT_SPLIT_TOTAL_BUDGET_MS, splitMaxDepth = 8, signal } = input;
39041
39537
  const requestedConcurrency = resolveScanConcurrency(input.concurrency ?? 1);
39042
39538
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
39043
39539
  const runBatchPass = async (concurrency) => {
39044
39540
  const allDiagnostics = [];
39045
39541
  const droppedFiles = [];
39046
39542
  let firstDropReason = null;
39047
- const spawnLintBatch = async (batch) => {
39543
+ const splitDeadlineMs = Date.now() + splitTotalBudgetMs;
39544
+ const spawnLintBatch = async (batch, depth) => {
39048
39545
  const batchArgs = [...baseArgs, ...batch];
39049
39546
  try {
39050
- return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes), project, rootDirectory);
39547
+ return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes, signal), project, rootDirectory);
39051
39548
  } catch (error) {
39052
39549
  if (!isSplittableReactDoctorError(error)) throw error;
39053
- if (batch.length <= 1) {
39550
+ const splitBudgetExhausted = Date.now() >= splitDeadlineMs || depth >= splitMaxDepth;
39551
+ if (batch.length <= 1 || splitBudgetExhausted) {
39054
39552
  droppedFiles.push(...batch);
39055
- if (firstDropReason === null) firstDropReason = error.message;
39553
+ if (firstDropReason === null) firstDropReason = splitBudgetExhausted && batch.length > 1 ? `${error.message} (split budget exhausted after ${splitMaxDepth} levels / ${splitTotalBudgetMs / MILLISECONDS_PER_SECOND}s)` : error.message;
39056
39554
  return [];
39057
39555
  }
39058
39556
  const splitIndex = Math.ceil(batch.length / 2);
39059
- return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
39557
+ return [...await spawnLintBatch(batch.slice(0, splitIndex), depth + 1), ...await spawnLintBatch(batch.slice(splitIndex), depth + 1)];
39060
39558
  }
39061
39559
  };
39062
39560
  let startedFileCount = 0;
@@ -39073,7 +39571,7 @@ const spawnLintBatches = async (input) => {
39073
39571
  try {
39074
39572
  const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
39075
39573
  startedFileCount += batch.length;
39076
- const batchDiagnostics = await spawnLintBatch(batch);
39574
+ const batchDiagnostics = await spawnLintBatch(batch, 0);
39077
39575
  scannedFileCount += batch.length;
39078
39576
  if (onFileProgress) {
39079
39577
  displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
@@ -39134,6 +39632,22 @@ const validateRuleRegistration = () => {
39134
39632
  ].filter((entry) => entry !== null).join("; ");
39135
39633
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
39136
39634
  };
39635
+ const hashFileContents = (filePath) => {
39636
+ try {
39637
+ return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
39638
+ } catch {
39639
+ return null;
39640
+ }
39641
+ };
39642
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
39643
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
39644
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
39645
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
39646
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
39647
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
39648
+ return Path.join(os.tmpdir(), "react-doctor-cache", projectCacheSubdir(projectDirectory));
39649
+ };
39650
+ const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
39137
39651
  /**
39138
39652
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39139
39653
  * the runner: once for the primary scan, once for the
@@ -39192,7 +39706,7 @@ const reactHooksJsPluginDropNote = (error) => {
39192
39706
  * 6. always restore disable directives + clean up the temp dir
39193
39707
  */
39194
39708
  const runOxlint = async (options) => {
39195
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, spawnTimeoutMs, outputMaxBytes } = options;
39709
+ 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;
39196
39710
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39197
39711
  const severityControls = buildRuleSeverityControls(userConfig);
39198
39712
  validateRuleRegistration();
@@ -39209,30 +39723,156 @@ const runOxlint = async (options) => {
39209
39723
  serverAuthFunctionNames,
39210
39724
  severityControls,
39211
39725
  userPlugins,
39212
- disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
39726
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
39727
+ ruleSelection: overrides.ruleSelection
39213
39728
  });
39214
39729
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
39215
39730
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
39216
39731
  const configPath = Path.join(configDirectory, "oxlintrc.json");
39217
39732
  try {
39218
- const baseArgs = [
39219
- resolveOxlintBinary(),
39220
- "-c",
39221
- configPath,
39222
- "--format",
39223
- "json"
39224
- ];
39733
+ const oxlintBinary = resolveOxlintBinary();
39734
+ const sharedArgs = [];
39735
+ let tsconfigContent = null;
39225
39736
  if (project.hasTypeScript) {
39226
39737
  const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
39227
- if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
39738
+ if (tsconfigRelativePath) {
39739
+ sharedArgs.push("--tsconfig", tsconfigRelativePath);
39740
+ try {
39741
+ tsconfigContent = NFS.readFileSync(Path.resolve(rootDirectory, tsconfigRelativePath), "utf8");
39742
+ } catch {
39743
+ tsconfigContent = null;
39744
+ }
39745
+ }
39228
39746
  }
39229
39747
  const combinedPatterns = collectIgnorePatterns(rootDirectory);
39230
39748
  if (combinedPatterns.length > 0) {
39231
39749
  const combinedIgnorePath = Path.join(configDirectory, "combined.ignore");
39232
39750
  NFS.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
39233
- baseArgs.push("--ignore-path", combinedIgnorePath);
39751
+ sharedArgs.push("--ignore-path", combinedIgnorePath);
39752
+ }
39753
+ const makeBaseArgs = (oxlintConfigPath) => [
39754
+ oxlintBinary,
39755
+ "-c",
39756
+ oxlintConfigPath,
39757
+ "--format",
39758
+ "json",
39759
+ ...sharedArgs
39760
+ ];
39761
+ const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
39762
+ const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
39763
+ const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
39764
+ if (files.length === 0) return {
39765
+ diagnostics: [],
39766
+ didDropReactHooksJsPlugin: false,
39767
+ hadPartialFailure: false
39768
+ };
39769
+ let hadPartialFailure = false;
39770
+ const reportPartialFailure = (reason) => {
39771
+ hadPartialFailure = true;
39772
+ onPartialFailure?.(reason);
39773
+ };
39774
+ const passConfigPath = Path.join(configDirectory, configFileName);
39775
+ const passBaseArgs = makeBaseArgs(passConfigPath);
39776
+ const passFileBatches = batchIncludePaths(passBaseArgs, files);
39777
+ const spawnPass = () => spawnLintBatches({
39778
+ baseArgs: passBaseArgs,
39779
+ fileBatches: passFileBatches,
39780
+ rootDirectory,
39781
+ nodeBinaryPath,
39782
+ project,
39783
+ onPartialFailure: reportPartialFailure,
39784
+ onFileProgress: fileProgress,
39785
+ spawnTimeoutMs,
39786
+ outputMaxBytes,
39787
+ concurrency: options.concurrency,
39788
+ signal: options.signal
39789
+ });
39790
+ writeOxlintConfig(passConfigPath, buildConfigForPass({}));
39791
+ try {
39792
+ return {
39793
+ diagnostics: await spawnPass(),
39794
+ didDropReactHooksJsPlugin: false,
39795
+ hadPartialFailure
39796
+ };
39797
+ } catch (error) {
39798
+ const reactHooksJsDropNote = reactHooksJsPluginDropNote(error);
39799
+ if (reactHooksJsDropNote === null) throw error;
39800
+ writeOxlintConfig(passConfigPath, buildConfigForPass({ disableReactHooksJsPlugin: true }));
39801
+ const diagnostics = await spawnPass();
39802
+ reportPartialFailure(reactHooksJsDropNote);
39803
+ return {
39804
+ diagnostics,
39805
+ didDropReactHooksJsPlugin: true,
39806
+ hadPartialFailure
39807
+ };
39808
+ }
39809
+ };
39810
+ if (perFileLintCacheEnabled && respectInlineDisables && !project.hasReactCompiler && extendsPaths.length === 0 && userPlugins.length === 0) {
39811
+ const rulesetHash = computeRulesetHash({
39812
+ config: buildConfig({
39813
+ extendsPaths: [],
39814
+ ruleSelection: "cacheable"
39815
+ }),
39816
+ toolchainVersions: resolveOxlintToolchainVersions(),
39817
+ ignorePatterns: combinedPatterns,
39818
+ tsconfigContent
39819
+ });
39820
+ const cache = createFileLintCache(resolveReactDoctorCacheDir(rootDirectory), rulesetHash);
39821
+ const cacheKeyByFile = /* @__PURE__ */ new Map();
39822
+ const missFiles = [];
39823
+ const replayedDiagnostics = [];
39824
+ for (const candidateFile of candidateFiles) {
39825
+ const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
39826
+ if (contentHash === null) {
39827
+ missFiles.push(candidateFile);
39828
+ continue;
39829
+ }
39830
+ const cacheKey = `${candidateFile.replaceAll("\\", "/")}${contentHash}`;
39831
+ cacheKeyByFile.set(candidateFile, cacheKey);
39832
+ const cachedDiagnostics = cache.lookup(cacheKey);
39833
+ if (cachedDiagnostics === null) missFiles.push(candidateFile);
39834
+ else replayedDiagnostics.push(...cachedDiagnostics);
39835
+ }
39836
+ const cacheHitFileCount = candidateFiles.length - missFiles.length;
39837
+ const cacheableResult = await runConfigOverFiles((overrides) => buildConfig({
39838
+ extendsPaths: [],
39839
+ ruleSelection: "cacheable",
39840
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
39841
+ }), "oxlintrc.cacheable.json", missFiles, void 0);
39842
+ const sidecarResult = await runConfigOverFiles(() => buildConfig({
39843
+ extendsPaths: [],
39844
+ ruleSelection: "sidecar"
39845
+ }), "oxlintrc.sidecar.json", candidateFiles, options.onFileProgress);
39846
+ onCacheStats?.(cacheHitFileCount, candidateFiles.length);
39847
+ const missFileByNormalizedPath = /* @__PURE__ */ new Map();
39848
+ for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
39849
+ const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
39850
+ let isAttributionSound = true;
39851
+ for (const diagnostic of cacheableResult.diagnostics) {
39852
+ const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
39853
+ if (missFile === void 0) {
39854
+ isAttributionSound = false;
39855
+ break;
39856
+ }
39857
+ const fileDiagnostics = freshDiagnosticsByFile.get(missFile) ?? [];
39858
+ fileDiagnostics.push(diagnostic);
39859
+ freshDiagnosticsByFile.set(missFile, fileDiagnostics);
39860
+ }
39861
+ if (!cacheableResult.didDropReactHooksJsPlugin && !cacheableResult.hadPartialFailure && isAttributionSound) {
39862
+ for (const missFile of missFiles) {
39863
+ const cacheKey = cacheKeyByFile.get(missFile);
39864
+ if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
39865
+ }
39866
+ cache.persist();
39867
+ }
39868
+ return dedupeDiagnostics([
39869
+ ...replayedDiagnostics,
39870
+ ...cacheableResult.diagnostics,
39871
+ ...sidecarResult.diagnostics
39872
+ ]);
39234
39873
  }
39235
- const fileBatches = batchIncludePaths(baseArgs, includePaths !== void 0 ? includePaths : listSourceFiles(rootDirectory));
39874
+ const baseArgs = makeBaseArgs(configPath);
39875
+ const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
39236
39876
  const runBatches = () => spawnLintBatches({
39237
39877
  baseArgs,
39238
39878
  fileBatches,
@@ -39243,7 +39883,8 @@ const runOxlint = async (options) => {
39243
39883
  onFileProgress: options.onFileProgress,
39244
39884
  spawnTimeoutMs,
39245
39885
  outputMaxBytes,
39246
- concurrency: options.concurrency
39886
+ concurrency: options.concurrency,
39887
+ signal: options.signal
39247
39888
  });
39248
39889
  writeOxlintConfig(configPath, buildConfig({ extendsPaths }));
39249
39890
  try {
@@ -39322,9 +39963,11 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
39322
39963
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
39323
39964
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
39324
39965
  const concurrency = yield* OxlintConcurrency;
39966
+ const lintBatchOrdering = yield* LintBatchOrdering;
39967
+ const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
39325
39968
  const collectedFailures = [];
39326
39969
  const diagnostics = yield* tryPromise({
39327
- try: () => runOxlint({
39970
+ try: (signal) => runOxlint({
39328
39971
  rootDirectory: input.rootDirectory,
39329
39972
  project: input.project,
39330
39973
  includePaths: input.includePaths ? [...input.includePaths] : void 0,
@@ -39339,9 +39982,13 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
39339
39982
  collectedFailures.push(reason);
39340
39983
  },
39341
39984
  onFileProgress: input.onFileProgress,
39985
+ perFileLintCacheEnabled,
39986
+ onCacheStats: input.onCacheStats,
39342
39987
  spawnTimeoutMs,
39343
39988
  outputMaxBytes,
39344
- concurrency
39989
+ concurrency,
39990
+ signal,
39991
+ lintBatchOrdering
39345
39992
  }),
39346
39993
  catch: ensureReactDoctorError
39347
39994
  });
@@ -39733,14 +40380,49 @@ const parseArtifactFromBody = (body) => {
39733
40380
  }
39734
40381
  return null;
39735
40382
  };
39736
- const fetchSocketArtifact = (dependency) => tryPromise(async (signal) => {
40383
+ const isSupplyChainCacheDisabled = () => {
40384
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
40385
+ return noCache === "1" || noCache === "true";
40386
+ };
40387
+ const supplyChainCacheFile = (cacheDirectory, dependency) => {
40388
+ const purlHash = crypto.createHash("sha256").update(toPurl(dependency)).digest("hex").slice(0, 16);
40389
+ return Path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR, `${purlHash}.json`);
40390
+ };
40391
+ const readCachedSocketBody = (cacheFile) => {
40392
+ try {
40393
+ const entry = JSON.parse(NFS.readFileSync(cacheFile, "utf-8"));
40394
+ if (typeof entry === "object" && entry !== null && "fetchedAtMs" in entry && "body" in entry && typeof entry.fetchedAtMs === "number" && typeof entry.body === "string" && Date.now() - entry.fetchedAtMs <= 864e5) return entry.body;
40395
+ } catch {}
40396
+ return null;
40397
+ };
40398
+ const writeCachedSocketBody = (cacheFile, body) => {
40399
+ try {
40400
+ NFS.mkdirSync(Path.dirname(cacheFile), { recursive: true });
40401
+ NFS.writeFileSync(cacheFile, JSON.stringify({
40402
+ fetchedAtMs: Date.now(),
40403
+ body
40404
+ }));
40405
+ } catch {}
40406
+ };
40407
+ const fetchSocketArtifact = (dependency, cacheDirectory) => tryPromise(async (signal) => {
40408
+ const cacheFile = cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency);
40409
+ if (cacheFile !== null) {
40410
+ const cachedBody = readCachedSocketBody(cacheFile);
40411
+ if (cachedBody !== null) {
40412
+ const cachedArtifact = parseArtifactFromBody(cachedBody);
40413
+ if (cachedArtifact !== null) return cachedArtifact;
40414
+ }
40415
+ }
39737
40416
  const requestUrl = `${SOCKET_FREE_PURL_API_BASE}/${encodeURIComponent(toPurl(dependency))}`;
39738
40417
  const response = await fetch(requestUrl, {
39739
40418
  headers: { "User-Agent": SOCKET_FREE_USER_AGENT },
39740
40419
  signal
39741
40420
  });
39742
40421
  if (!response.ok) return null;
39743
- return parseArtifactFromBody(await response.text());
40422
+ const body = await response.text();
40423
+ const artifact = parseArtifactFromBody(body);
40424
+ if (artifact !== null && cacheFile !== null) writeCachedSocketBody(cacheFile, body);
40425
+ return artifact;
39744
40426
  }).pipe(timeout(FETCH_TIMEOUT_MS), orElseSucceed(() => null), tap$1((artifact) => {
39745
40427
  const scoreAttributes = {};
39746
40428
  if (artifact !== null) {
@@ -39845,7 +40527,8 @@ const checkSupplyChain = (input) => gen(function* () {
39845
40527
  const packageJsonPath = Path.join(input.rootDirectory, "package.json");
39846
40528
  const dependencies = collectDependenciesToScore(readPackageJson(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
39847
40529
  if (dependencies.length === 0) return [];
39848
- const artifacts = yield* forEach$1(dependencies, fetchSocketArtifact, { concurrency: 8 });
40530
+ const cacheDirectory = isSupplyChainCacheDisabled() ? null : resolveReactDoctorCacheDir(input.rootDirectory);
40531
+ const artifacts = yield* forEach$1(dependencies, (dependency) => fetchSocketArtifact(dependency, cacheDirectory), { concurrency: 8 }).pipe(timeoutOption(input.totalTimeoutMs ?? 9e4), map$3((maybeArtifacts) => getOrElse$1(maybeArtifacts, () => [])));
39849
40532
  const diagnostics = [];
39850
40533
  for (let index = 0; index < dependencies.length; index += 1) {
39851
40534
  const artifact = artifacts[index];
@@ -39870,6 +40553,10 @@ const checkSupplyChain = (input) => gen(function* () {
39870
40553
  * The underlying `checkSupplyChain` Effect is total/fail-open — per-package
39871
40554
  * timeouts and network failures recover to "skip" — so the stream never
39872
40555
  * fails, mirroring `DeadCode`'s stream shape so the two compose the same way.
40556
+ * The orchestrator (`run-inspect.ts`) consumes this stream on a background
40557
+ * fiber whose network time overlaps the lint pass, joined under a generous
40558
+ * wall-clock budget; a budget expiry is the same fail-open outcome as a Socket
40559
+ * outage.
39873
40560
  */
39874
40561
  var SupplyChain = class SupplyChain extends Service()("react-doctor/SupplyChain") {
39875
40562
  static layerNode = succeed$3(SupplyChain, SupplyChain.of({ run: (input) => unwrap(checkSupplyChain(input).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)), withSpan("SupplyChain.run"))) }));
@@ -39928,18 +40615,42 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
39928
40615
  *
39929
40616
  * Phases:
39930
40617
  *
39931
- * 1. Config.resolve(directory) → Project.discover → Git metadata
40618
+ * 1. Config.resolve(directory) → Project.discover → Git metadata.
40619
+ * The GitHub viewer-permission lookup is forked onto a background
40620
+ * fiber here and joined late (it feeds score metadata, not
40621
+ * diagnostics).
39932
40622
  * 2. beforeLint hook (e.g. CLI renders the project-detection block)
39933
40623
  * 3. environment checks (reduced-motion + pnpm hardening +
39934
- * expo/react-native + security scan)
39935
- * 4. Linter.run + DeadCode.run forked as concurrent fibers so
39936
- * their wall-clock times overlap. Progress spinners stay
39937
- * sequential (lint first, then dead-code) for clean terminal
39938
- * output. GitHub viewer permission also runs as a background
39939
- * fiber during this phase.
39940
- * 5. afterLint hook
39941
- * 6. Reporter.finalize
39942
- * 7. Score.compute against the surface-filtered diagnostic set
40624
+ * expo/react-native + security scan), collected synchronously
40625
+ * 4. The supply-chain check (Socket.dev) is forked onto a background
40626
+ * fiber so its ~100% network-bound time overlaps the ~100%
40627
+ * CPU/subprocess-bound lint pass below, collapsing two serial
40628
+ * phases into roughly `max(supplyChain, lint)`. It is capped by
40629
+ * `SupplyChainOverlapTimeoutMs` (measured from fork) so a hung
40630
+ * socket can't drag out its join; on timeout it fails open to no
40631
+ * diagnostics — the same outcome class as a Socket outage.
40632
+ * 5. Linter.run runs; DeadCode.run runs concurrently (forked child
40633
+ * fiber) ONLY when the memory gate has headroom to run the 8 GB
40634
+ * dead-code child alongside the oxlint workers — or when overlap is
40635
+ * forced via REACT_DOCTOR_DEAD_CODE_OVERLAP. Otherwise dead-code
40636
+ * runs sequentially after lint, exactly as it did pre-overlap. The
40637
+ * fiber is joined (or interrupted, SIGKILLing its worker, on lint
40638
+ * failure) before diagnostics are concatenated. The afterLint hook
40639
+ * fires between lint and dead-code. Progress spinner labels AND the
40640
+ * final diagnostic / score order stay independent of execution
40641
+ * order, so terminal output is identical either way; supply-chain
40642
+ * rides alongside without a spinner.
40643
+ * 6. Join the supply-chain fiber, then assemble the diagnostics in a
40644
+ * FIXED order (env, supply-chain, lint, dead-code) so the output is
40645
+ * byte-identical regardless of which fiber settled first. The
40646
+ * viewer-permission fiber is joined later, during score-metadata
40647
+ * assembly (it feeds score metadata, not diagnostics). The per-element
40648
+ * `Reporter.emit` side-channel now interleaves supply-chain with lint
40649
+ * emits, so capture-order assertions must target the deterministic
40650
+ * concat below, not emit order (production `Reporter.layerNoop` makes
40651
+ * emit a no-op).
40652
+ * 7. Reporter.finalize
40653
+ * 8. Score.compute against the surface-filtered diagnostic set
39943
40654
  *
39944
40655
  * The orchestrator owns spinner lifecycle via `Progress`; callers
39945
40656
  * choose `Progress.layerOra(...)` for CLI feedback or
@@ -39997,10 +40708,21 @@ const runInspect = (input, hooks = {}) => gen(function* () {
39997
40708
  ignoredTags: input.ignoredTags
39998
40709
  })
39999
40710
  ])));
40000
- const supplyChainCollected = !isDiffMode || (input.supplyChainManifestChanged ?? false) ? yield* runCollect(applyPerElementPipeline(supplyChainService.run({
40711
+ const shouldRunSupplyChain = !isDiffMode || (input.supplyChainManifestChanged ?? false);
40712
+ const supplyChainOverlapTimeout = yield* SupplyChainOverlapTimeoutMs;
40713
+ const supplyChainFiber = yield* forkChild(shouldRunSupplyChain ? runCollect(applyPerElementPipeline(supplyChainService.run({
40001
40714
  rootDirectory: scanDirectory,
40002
40715
  userConfig: resolvedConfig.config
40003
- }))) : [];
40716
+ }))).pipe(map$3((diagnostics) => ({
40717
+ diagnostics,
40718
+ timedOut: false
40719
+ })), timeout(supplyChainOverlapTimeout), orElseSucceed(() => ({
40720
+ diagnostics: [],
40721
+ timedOut: true
40722
+ }))) : succeed$2({
40723
+ diagnostics: [],
40724
+ timedOut: false
40725
+ }));
40004
40726
  const lintFailure = yield* make$13({
40005
40727
  didFail: false,
40006
40728
  reason: null,
@@ -40011,12 +40733,49 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40011
40733
  didFail: false,
40012
40734
  reason: null
40013
40735
  });
40014
- const scanConcurrency = yield* OxlintConcurrency;
40736
+ const scanConcurrency = resolveScanConcurrency(yield* OxlintConcurrency);
40737
+ const lintPhaseTimeoutMs = yield* LintPhaseTimeoutMs;
40738
+ const deadCodePhaseTimeoutMs = yield* DeadCodePhaseTimeoutMs;
40739
+ const resolveDeadCodePhaseTimeoutMs = (scaledPhaseTimeoutMs) => deadCodePhaseTimeoutMs === 15e4 ? scaledPhaseTimeoutMs : deadCodePhaseTimeoutMs;
40015
40740
  const workerCountSuffix = scanConcurrency > 1 ? ` ${highlighter.dim(`[~${scanConcurrency} workers]`)}` : "";
40741
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
40742
+ const deadCodeOverlapMode = yield* DeadCodeOverlap;
40743
+ const shouldOverlapDeadCode = shouldRunDeadCode && deadCodeOverlapMode === "on";
40744
+ const deadCodeParseConcurrency = shouldOverlapDeadCode ? Math.max(1, Math.floor(scanConcurrency * DEAD_CODE_OVERLAP_PARSE_SHARE)) : void 0;
40745
+ const lintConcurrency = deadCodeParseConcurrency === void 0 ? scanConcurrency : Math.max(1, scanConcurrency - deadCodeParseConcurrency);
40746
+ const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
40747
+ rootDirectory: scanDirectory,
40748
+ userConfig: resolvedConfig.config,
40749
+ parseConcurrency: deadCodeParseConcurrency,
40750
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
40751
+ }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
40752
+ yield* set(deadCodeFailure, {
40753
+ didFail: true,
40754
+ reason: error.message
40755
+ });
40756
+ return empty$4;
40757
+ })))))).pipe(timeoutOption(deadCodeTimeout.phaseTimeoutMs), flatMap$2(match$3({
40758
+ onNone: () => set(deadCodeFailure, {
40759
+ didFail: true,
40760
+ reason: `Dead-code analysis exceeded ${Math.round(deadCodeTimeout.phaseTimeoutMs / MILLISECONDS_PER_SECOND)}s and was skipped.`
40761
+ }).pipe(as([])),
40762
+ onSome: succeed$2
40763
+ })));
40764
+ const overlapDeadCodeTimeout = resolveDeadCodeTimeout({
40765
+ sourceFileCount: project.sourceFileCount,
40766
+ deadCodeConcurrency: deadCodeParseConcurrency ?? scanConcurrency,
40767
+ fullConcurrency: scanConcurrency
40768
+ });
40769
+ const deadCodeFiber = shouldOverlapDeadCode ? yield* forkChild(buildCollectDeadCode({
40770
+ workerTimeoutMs: overlapDeadCodeTimeout.workerTimeoutMs,
40771
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(overlapDeadCodeTimeout.phaseTimeoutMs)
40772
+ })) : null;
40016
40773
  const scanProgress = yield* progressService.start("Scanning...");
40017
40774
  const scanStartTime = Date.now();
40018
40775
  let lastReportedTotalFileCount = 0;
40019
- const lintCollected = yield* runCollect(applyPerElementPipeline(linterService.run({
40776
+ let lintCacheHitFileCount = null;
40777
+ let lintCacheTotalFileCount = null;
40778
+ const baseLintStream = linterService.run({
40020
40779
  rootDirectory: scanDirectory,
40021
40780
  project,
40022
40781
  includePaths: lintIncludePaths ?? void 0,
@@ -40030,6 +40789,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40030
40789
  onFileProgress: (scannedFileCount, totalFileCount) => {
40031
40790
  lastReportedTotalFileCount = totalFileCount;
40032
40791
  runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
40792
+ },
40793
+ onCacheStats: (cacheHitFileCount, totalConsideredFileCount) => {
40794
+ lintCacheHitFileCount = cacheHitFileCount;
40795
+ lintCacheTotalFileCount = totalConsideredFileCount;
40033
40796
  }
40034
40797
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
40035
40798
  yield* set(lintFailure, {
@@ -40039,36 +40802,54 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40039
40802
  reasonKind: error.reason._tag === "OxlintUnavailable" ? error.reason.kind : null
40040
40803
  });
40041
40804
  return empty$4;
40042
- }))))));
40805
+ }))));
40806
+ const lintCollected = yield* runCollect(applyPerElementPipeline(shouldOverlapDeadCode ? baseLintStream.pipe(provideService(OxlintConcurrency, lintConcurrency)) : baseLintStream)).pipe(timeoutOption(lintPhaseTimeoutMs), flatMap$2(match$3({
40807
+ onNone: () => set(lintFailure, {
40808
+ didFail: true,
40809
+ reason: `Lint analysis exceeded ${lintPhaseTimeoutMs / MILLISECONDS_PER_SECOND}s and was skipped.`,
40810
+ reasonTag: "OxlintBatchExceeded",
40811
+ reasonKind: null
40812
+ }).pipe(as([])),
40813
+ onSome: succeed$2
40814
+ })));
40043
40815
  const lintFailureState = yield* get$2(lintFailure);
40044
40816
  yield* afterLint(lintFailureState.didFail);
40045
40817
  if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
40046
40818
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
40047
40819
  const scannedFilesLabel = `${totalFileCount} ${totalFileCount === 1 ? "file" : "files"}`;
40048
- const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
40049
- const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`).pipe(andThen(runCollect(applyPerElementPipeline(deadCodeService.run({
40050
- rootDirectory: scanDirectory,
40051
- userConfig: resolvedConfig.config
40052
- }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
40053
- yield* set(deadCodeFailure, {
40054
- didFail: true,
40055
- reason: error.message
40820
+ let deadCodeCollected = [];
40821
+ if (lintFailureState.didFail) {
40822
+ if (deadCodeFiber !== null) yield* interrupt(deadCodeFiber);
40823
+ } else if (shouldRunDeadCode) {
40824
+ yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`);
40825
+ const sequentialDeadCodeTimeout = resolveDeadCodeTimeout({
40826
+ sourceFileCount: totalFileCount,
40827
+ deadCodeConcurrency: scanConcurrency,
40828
+ fullConcurrency: scanConcurrency
40056
40829
  });
40057
- return empty$4;
40058
- }))))))));
40059
- const deadCodeFailureState = yield* get$2(deadCodeFailure);
40830
+ deadCodeCollected = deadCodeFiber !== null ? yield* join(deadCodeFiber) : yield* buildCollectDeadCode({
40831
+ workerTimeoutMs: sequentialDeadCodeTimeout.workerTimeoutMs,
40832
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(sequentialDeadCodeTimeout.phaseTimeoutMs)
40833
+ });
40834
+ }
40835
+ const deadCodeFailureState = lintFailureState.didFail ? {
40836
+ didFail: false,
40837
+ reason: null
40838
+ } : yield* get$2(deadCodeFailure);
40060
40839
  const scanElapsedMilliseconds = Date.now() - scanStartTime;
40061
40840
  const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1);
40062
40841
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
40063
40842
  else if (input.suppressScanSummary) yield* scanProgress.stop();
40064
40843
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
40844
+ const supplyChainResult = yield* join(supplyChainFiber);
40845
+ const supplyChainCollected = supplyChainResult.diagnostics;
40065
40846
  yield* reporterService.finalize;
40066
- const finalDiagnostics = assignFixGroups([
40847
+ const finalDiagnostics = sortDiagnosticsStable(assignFixGroups([
40067
40848
  ...envCollected,
40068
40849
  ...supplyChainCollected,
40069
40850
  ...lintCollected,
40070
40851
  ...deadCodeCollected
40071
- ]);
40852
+ ]));
40072
40853
  const githubViewerPermission = yield* join(githubViewerPermissionFiber);
40073
40854
  const scoreMetadata = {
40074
40855
  ...repo !== null ? { repo } : {},
@@ -40104,9 +40885,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40104
40885
  lintPartialFailures,
40105
40886
  didDeadCodeFail: deadCodeFailureState.didFail,
40106
40887
  deadCodeFailureReason: deadCodeFailureState.reason,
40888
+ deadCodeOverlapped: shouldOverlapDeadCode,
40107
40889
  scannedFileCount: totalFileCount,
40108
40890
  scannedFilePaths,
40109
- scanElapsedMilliseconds
40891
+ scanElapsedMilliseconds,
40892
+ scanConcurrency,
40893
+ supplyChainOverlapTimedOut: supplyChainResult.timedOut,
40894
+ lintCacheHitFileCount,
40895
+ lintCacheTotalFileCount
40110
40896
  };
40111
40897
  }).pipe(withSpan("runInspect", { attributes: {
40112
40898
  "inspect.directory": input.directory,
@@ -40114,7 +40900,7 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40114
40900
  "inspect.runDeadCode": input.runDeadCode,
40115
40901
  "inspect.isCi": input.isCi,
40116
40902
  "inspect.scoreSurface": input.scoreSurface ?? "score"
40117
- } }));
40903
+ } }), (scanProgram) => flatMap$2(ScanDeadlineMs, (scanDeadlineMs) => scanProgram.pipe(timeout(scanDeadlineMs), catchTag$1("TimeoutError", () => new ReactDoctorError({ reason: new ScanDeadlineExceeded({ detail: `${scanDeadlineMs / MILLISECONDS_PER_SECOND}s elapsed` }) })))));
40118
40904
  const parseNodeVersion = (versionString) => {
40119
40905
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
40120
40906
  return {
@@ -40435,7 +41221,7 @@ const computeConfigFingerprint = (projectDirectory, version) => {
40435
41221
  /** Display name used in client-facing messages and progress titles. */
40436
41222
  const SERVER_DISPLAY_NAME = "React Doctor";
40437
41223
  /** Server version reported in `serverInfo`; injected at build, `dev` from source. */
40438
- const SERVER_VERSION = "0.5.7";
41224
+ const SERVER_VERSION = "0.5.8";
40439
41225
  /** `Diagnostic.source` shown next to every published diagnostic. */
40440
41226
  const DIAGNOSTIC_SOURCE = "react-doctor";
40441
41227
  /**
@@ -41090,6 +41876,7 @@ const createProjectGraph = (options) => {
41090
41876
  clearPackageJsonCache();
41091
41877
  clearIgnorePatternsCache();
41092
41878
  clearAutoSuppressionCaches();
41879
+ clearMinifiedFileCache();
41093
41880
  projects = null;
41094
41881
  }
41095
41882
  };
@@ -42130,6 +42917,7 @@ const SENTRY_FLUSH_TIMEOUT_MS = 2e3;
42130
42917
  const METRIC = {
42131
42918
  cliInvoked: "cli.invoked",
42132
42919
  cliError: "cli.error",
42920
+ cliEnvironmentError: "cli.env_error",
42133
42921
  projectDetected: "project.detected",
42134
42922
  projectPathSelected: "project.path_selected",
42135
42923
  projectConfigSelected: "project.config_selected",
@@ -42476,5 +43264,5 @@ const startLanguageServer = () => {
42476
43264
  };
42477
43265
  //#endregion
42478
43266
  export { startLanguageServer };
42479
- !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]="1afe1e75-32a9-5e7e-bd02-778b52d7ca8d")}catch(e){}}();
42480
- //# debugId=1afe1e75-32a9-5e7e-bd02-778b52d7ca8d
43267
+ !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]="d12f3c5d-14fd-59ba-9d8a-7e3f589c88ad")}catch(e){}}();
43268
+ //# debugId=d12f3c5d-14fd-59ba-9d8a-7e3f589c88ad