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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="03972752-ed17-5a0b-a633-71b652d2f457")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="dccb6d63-63d4-5e91-af21-2756a08fe93a")}catch(e){}}();
3
3
  import { r as __toESM$1, t as __commonJSMin$1 } from "./chunk-N93fKeF6.js";
4
4
  import { createRequire } from "node:module";
5
5
  import * as NFS from "node:fs";
@@ -9,7 +9,7 @@ import path from "node:path";
9
9
  import * as NodeChildProcess from "node:child_process";
10
10
  import { spawn, spawnSync } from "node:child_process";
11
11
  import * as ts from "typescript";
12
- 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";
12
+ import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, CROSS_FILE_RULE_IDS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES, REACT_COMPILER_RULES, REACT_DOCTOR_RULES, classifySecurityScanFile, shouldReadSecurityScanContent } from "oxlint-plugin-react-doctor";
13
13
  import * as OS from "node:os";
14
14
  import os from "node:os";
15
15
  import { parseJSON5 } from "confbox";
@@ -17,7 +17,7 @@ import * as NodeUrl from "node:url";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { createJiti } from "jiti";
19
19
  import * as Crypto from "node:crypto";
20
- import { createHash } from "node:crypto";
20
+ import crypto, { createHash } from "node:crypto";
21
21
  import { gzipSync } from "node:zlib";
22
22
  //#region ../../node_modules/.pnpm/effect@4.0.0-beta.70/node_modules/effect/dist/Pipeable.js
23
23
  /**
@@ -7199,7 +7199,7 @@ const provideContext$1 = /* @__PURE__ */ dual(2, (self, context) => {
7199
7199
  return updateContext$1(self, merge$3(context));
7200
7200
  });
7201
7201
  /** @internal */
7202
- const provideService$1 = function() {
7202
+ const provideService$3 = function() {
7203
7203
  if (arguments.length === 1) return dual(2, (self, impl) => provideServiceImpl(self, arguments[0], impl));
7204
7204
  return dual(3, (self, service, impl) => provideServiceImpl(self, service, impl)).apply(this, arguments);
7205
7205
  };
@@ -7430,7 +7430,7 @@ const constScopeEmpty = { _tag: "Empty" };
7430
7430
  /** @internal */
7431
7431
  const scope = scopeTag;
7432
7432
  /** @internal */
7433
- const provideScope = /* @__PURE__ */ provideService$1(scopeTag);
7433
+ const provideScope = /* @__PURE__ */ provideService$3(scopeTag);
7434
7434
  /** @internal */
7435
7435
  const scoped$1 = (self) => withFiber$1((fiber) => {
7436
7436
  const prev = fiber.context;
@@ -7871,7 +7871,7 @@ const makeLatchUnsafe = (open) => new Latch(open ?? false);
7871
7871
  /** @internal */
7872
7872
  const makeLatch = (open) => sync$2(() => makeLatchUnsafe(open));
7873
7873
  /** @internal */
7874
- const withTracerEnabled$1 = /* @__PURE__ */ provideService$1(TracerEnabled);
7874
+ const withTracerEnabled$1 = /* @__PURE__ */ provideService$3(TracerEnabled);
7875
7875
  const bigint0 = /* @__PURE__ */ BigInt(0);
7876
7876
  const NoopSpanProto = {
7877
7877
  _tag: "Span",
@@ -7952,7 +7952,7 @@ const useSpan$1 = (name, ...args) => {
7952
7952
  }));
7953
7953
  });
7954
7954
  };
7955
- const provideParentSpan = /* @__PURE__ */ provideService$1(ParentSpan);
7955
+ const provideParentSpan = /* @__PURE__ */ provideService$3(ParentSpan);
7956
7956
  /** @internal */
7957
7957
  const withParentSpan$1 = function() {
7958
7958
  const dataFirst = isEffect$1(arguments[0]);
@@ -9519,7 +9519,7 @@ var CurrentMemoMap = class extends Service()("effect/Layer/CurrentMemoMap") {
9519
9519
  * @category memo map
9520
9520
  * @since 2.0.0
9521
9521
  */
9522
- const buildWithMemoMap = /* @__PURE__ */ dual(3, (self, memoMap, scope) => provideService$1(map$4(self.build(memoMap, scope), add(CurrentMemoMap, memoMap)), CurrentMemoMap, memoMap));
9522
+ const buildWithMemoMap = /* @__PURE__ */ dual(3, (self, memoMap, scope) => provideService$3(map$4(self.build(memoMap, scope), add(CurrentMemoMap, memoMap)), CurrentMemoMap, memoMap));
9523
9523
  /**
9524
9524
  * Builds a layer into an `Effect` value. Any resources associated with this
9525
9525
  * layer will be released when the specified scope is closed unless their scope
@@ -10872,7 +10872,7 @@ const provide$1 = /* @__PURE__ */ dual((args) => isEffect$1(args[0]), (self, sou
10872
10872
  /** @internal */
10873
10873
  const repeatOrElse = /* @__PURE__ */ dual(3, (self, schedule, orElse) => flatMap$4(toStepWithMetadata(schedule), (step) => {
10874
10874
  let meta = CurrentMetadata.defaultValue();
10875
- return catch_$2(forever$2(tap$2(flatMap$4(suspend$3(() => provideService$1(self, CurrentMetadata, meta)), step), (meta_) => sync$2(() => {
10875
+ return catch_$2(forever$2(tap$2(flatMap$4(suspend$3(() => provideService$3(self, CurrentMetadata, meta)), step), (meta_) => sync$2(() => {
10876
10876
  meta = meta_;
10877
10877
  })), { disableYield: true }), (error) => isDone$2(error) ? succeed$5(error.value) : orElse(error, meta.attempt === 0 ? none() : some(meta)));
10878
10878
  }));
@@ -10880,7 +10880,7 @@ const repeatOrElse = /* @__PURE__ */ dual(3, (self, schedule, orElse) => flatMap
10880
10880
  const retryOrElse = /* @__PURE__ */ dual(3, (self, policy, orElse) => flatMap$4(toStepWithMetadata(policy), (step) => {
10881
10881
  let meta = CurrentMetadata.defaultValue();
10882
10882
  let lastError;
10883
- const loop = catch_$2(suspend$3(() => provideService$1(self, CurrentMetadata, meta)), (error) => {
10883
+ const loop = catch_$2(suspend$3(() => provideService$3(self, CurrentMetadata, meta)), (error) => {
10884
10884
  lastError = error;
10885
10885
  return flatMap$4(step(error), (meta_) => {
10886
10886
  meta = meta_;
@@ -12996,7 +12996,7 @@ const updateContext = updateContext$1;
12996
12996
  * @category Context
12997
12997
  * @since 2.0.0
12998
12998
  */
12999
- const provideService = provideService$1;
12999
+ const provideService$2 = provideService$3;
13000
13000
  /**
13001
13001
  * Scopes all resources used in this workflow to the lifetime of the workflow,
13002
13002
  * ensuring that their finalizers are run as soon as this workflow completes
@@ -17990,6 +17990,20 @@ function decodeUnknownOption$1(schema, options) {
17990
17990
  return asOption(decodeUnknownEffect(schema, options));
17991
17991
  }
17992
17992
  /**
17993
+ * Creates a synchronous decoder for `unknown` input.
17994
+ *
17995
+ * **Details**
17996
+ *
17997
+ * The returned function returns the decoded `Type` on success and throws an
17998
+ * `Error` with the `SchemaIssue.Issue` in its `cause` on decoding failure.
17999
+ *
18000
+ * @category decoding
18001
+ * @since 3.10.0
18002
+ */
18003
+ function decodeUnknownSync$1(schema, options) {
18004
+ return asSync(decodeUnknownEffect(schema, options));
18005
+ }
18006
+ /**
17993
18007
  * Creates an effectful encoder for `unknown` input.
17994
18008
  *
17995
18009
  * **Details**
@@ -18291,6 +18305,40 @@ function isSchemaError(u) {
18291
18305
  */
18292
18306
  const decodeUnknownOption = decodeUnknownOption$1;
18293
18307
  /**
18308
+ * Decodes an `unknown` input against a schema synchronously, returning the
18309
+ * decoded value or throwing an `Error` whose cause contains the schema issue.
18310
+ * Use this when you want to validate data at a boundary and treat a schema
18311
+ * mismatch as an exception. For typed input use `decodeSync`.
18312
+ *
18313
+ * **Details**
18314
+ *
18315
+ * Only service-free schemas can be decoded synchronously. For non-throwing
18316
+ * alternatives see `decodeUnknownOption`, `decodeUnknownExit`, or
18317
+ * `decodeUnknownEffect`. Options may be provided either when creating the
18318
+ * decoder or when applying it; application options override creation options.
18319
+ *
18320
+ * **Example** (Decoding with a transformation schema)
18321
+ *
18322
+ * ```ts
18323
+ * import { Schema } from "effect"
18324
+ *
18325
+ * const NumberFromString = Schema.NumberFromString
18326
+ *
18327
+ * console.log(Schema.decodeUnknownSync(NumberFromString)("42"))
18328
+ * // Output: 42
18329
+ *
18330
+ * Schema.decodeUnknownSync(NumberFromString)("not a number")
18331
+ * // throws SchemaError: NumberFromString
18332
+ * // └─ Encoded side transformation failure
18333
+ * // └─ NumberFromString
18334
+ * // └─ Expected a numeric string, actual "not a number"
18335
+ * ```
18336
+ *
18337
+ * @category decoding
18338
+ * @since 4.0.0
18339
+ */
18340
+ const decodeUnknownSync = decodeUnknownSync$1;
18341
+ /**
18294
18342
  * Encodes an `unknown` input against a schema synchronously, throwing a
18295
18343
  * {@link SchemaError} on failure. Use this when you want to serialize data at a
18296
18344
  * boundary and treat a schema mismatch as an unrecoverable error. For
@@ -25170,6 +25218,14 @@ const runWith = (self, f, onHalt) => suspend$2(() => {
25170
25218
  return catchDone(flatMap$2(toTransform(self)(done$1(), scope), f), onHalt ? onHalt : succeed$2).pipe(onExit$1((exit) => close(scope, exit)));
25171
25219
  });
25172
25220
  /**
25221
+ * Provides a concrete service for a context key, removing that service
25222
+ * requirement from the returned channel.
25223
+ *
25224
+ * @category services
25225
+ * @since 2.0.0
25226
+ */
25227
+ 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))));
25228
+ /**
25173
25229
  * Runs a channel and applies an effect to each output element.
25174
25230
  *
25175
25231
  * **Example** (Running effects for each output)
@@ -26558,6 +26614,44 @@ const splitLines = (self) => self.channel.pipe(pipeTo(splitLines$1()), fromChann
26558
26614
  */
26559
26615
  const ensuring = /* @__PURE__ */ dual(2, (self, finalizer) => fromChannel(ensuring$1(self.channel, finalizer)));
26560
26616
  /**
26617
+ * Provides the stream with a single required service, eliminating that
26618
+ * requirement from its environment.
26619
+ *
26620
+ * **Example** (Providing a stream service)
26621
+ *
26622
+ * ```ts
26623
+ * import { Console, Context, Effect, Stream } from "effect"
26624
+ *
26625
+ * class Greeter extends Context.Service<Greeter, {
26626
+ * greet: (name: string) => string
26627
+ * }>()("Greeter") {}
26628
+ *
26629
+ * const stream = Stream.fromEffect(
26630
+ * Effect.service(Greeter).pipe(
26631
+ * Effect.map((greeter) => greeter.greet("Ada"))
26632
+ * )
26633
+ * )
26634
+ *
26635
+ * const program = Effect.gen(function*() {
26636
+ * const collected = yield* Stream.runCollect(
26637
+ * stream.pipe(
26638
+ * Stream.provideService(Greeter, {
26639
+ * greet: (name) => `Hello, ${name}`
26640
+ * })
26641
+ * )
26642
+ * )
26643
+ * yield* Console.log(collected)
26644
+ * })
26645
+ *
26646
+ * Effect.runPromise(program)
26647
+ * //=> ["Hello, Ada"]
26648
+ * ```
26649
+ *
26650
+ * @category services
26651
+ * @since 2.0.0
26652
+ */
26653
+ const provideService = /* @__PURE__ */ dual(3, (self, key, service) => fromChannel(provideService$1(self.channel, key, service)));
26654
+ /**
26561
26655
  * Runs a stream with a sink and returns the sink result.
26562
26656
  *
26563
26657
  * **Example** (Running a stream with a sink)
@@ -29987,7 +30081,7 @@ const make$8 = /* @__PURE__ */ fnUntraced(function* (options) {
29987
30081
  const runFork = runForkWith(services);
29988
30082
  const exportInterval = max(fromInputUnsafe(options.exportInterval), zero);
29989
30083
  let disabledUntil = void 0;
29990
- const client = filterStatusOk(get$4(services, HttpClient)).pipe(transformResponse(provideService(TracerPropagationEnabled, false)), retryTransient({
30084
+ const client = filterStatusOk(get$4(services, HttpClient)).pipe(transformResponse(provideService$2(TracerPropagationEnabled, false)), retryTransient({
29991
30085
  schedule: policy,
29992
30086
  times: 3
29993
30087
  }));
@@ -32717,15 +32811,24 @@ const isMinifiedSource = (absolutePath) => {
32717
32811
  if (fileDescriptor !== void 0) NFS.closeSync(fileDescriptor);
32718
32812
  }
32719
32813
  };
32720
- const isLargeMinifiedFile = (absolutePath) => {
32721
- let sizeBytes;
32814
+ const cachedIsLargeMinifiedByPath = /* @__PURE__ */ new Map();
32815
+ const clearMinifiedFileCache = () => {
32816
+ cachedIsLargeMinifiedByPath.clear();
32817
+ };
32818
+ const statSourceFileSize = (absolutePath) => {
32722
32819
  try {
32723
- sizeBytes = NFS.statSync(absolutePath).size;
32820
+ return NFS.statSync(absolutePath).size;
32724
32821
  } catch {
32725
- return false;
32822
+ return null;
32726
32823
  }
32727
- if (sizeBytes < 2e4) return false;
32728
- return isMinifiedSource(absolutePath);
32824
+ };
32825
+ const isLargeMinifiedFile = (absolutePath, knownSizeBytes) => {
32826
+ const cached = cachedIsLargeMinifiedByPath.get(absolutePath);
32827
+ if (cached !== void 0) return cached;
32828
+ const sizeBytes = knownSizeBytes === void 0 ? statSourceFileSize(absolutePath) : knownSizeBytes;
32829
+ const result = sizeBytes !== null && sizeBytes >= 2e4 && isMinifiedSource(absolutePath);
32830
+ cachedIsLargeMinifiedByPath.set(absolutePath, result);
32831
+ return result;
32729
32832
  };
32730
32833
  const isErrnoException = (error) => error instanceof Error && "code" in error;
32731
32834
  const IGNORABLE_READDIR_ERROR_CODES = new Set([
@@ -33591,6 +33694,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
33591
33694
  const SCORE_API_URL = "https://www.react.doctor/api/score";
33592
33695
  const FETCH_TIMEOUT_MS = 1e4;
33593
33696
  const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
33697
+ const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
33698
+ const PER_WORKER_MEM_BUDGET_BYTES = 1024 * 1024 * 1024;
33594
33699
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
33595
33700
  const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
33596
33701
  const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
@@ -33640,7 +33745,16 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
33640
33745
  ];
33641
33746
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
33642
33747
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
33748
+ const NODE_COMPILE_CACHE_DIR_NAME = "node-compile-cache";
33749
+ const DEAD_CODE_WORKER_TIMEOUT_MS = 12e4;
33750
+ const OXLINT_SPLIT_TOTAL_BUDGET_MS = 18e4;
33751
+ const DEAD_CODE_PHASE_TIMEOUT_MS = 15e4;
33752
+ const LINT_PHASE_TIMEOUT_MS = 3e5;
33753
+ const SCAN_TOTAL_DEADLINE_MS = 9e5;
33643
33754
  const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192;
33755
+ const DEAD_CODE_TIMEOUT_CEILING_MS = 6e5;
33756
+ const DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS = 3e4;
33757
+ const DEAD_CODE_OVERLAP_PARSE_SHARE = .4;
33644
33758
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
33645
33759
  const REACT_SERVER_DOM_PACKAGES = [
33646
33760
  "react-server-dom-webpack",
@@ -33675,9 +33789,13 @@ const CONFIG_CACHE_TTL_MS = 300 * 1e3;
33675
33789
  const SOCKET_FREE_PURL_API_BASE = "https://firewall-api.socket.dev/purl";
33676
33790
  const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
33677
33791
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
33792
+ const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
33793
+ const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
33678
33794
  const SUPPLY_CHAIN_PLUGIN = "socket";
33679
33795
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
33680
33796
  const SUPPLY_CHAIN_CATEGORY = "Security";
33797
+ const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
33798
+ const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
33681
33799
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
33682
33800
  const TSCONFIG_FILENAME = "tsconfig.json";
33683
33801
  const isRelativeExtendsValue = (extendsValue) => extendsValue.startsWith("./") || extendsValue.startsWith("../") || Path.isAbsolute(extendsValue);
@@ -34370,7 +34488,10 @@ for (const [legacyRuleKey, nativeRuleKey] of Object.entries(LEGACY_RULE_KEY_TO_N
34370
34488
  NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.set(nativeRuleKey, aliases);
34371
34489
  }
34372
34490
  const getLegacyRuleKeysForNative = (ruleKey) => NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.get(ruleKey) ?? [];
34373
- const canonicalizeRuleKey = (ruleKey) => LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey] ?? ruleKey;
34491
+ const canonicalizeRuleKey = (ruleKey) => {
34492
+ const nativeRuleKey = LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey];
34493
+ return typeof nativeRuleKey === "string" ? nativeRuleKey : ruleKey;
34494
+ };
34374
34495
  const isReactDoctorShortIdOf = (bareRuleKey, qualifiedRuleKey) => !bareRuleKey.includes("/") && qualifiedRuleKey === `react-doctor/${bareRuleKey}`;
34375
34496
  const isSameRuleKey = (candidateRuleKey, targetRuleKey) => {
34376
34497
  const canonicalCandidate = canonicalizeRuleKey(candidateRuleKey);
@@ -35036,6 +35157,11 @@ var OxlintBatchExceeded = class extends TaggedErrorClass()("OxlintBatchExceeded"
35036
35157
  }
35037
35158
  }
35038
35159
  };
35160
+ var ScanDeadlineExceeded = class extends TaggedErrorClass()("ScanDeadlineExceeded", { detail: String$1 }) {
35161
+ get message() {
35162
+ return `Scan exceeded its overall time budget: ${this.detail}`;
35163
+ }
35164
+ };
35039
35165
  var OxlintSpawnFailed = class extends TaggedErrorClass()("OxlintSpawnFailed", { cause: Unknown }) {
35040
35166
  get message() {
35041
35167
  return `Failed to run oxlint: ${pretty(fail$6(this.cause))}`;
@@ -35099,6 +35225,7 @@ var GitBaseBranchInvalid = class extends TaggedErrorClass()("GitBaseBranchInvali
35099
35225
  const ReactDoctorErrorReason = Union([
35100
35226
  OxlintUnavailable,
35101
35227
  OxlintBatchExceeded,
35228
+ ScanDeadlineExceeded,
35102
35229
  OxlintSpawnFailed,
35103
35230
  OxlintOutputUnparseable,
35104
35231
  ConfigParseFailed,
@@ -35171,15 +35298,105 @@ const layerOtlp = unwrap$3(gen(function* () {
35171
35298
  }).pipe(provide$2(layer$8));
35172
35299
  }).pipe(orDie));
35173
35300
  /**
35174
- * Resolves a requested lint worker count to a clamped integer within
35175
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
35176
- * machine's CPU cores; out-of-range or non-finite requests degrade to
35301
+ * Read a positive-millisecond timeout from an env var, falling back to
35302
+ * `defaultMs` when the var is unset, non-finite, or not strictly positive.
35303
+ */
35304
+ const readPositiveEnvMs = (envVarName, defaultMs) => {
35305
+ const rawValue = process.env[envVarName];
35306
+ if (rawValue === void 0) return defaultMs;
35307
+ const parsedValue = Number(rawValue);
35308
+ if (!Number.isFinite(parsedValue) || parsedValue <= 0) return defaultMs;
35309
+ return parsedValue;
35310
+ };
35311
+ const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
35312
+ const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
35313
+ const CGROUP_UNLIMITED_SENTINEL_BYTES = Number.MAX_SAFE_INTEGER;
35314
+ /**
35315
+ * Parses one raw cgroup memory-limit file value into a positive byte count, or
35316
+ * `undefined` when it represents "no limit" (the v2 `"max"` literal, an empty
35317
+ * read, a non-positive / non-finite value, or v1's near-2^63 unlimited
35318
+ * sentinel). Pure and exported so the classification is unit-testable without
35319
+ * touching the filesystem.
35320
+ */
35321
+ const parseCgroupMemoryLimitBytes = (raw) => {
35322
+ if (raw === void 0) return void 0;
35323
+ const trimmed = raw.trim();
35324
+ if (trimmed === "" || trimmed === "max") return void 0;
35325
+ const parsed = Number(trimmed);
35326
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= CGROUP_UNLIMITED_SENTINEL_BYTES) return;
35327
+ return parsed;
35328
+ };
35329
+ const CGROUP_MEMORY_LIMIT_PATHS = [CGROUP_V2_MEMORY_MAX_PATH, CGROUP_V1_MEMORY_LIMIT_PATH];
35330
+ /**
35331
+ * Reads this process's cgroup memory limit in bytes from the first candidate
35332
+ * path that yields a real limit, or `undefined` when none does — no cgroup, no
35333
+ * limit, or the files are unreadable (e.g. macOS / Windows dev machines).
35334
+ * `os.totalmem()` reports the HOST total and ignores cgroup memory limits, so a
35335
+ * memory-constrained container over-reports total memory; `resolveAutoScan-
35336
+ * Concurrency` takes `min(totalmem, this)` to honor the limit.
35337
+ *
35338
+ * The cgroup v2 read is the mount-root `memory.max`, which IS the container's
35339
+ * limit under the standard cgroup-namespace setup CI runners use (the
35340
+ * container's own cgroup is the root of its namespaced view). A process in a
35341
+ * non-namespaced nested/delegated cgroup whose root reads `"max"` is not
35342
+ * detected here and falls back to the host total; the EAGAIN/ENOMEM serial
35343
+ * replay in `spawnLintBatches` remains the runtime backstop for that case.
35344
+ *
35345
+ * `candidatePaths` is injectable so tests exercise the v2-wins-over-v1
35346
+ * precedence, the skip-unreadable fallback, and the all-missing case without a
35347
+ * real `/sys/fs/cgroup`.
35348
+ */
35349
+ const readCgroupMemoryLimitBytes = (candidatePaths = CGROUP_MEMORY_LIMIT_PATHS) => {
35350
+ for (const limitPath of candidatePaths) {
35351
+ let raw;
35352
+ try {
35353
+ raw = fs.readFileSync(limitPath, "utf8");
35354
+ } catch {
35355
+ continue;
35356
+ }
35357
+ const limitBytes = parseCgroupMemoryLimitBytes(raw);
35358
+ if (limitBytes !== void 0) return limitBytes;
35359
+ }
35360
+ };
35361
+ /**
35362
+ * Clamps a requested lint worker count to `[MIN_SCAN_CONCURRENCY,
35363
+ * HARD_MAX_SCAN_CONCURRENCY]` as a finite integer. This is the explicit-pin and
35364
+ * spawn-boundary clamp — the memory-and-core-budgeted auto count comes from
35365
+ * `resolveAutoScanConcurrency`. Out-of-range or non-finite requests degrade to
35177
35366
  * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
35178
35367
  */
35179
35368
  const resolveScanConcurrency = (requested) => {
35180
- const desired = requested === "auto" ? os.availableParallelism() : requested;
35181
- if (!Number.isFinite(desired) || desired < 1) return 1;
35182
- return Math.max(1, Math.min(Math.floor(desired), 16));
35369
+ if (!Number.isFinite(requested) || requested < 1) return 1;
35370
+ return Math.min(Math.floor(requested), 32);
35371
+ };
35372
+ const readSystemFacts = () => ({
35373
+ availableCores: os.availableParallelism(),
35374
+ totalMemoryBytes: os.totalmem(),
35375
+ cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
35376
+ });
35377
+ /**
35378
+ * Auto lint-worker count: the smaller of the (cgroup-CPU-aware) core count and
35379
+ * the number of `PER_WORKER_MEM_BUDGET_BYTES` workers that fit in available
35380
+ * memory, then clamped to `[MIN, HARD_MAX]` by `resolveScanConcurrency`.
35381
+ *
35382
+ * `os.availableParallelism()` already respects cgroup CPU quotas, so the core
35383
+ * term needs no help. Available memory is `os.totalmem()` floored by the cgroup
35384
+ * memory limit — `os.freemem()` is deliberately NOT used: it excludes
35385
+ * reclaimable page cache and reads near-zero on macOS / cache-heavy Linux, which
35386
+ * would collapse the auto path to a single worker. `os.totalmem()` reports the
35387
+ * host total even inside a container, so the cgroup limit (read directly,
35388
+ * because Node doesn't fold it into `totalmem()`) is the real ceiling there.
35389
+ *
35390
+ * `facts` is injectable so tests exercise core-bound, memory-bound, cgroup-
35391
+ * limited, and ceiling cases without mocking `os` or the filesystem.
35392
+ */
35393
+ const resolveAutoScanConcurrency = (facts = readSystemFacts()) => {
35394
+ const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
35395
+ const memoryBoundedWorkers = Math.floor(availableMemoryBytes / PER_WORKER_MEM_BUDGET_BYTES);
35396
+ return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
35397
+ };
35398
+ const resolveLintBatchOrdering = () => {
35399
+ return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "cost" ? "cost" : "arrival";
35183
35400
  };
35184
35401
  /**
35185
35402
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -35187,11 +35404,38 @@ const resolveScanConcurrency = (requested) => {
35187
35404
  * microVMs without recompiling react-doctor. Tests override via
35188
35405
  * `Layer.succeed(OxlintSpawnTimeoutMs, ...)`.
35189
35406
  */
35190
- var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => {
35191
- const raw = process.env["REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS"];
35192
- if (raw === void 0) return OXLINT_SPAWN_TIMEOUT_MS;
35407
+ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS", OXLINT_SPAWN_TIMEOUT_MS) }) {};
35408
+ /**
35409
+ * Effect-side cap on the lint phase. The env var lets CI / eval runners
35410
+ * raise the phase budget for slow large repos without recompiling.
35411
+ * Tests override via `Layer.succeed(LintPhaseTimeoutMs, ...)`.
35412
+ */
35413
+ var LintPhaseTimeoutMs = class extends Reference("react-doctor/LintPhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_LINT_PHASE_TIMEOUT_MS", LINT_PHASE_TIMEOUT_MS) }) {};
35414
+ /**
35415
+ * Effect-side cap on the dead-code phase, sitting above the in-worker
35416
+ * timeout as a runtime-independent backstop. The env var raises it for
35417
+ * type-heavy projects; tests override via
35418
+ * `Layer.succeed(DeadCodePhaseTimeoutMs, ...)`.
35419
+ */
35420
+ var DeadCodePhaseTimeoutMs = class extends Reference("react-doctor/DeadCodePhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_DEAD_CODE_PHASE_TIMEOUT_MS", DEAD_CODE_PHASE_TIMEOUT_MS) }) {};
35421
+ /**
35422
+ * Overall scan deadline backstop, bounding everything the per-phase
35423
+ * timeouts don't (wedged git / IO). The env var raises it for very
35424
+ * large repos; tests override via `Layer.succeed(ScanDeadlineMs, ...)`.
35425
+ */
35426
+ var ScanDeadlineMs = class extends Reference("react-doctor/ScanDeadlineMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_SCAN_DEADLINE_MS", SCAN_TOTAL_DEADLINE_MS) }) {};
35427
+ /**
35428
+ * Wall-clock budget for the supply-chain check when it runs on a background
35429
+ * fiber overlapping the lint pass. Reads from the env var on startup so the
35430
+ * eval harness can raise the budget under sandbox microVMs (slower network)
35431
+ * without recompiling react-doctor. Tests override via
35432
+ * `Layer.succeed(SupplyChainOverlapTimeoutMs, ...)`.
35433
+ */
35434
+ var SupplyChainOverlapTimeoutMs = class extends Reference("react-doctor/SupplyChainOverlapTimeoutMs", { defaultValue: () => {
35435
+ const raw = process.env["REACT_DOCTOR_SUPPLY_CHAIN_TIMEOUT_MS"];
35436
+ if (raw === void 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
35193
35437
  const parsed = Number(raw);
35194
- if (!Number.isFinite(parsed) || parsed <= 0) return OXLINT_SPAWN_TIMEOUT_MS;
35438
+ if (!Number.isFinite(parsed) || parsed <= 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
35195
35439
  return parsed;
35196
35440
  } }) {};
35197
35441
  /**
@@ -35202,31 +35446,93 @@ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTime
35202
35446
  */
35203
35447
  var OxlintOutputMaxBytes = class extends Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
35204
35448
  /**
35205
- * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
35206
- * to auto-detected CPU cores (parallel) so large repos scan fast out of
35207
- * the box; `spawnLintBatches` transparently falls back to a single worker
35208
- * if a parallel run exhausts system resources. The CLI's `--no-parallel`
35209
- * flag forces serial via `Layer.succeed`; the `REACT_DOCTOR_PARALLEL` env
35210
- * var seeds the default for programmatic / CI callers that never touch the
35211
- * flag parallelism is opt-OUT, so only the explicit serial values pin
35212
- * one worker:
35213
- *
35214
- * - unset / `auto` / `true` / `on` → available CPU cores (clamped)
35449
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults to a
35450
+ * memory-and-core-budgeted auto count (`resolveAutoScanConcurrency`) so large
35451
+ * repos scan fast out of the box without OOMing the native binding on a
35452
+ * high-core / low-memory box; `spawnLintBatches` transparently falls back to a
35453
+ * single worker if a parallel run still exhausts system resources. The CLI's
35454
+ * `--no-parallel` flag forces serial via `Layer.succeed`; the
35455
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic / CI
35456
+ * callers that never touch the flag — parallelism is opt-OUT, so only the
35457
+ * explicit serial values pin one worker:
35458
+ *
35459
+ * - unset / `auto` / `true` / `on` → memory-and-core-budgeted auto count
35215
35460
  * - `0` / `false` / `off` → `1` (serial)
35216
35461
  * - a positive integer → that many workers (clamped)
35217
- * - any other value → available CPU cores (clamped)
35462
+ * - any other value → memory-and-core-budgeted auto count
35218
35463
  *
35219
35464
  * The resolved value is always within
35220
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
35465
+ * `[MIN_SCAN_CONCURRENCY, HARD_MAX_SCAN_CONCURRENCY]`.
35221
35466
  */
35222
35467
  var OxlintConcurrency = class extends Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
35223
35468
  const raw = process.env["REACT_DOCTOR_PARALLEL"];
35224
- if (raw === void 0) return resolveScanConcurrency("auto");
35469
+ if (raw === void 0) return resolveAutoScanConcurrency();
35225
35470
  const normalized = raw.trim().toLowerCase();
35226
35471
  if (normalized === "0" || normalized === "false" || normalized === "off") return 1;
35227
35472
  const parsed = Number.parseInt(normalized, 10);
35228
35473
  if (Number.isInteger(parsed) && parsed > 0) return resolveScanConcurrency(parsed);
35229
- return resolveScanConcurrency("auto");
35474
+ return resolveAutoScanConcurrency();
35475
+ } }) {};
35476
+ /**
35477
+ * Three-state control for overlapping the dead-code pass with the lint pass —
35478
+ * forking dead-code as a child fiber that runs DURING lint instead of strictly
35479
+ * after it.
35480
+ *
35481
+ * - `"auto"` (default) / `"off"` → strictly SEQUENTIAL: dead-code runs after
35482
+ * lint with the full core budget. Both deslop's parse pool and the oxlint
35483
+ * pool are CPU-bound and each size themselves to all cores, so overlapping
35484
+ * them only oversubscribes (~2x the cores) and starves the parse pass past
35485
+ * its timeout — for no wall-clock win, since there are no spare cores to
35486
+ * absorb the second pass. Sequential is both faster per-phase and safe.
35487
+ * - `"on"` → force the overlap anyway. The orchestrator then SPLITS the core
35488
+ * budget (`DEAD_CODE_OVERLAP_PARSE_SHARE`): deslop's parse pool is capped
35489
+ * and lint shrinks to the remainder, so the two sum to the cores instead of
35490
+ * doubling them, and the dead-code timeout scales up for the reduced share.
35491
+ *
35492
+ * Seeded from `REACT_DOCTOR_DEAD_CODE_OVERLAP` so operators get a redeploy-free
35493
+ * switch; tests pin it via `Layer.succeed(DeadCodeOverlap, ...)`.
35494
+ */
35495
+ var DeadCodeOverlap = class extends Reference("react-doctor/DeadCodeOverlap", { defaultValue: () => {
35496
+ const raw = process.env["REACT_DOCTOR_DEAD_CODE_OVERLAP"]?.trim().toLowerCase();
35497
+ if (raw === "on" || raw === "true" || raw === "1") return "on";
35498
+ if (raw === "off" || raw === "false" || raw === "0") return "off";
35499
+ return "auto";
35500
+ } }) {};
35501
+ /**
35502
+ * How the full-scan lint pass orders its file batches. `"arrival"` (the
35503
+ * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
35504
+ * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
35505
+ * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
35506
+ * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
35507
+ * batch is a straggler (and can trip the per-batch timeout + split), measurably
35508
+ * regressing the common full-scan case. LPT needs the heavy files SPREAD across
35509
+ * batches before `cost` earns the default. Tests override via
35510
+ * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
35511
+ * — they pass user-scoped `includePaths` that skip discovery and stay in
35512
+ * arrival order; only the full-scan branch reads it.
35513
+ */
35514
+ var LintBatchOrdering = class extends Reference("react-doctor/LintBatchOrdering", { defaultValue: resolveLintBatchOrdering }) {};
35515
+ const CACHE_DISABLED_VALUES = new Set(["1", "true"]);
35516
+ /**
35517
+ * Whether the per-file lint cache (`runners/oxlint/file-lint-cache.ts`) is
35518
+ * active. Defaults ON — repeat scans re-lint only the files whose content
35519
+ * changed, and correctness is guaranteed byte-identical to a cold scan by the
35520
+ * always-fresh cross-file sidecar. Opt-OUT, two knobs (matching the whole-repo
35521
+ * scan cache's `REACT_DOCTOR_NO_CACHE`):
35522
+ *
35523
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch; disables BOTH the
35524
+ * whole-repo scan cache and this per-file cache.
35525
+ * - `REACT_DOCTOR_NO_FILE_CACHE` — granular: bust only the per-file cache
35526
+ * while keeping the whole-repo short-circuit.
35527
+ *
35528
+ * Tests override via `Layer.succeed(PerFileLintCacheEnabled, false)`.
35529
+ */
35530
+ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintCacheEnabled", { defaultValue: () => {
35531
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
35532
+ const noFileCache = process.env["REACT_DOCTOR_NO_FILE_CACHE"]?.toLowerCase() ?? "";
35533
+ if (CACHE_DISABLED_VALUES.has(noCache)) return false;
35534
+ if (CACHE_DISABLED_VALUES.has(noFileCache)) return false;
35535
+ return true;
35230
35536
  } }) {};
35231
35537
  const DIAGNOSTIC_SURFACES = [
35232
35538
  "cli",
@@ -35275,6 +35581,12 @@ const BOOLEAN_FIELD_NAMES = [
35275
35581
  "adoptExistingLintConfig"
35276
35582
  ];
35277
35583
  const STRING_FIELD_NAMES = ["rootDir"];
35584
+ const STRING_ARRAY_FIELD_NAMES = [
35585
+ "projects",
35586
+ "textComponents",
35587
+ "rawTextWrapperComponents",
35588
+ "serverAuthFunctionNames"
35589
+ ];
35278
35590
  const SURFACE_CONTROL_FIELD_NAMES = [
35279
35591
  "includeTags",
35280
35592
  "excludeTags",
@@ -35376,6 +35688,7 @@ const validateConfigTypes = (config) => {
35376
35688
  const validated = { ...config };
35377
35689
  for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
35378
35690
  for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
35691
+ for (const fieldName of STRING_ARRAY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateStringArrayField(fieldName, value));
35379
35692
  applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
35380
35693
  for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
35381
35694
  applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
@@ -35632,6 +35945,8 @@ const assignFixGroups = (diagnostics) => {
35632
35945
  };
35633
35946
  });
35634
35947
  };
35948
+ const compareStrings = (left, right) => left < right ? -1 : left > right ? 1 : 0;
35949
+ 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));
35635
35950
  const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
35636
35951
  const buildExpoCheckContext = (rootDirectory, expoVersion) => {
35637
35952
  const packageJson = readPackageJson(Path.join(rootDirectory, "package.json"));
@@ -36138,10 +36453,15 @@ const buildHardeningDiagnostic = (input) => ({
36138
36453
  column: input.column ?? 0,
36139
36454
  category: "Security"
36140
36455
  });
36141
- const checkPnpmHardening = (rootDirectory) => {
36142
- if (!isPnpmManagedProject(rootDirectory)) return [];
36143
- const workspacePath = Path.join(rootDirectory, PNPM_WORKSPACE_FILE);
36144
- const settings = parseHardeningSettings(isFile(workspacePath) ? NFS.readFileSync(workspacePath, "utf-8") : "");
36456
+ const checkPnpmHardening = (scanDirectory) => {
36457
+ if (!isPnpmManagedProject(scanDirectory)) return [];
36458
+ const workspacePath = Path.join(scanDirectory, PNPM_WORKSPACE_FILE);
36459
+ const hasWorkspaceFile = isFile(workspacePath);
36460
+ if (!hasWorkspaceFile) {
36461
+ const monorepoRoot = findMonorepoRoot(scanDirectory);
36462
+ if (monorepoRoot !== null && isFile(Path.join(monorepoRoot, PNPM_WORKSPACE_FILE))) return [];
36463
+ }
36464
+ const settings = parseHardeningSettings(hasWorkspaceFile ? NFS.readFileSync(workspacePath, "utf-8") : "");
36145
36465
  const diagnostics = [];
36146
36466
  if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
36147
36467
  message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
@@ -36926,6 +37246,22 @@ process.stdin.on("end", () => {
36926
37246
  ...(workerInput.ignorePatterns.length > 0
36927
37247
  ? { ignorePatterns: workerInput.ignorePatterns }
36928
37248
  : {}),
37249
+ // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
37250
+ // unusedDependencies, circularDependencies). Everything else deslop can compute
37251
+ // is pure wasted work for us, and it's the bulk of the runtime:
37252
+ // - semantic: a full TS Program for unusedTypes/enum/class-members/
37253
+ // misclassifiedDependencies (~37-45% of the phase).
37254
+ // - reportCodeQuality: the duplicate-block, complexity, feature-flag,
37255
+ // TypeScript-smell, private-type-leak and re-export-cycle detectors. These
37256
+ // are the single most expensive pass — duplicate-block detection alone was
37257
+ // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
37258
+ // speedup on a large repo.
37259
+ // Both are provably safe: the consumed graph findings are computed by their own
37260
+ // detectors, independent of these passes (confirmed byte-identical on
37261
+ // excalidraw + mui-material + sentry). tsConfigPath stays — the module resolver
37262
+ // needs it for path-alias resolution in the import graph.
37263
+ semantic: { enabled: false },
37264
+ reportCodeQuality: false,
36929
37265
  };
36930
37266
  const result = await analyze(defineConfig(config));
36931
37267
  emit({ ok: true, result: normalizeResult(result) });
@@ -37055,7 +37391,11 @@ const createDeadCodeWorker = (input) => {
37055
37391
  "pipe",
37056
37392
  "pipe"
37057
37393
  ],
37058
- windowsHide: true
37394
+ windowsHide: true,
37395
+ env: input.parseConcurrency === void 0 ? process.env : {
37396
+ ...process.env,
37397
+ DESLOP_PARSE_CONCURRENCY: String(input.parseConcurrency)
37398
+ }
37059
37399
  });
37060
37400
  const stdoutChunks = [];
37061
37401
  const stderrChunks = [];
@@ -37100,28 +37440,25 @@ const createDeadCodeWorker = (input) => {
37100
37440
  }
37101
37441
  };
37102
37442
  };
37103
- const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve, reject) => {
37443
+ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs, abortSignal) => new Promise((resolve, reject) => {
37104
37444
  let didSettle = false;
37105
- const timeoutHandle = setTimeout(() => {
37106
- if (didSettle) return;
37107
- didSettle = true;
37108
- handle.terminate?.();
37109
- reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`));
37110
- }, timeoutMs);
37111
- timeoutHandle.unref?.();
37112
- handle.result.then((value) => {
37445
+ const settle = (finish) => {
37113
37446
  if (didSettle) return;
37114
37447
  didSettle = true;
37115
37448
  clearTimeout(timeoutHandle);
37449
+ abortSignal?.removeEventListener("abort", onAbort);
37116
37450
  handle.terminate?.();
37117
- resolve(value);
37118
- }, (error) => {
37119
- if (didSettle) return;
37120
- didSettle = true;
37121
- clearTimeout(timeoutHandle);
37122
- handle.terminate?.();
37123
- reject(error);
37124
- });
37451
+ finish();
37452
+ };
37453
+ const onAbort = () => settle(() => reject(/* @__PURE__ */ new Error("Dead-code worker aborted.")));
37454
+ const timeoutHandle = setTimeout(() => settle(() => reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`))), timeoutMs);
37455
+ timeoutHandle.unref?.();
37456
+ if (abortSignal?.aborted) {
37457
+ onAbort();
37458
+ return;
37459
+ }
37460
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
37461
+ handle.result.then((value) => settle(() => resolve(value)), (error) => settle(() => reject(error)));
37125
37462
  });
37126
37463
  const checkDeadCode = async (options) => {
37127
37464
  const rootDirectory = toCanonicalPath(options.rootDirectory);
@@ -37133,8 +37470,9 @@ const checkDeadCode = async (options) => {
37133
37470
  entryPatterns,
37134
37471
  tsConfigPath: resolveTsConfigPath(rootDirectory),
37135
37472
  ignorePatterns,
37136
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js")
37137
- }), options.workerTimeoutMs ?? 12e4));
37473
+ deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
37474
+ parseConcurrency: options.parseConcurrency
37475
+ }), options.workerTimeoutMs ?? 12e4, options.abortSignal));
37138
37476
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
37139
37477
  const diagnostics = [];
37140
37478
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -37232,7 +37570,37 @@ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
37232
37570
  return true;
37233
37571
  };
37234
37572
  const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
37235
- const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(Path.resolve(rootDirectory, relativePath)));
37573
+ /**
37574
+ * Budget for the dead-code phase, scaled to the work. deslop's graph build is
37575
+ * CPU-bound and roughly linear in file count, so a fixed 120s cap is too tight
37576
+ * for a large repo (where the pass legitimately runs that long) and is then
37577
+ * tipped over by any concurrent load — silently dropping every dead-code
37578
+ * finding. Scaling the budget with file count (and inversely with the core
37579
+ * share when overlapped) lets the pass complete, while the ceiling still
37580
+ * reclaims a genuinely wedged worker. Returns the in-worker SIGKILL deadline
37581
+ * and the Effect-side phase backstop that sits a margin above it.
37582
+ */
37583
+ const resolveDeadCodeTimeout = (input) => {
37584
+ const coreShareFactor = Math.max(1, input.fullConcurrency / Math.max(1, input.deadCodeConcurrency));
37585
+ const workerTimeoutMs = Math.min(DEAD_CODE_TIMEOUT_CEILING_MS, Math.max(DEAD_CODE_WORKER_TIMEOUT_MS, Math.ceil(input.sourceFileCount * 30 * coreShareFactor)));
37586
+ return {
37587
+ workerTimeoutMs,
37588
+ phaseTimeoutMs: workerTimeoutMs + DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS
37589
+ };
37590
+ };
37591
+ const collectSizedSourceFiles = (rootDirectory, relativePaths) => {
37592
+ const entries = [];
37593
+ for (const relativePath of relativePaths) {
37594
+ const absolutePath = Path.resolve(rootDirectory, relativePath);
37595
+ const sizeBytes = statSourceFileSize(absolutePath);
37596
+ if (isLargeMinifiedFile(absolutePath, sizeBytes)) continue;
37597
+ entries.push({
37598
+ path: relativePath,
37599
+ sizeBytes: sizeBytes ?? 0
37600
+ });
37601
+ }
37602
+ return entries;
37603
+ };
37236
37604
  const listSourceFilesViaGit = (rootDirectory) => {
37237
37605
  const result = spawnSync("git", [
37238
37606
  "ls-files",
@@ -37265,7 +37633,8 @@ const listSourceFilesViaFilesystem = (rootDirectory) => {
37265
37633
  }
37266
37634
  return filePaths;
37267
37635
  };
37268
- const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
37636
+ const listSourceFilesWithSize = (rootDirectory) => collectSizedSourceFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
37637
+ const listSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).map((entry) => entry.path);
37269
37638
  const resolveLintIncludePaths = (rootDirectory, userConfig, project) => {
37270
37639
  if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
37271
37640
  const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
@@ -37308,9 +37677,12 @@ var Config = class Config extends Service()("react-doctor/Config") {
37308
37677
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
37309
37678
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
37310
37679
  return yield* tryPromise({
37311
- try: () => checkDeadCode({
37680
+ try: (signal) => checkDeadCode({
37312
37681
  rootDirectory: input.rootDirectory,
37313
- userConfig: input.userConfig
37682
+ userConfig: input.userConfig,
37683
+ parseConcurrency: input.parseConcurrency,
37684
+ workerTimeoutMs: input.workerTimeoutMs,
37685
+ abortSignal: signal
37314
37686
  }),
37315
37687
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
37316
37688
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -37501,43 +37873,46 @@ var Git = class Git extends Service()("react-doctor/Git") {
37501
37873
  * reason: GitInvocationFailed })` so the rest of the codebase
37502
37874
  * sees a single failure channel.
37503
37875
  */
37504
- const runCommand = (input) => scoped(gen(function* () {
37505
- const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
37506
- cwd: input.directory,
37507
- env: input.env,
37508
- extendEnv: true
37509
- }));
37510
- const maxStdoutBytes = input.maxStdoutBytes;
37511
- const stdoutByteCount = yield* make$13(0);
37512
- const [stdout, stderr, status] = yield* all([
37513
- 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({
37514
- args: [...input.args],
37515
- directory: input.directory,
37516
- cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
37517
- }) })) : void_)))))),
37518
- mkString(decodeText(handle.stderr)),
37519
- handle.exitCode
37520
- ], { concurrency: 3 });
37521
- return {
37522
- status,
37523
- stdout,
37524
- stderr
37525
- };
37526
- })).pipe(catchTag$1("PlatformError", (cause) => {
37527
- if (input.command !== "git") return succeed$2({
37876
+ const runCommand = (input) => {
37877
+ const foldSpawnFailure = (cause) => input.command !== "git" ? succeed$2({
37528
37878
  status: 127,
37529
37879
  stdout: "",
37530
37880
  stderr: String(cause)
37531
- });
37532
- return new ReactDoctorError({ reason: new GitInvocationFailed({
37881
+ }) : fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
37533
37882
  args: [...input.args],
37534
37883
  directory: input.directory,
37535
37884
  cause
37536
- }) });
37537
- }), withSpan("git.exec", { attributes: {
37538
- "git.command": input.command,
37539
- "git.subcommand": input.args[0] ?? ""
37540
- } }));
37885
+ }) }));
37886
+ return scoped(gen(function* () {
37887
+ if (!isDirectory(input.directory)) return yield* foldSpawnFailure(`spawn ENOTDIR (cwd is not a directory: ${input.directory})`);
37888
+ const argvLengthChars = input.command.length + 1 + input.args.reduce((total, arg) => total + arg.length + 1, 0);
37889
+ if (argvLengthChars > 24e3) return yield* foldSpawnFailure(`spawn ENAMETOOLONG (${argvLengthChars} argv chars exceed ${SPAWN_ARGS_MAX_LENGTH_CHARS})`);
37890
+ const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
37891
+ cwd: input.directory,
37892
+ env: input.env,
37893
+ extendEnv: true
37894
+ }));
37895
+ const maxStdoutBytes = input.maxStdoutBytes;
37896
+ const stdoutByteCount = yield* make$13(0);
37897
+ const [stdout, stderr, status] = yield* all([
37898
+ 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({
37899
+ args: [...input.args],
37900
+ directory: input.directory,
37901
+ cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
37902
+ }) })) : void_)))))),
37903
+ mkString(decodeText(handle.stderr)),
37904
+ handle.exitCode
37905
+ ], { concurrency: 3 });
37906
+ return {
37907
+ status,
37908
+ stdout,
37909
+ stderr
37910
+ };
37911
+ })).pipe(catchTag$1("PlatformError", foldSpawnFailure), withSpan("git.exec", { attributes: {
37912
+ "git.command": input.command,
37913
+ "git.subcommand": input.args[0] ?? ""
37914
+ } }));
37915
+ };
37541
37916
  const runGit = (directory, args) => runCommand({
37542
37917
  command: "git",
37543
37918
  args,
@@ -37570,7 +37945,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
37570
37945
  "rev-parse",
37571
37946
  "--verify",
37572
37947
  branch
37573
- ]).pipe(map$3((result) => result.status === 0));
37948
+ ]).pipe(map$3((result) => result.status === 0), catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(false) : fail$4(error)));
37574
37949
  const headSha = (directory) => runGit(directory, ["rev-parse", "HEAD"]).pipe(map$3((result) => result.status === 0 ? trimOrNull(result.stdout) : null));
37575
37950
  const mergeBase = (input) => isSafeGitRevision(input.ref) ? runGit(input.directory, [
37576
37951
  "merge-base",
@@ -37784,7 +38159,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
37784
38159
  ]);
37785
38160
  if (result.status !== 0) return null;
37786
38161
  return parseChangedLineRanges(result.stdout);
37787
- }).pipe(withSpan("Git.changedLineRanges"))
38162
+ }).pipe(catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(null) : fail$4(error)), withSpan("Git.changedLineRanges"))
37788
38163
  });
37789
38164
  })).pipe(provide$2(layer$2.pipe(provide$2(mergeAll$1(layer$1, layer)))));
37790
38165
  /**
@@ -38023,6 +38398,14 @@ const neutralizeDisableDirectives = async (rootDirectory, includePaths) => {
38023
38398
  process.removeListener("exit", onExit);
38024
38399
  };
38025
38400
  };
38401
+ const ROOT_DIRECTORY_PLACEHOLDER = "<root>";
38402
+ const normalizeConfigForHash = (config) => {
38403
+ const clone = JSON.parse(JSON.stringify(config));
38404
+ if (clone?.settings?.["react-doctor"]) clone.settings["react-doctor"].rootDirectory = ROOT_DIRECTORY_PLACEHOLDER;
38405
+ if (Array.isArray(clone?.jsPlugins)) clone.jsPlugins = clone.jsPlugins.map((_, index) => `<plugin:${index}>`);
38406
+ return clone;
38407
+ };
38408
+ 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");
38026
38409
  /**
38027
38410
  * Loads a plugin module via the local require resolver and extracts
38028
38411
  * `(name, ruleNames)` from either `module.exports.meta + rules` or
@@ -38049,16 +38432,16 @@ const readPluginShape = (pluginSpecifier, loadModule) => {
38049
38432
  ruleNames: new Set(Object.keys(rules))
38050
38433
  };
38051
38434
  };
38052
- const bundledRequire = createRequire(import.meta.url);
38435
+ const bundledRequire$1 = createRequire(import.meta.url);
38053
38436
  const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
38054
38437
  if (!hasReactCompiler || customRulesOnly) return null;
38055
38438
  let pluginSpecifier;
38056
38439
  try {
38057
- pluginSpecifier = bundledRequire.resolve("eslint-plugin-react-hooks");
38440
+ pluginSpecifier = bundledRequire$1.resolve("eslint-plugin-react-hooks");
38058
38441
  } catch {
38059
38442
  return null;
38060
38443
  }
38061
- const { ruleNames } = readPluginShape(pluginSpecifier, (spec) => bundledRequire(spec));
38444
+ const { ruleNames } = readPluginShape(pluginSpecifier, (spec) => bundledRequire$1(spec));
38062
38445
  return {
38063
38446
  entry: {
38064
38447
  name: "react-hooks-js",
@@ -38177,8 +38560,8 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
38177
38560
  }
38178
38561
  return enabled;
38179
38562
  };
38180
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false }) => {
38181
- const reactHooksJsPlugin = disableReactHooksJsPlugin ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
38563
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection }) => {
38564
+ const reactHooksJsPlugin = disableReactHooksJsPlugin || ruleSelection === "sidecar" ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
38182
38565
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
38183
38566
  const jsPlugins = [];
38184
38567
  if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
@@ -38187,6 +38570,8 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38187
38570
  for (const registryEntry of REACT_DOCTOR_RULES) {
38188
38571
  const rule = reactDoctorPlugin.rules[registryEntry.id];
38189
38572
  if (!rule) continue;
38573
+ if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38574
+ if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
38190
38575
  if (rule.scan !== void 0) continue;
38191
38576
  if (customRulesOnly && registryEntry.originallyExternal) continue;
38192
38577
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -38201,7 +38586,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38201
38586
  enabledReactDoctorRules[registryEntry.key] = severity;
38202
38587
  }
38203
38588
  const userPluginRules = {};
38204
- for (const userPlugin of userPlugins) {
38589
+ if (ruleSelection !== "sidecar") for (const userPlugin of userPlugins) {
38205
38590
  Object.assign(userPluginRules, buildUserPluginRules(userPlugin, severityControls));
38206
38591
  jsPlugins.push(userPlugin.entry);
38207
38592
  }
@@ -38231,6 +38616,100 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
38231
38616
  }
38232
38617
  };
38233
38618
  };
38619
+ const atomicWriteJson = (filePath, value) => {
38620
+ try {
38621
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
38622
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
38623
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
38624
+ NFS.renameSync(temporaryPath, filePath);
38625
+ } catch {
38626
+ return;
38627
+ }
38628
+ };
38629
+ const failOpenReadJson = (filePath, fallback) => {
38630
+ try {
38631
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
38632
+ } catch {
38633
+ return fallback;
38634
+ }
38635
+ };
38636
+ const validateDiagnostic = decodeUnknownSync(Diagnostic);
38637
+ const decodeFileDiagnostics = (raw) => {
38638
+ if (!Array.isArray(raw)) return null;
38639
+ try {
38640
+ for (const entry of raw) validateDiagnostic(entry);
38641
+ return raw;
38642
+ } catch {
38643
+ return null;
38644
+ }
38645
+ };
38646
+ const emptyCache = () => ({
38647
+ version: 1,
38648
+ rulesets: {}
38649
+ });
38650
+ const loadRulesetEntries = (cacheFilePath, rulesetHash) => {
38651
+ const entries = /* @__PURE__ */ new Map();
38652
+ const persisted = failOpenReadJson(cacheFilePath, emptyCache());
38653
+ if (persisted.version !== 1 || !isRecord(persisted.rulesets)) return entries;
38654
+ const bucket = persisted.rulesets[rulesetHash];
38655
+ if (!isRecord(bucket) || !isRecord(bucket.files)) return entries;
38656
+ for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
38657
+ const decoded = decodeFileDiagnostics(rawDiagnostics);
38658
+ if (decoded !== null) entries.set(fileKey, decoded);
38659
+ }
38660
+ return entries;
38661
+ };
38662
+ const createFileLintCache = (cacheDirectory, rulesetHash) => {
38663
+ const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
38664
+ const entries = loadRulesetEntries(cacheFilePath, rulesetHash);
38665
+ return {
38666
+ lookup: (fileKey) => entries.get(fileKey) ?? null,
38667
+ store: (fileKey, diagnostics) => {
38668
+ entries.delete(fileKey);
38669
+ entries.set(fileKey, diagnostics);
38670
+ },
38671
+ persist: () => {
38672
+ const onDisk = failOpenReadJson(cacheFilePath, emptyCache());
38673
+ const rulesets = onDisk.version === 1 && isRecord(onDisk.rulesets) ? { ...onDisk.rulesets } : {};
38674
+ const existingBucket = rulesets[rulesetHash];
38675
+ const existingFiles = isRecord(existingBucket) && isRecord(existingBucket.files) ? existingBucket.files : {};
38676
+ const ourFiles = {};
38677
+ for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
38678
+ const cappedEntries = Object.entries({
38679
+ ...existingFiles,
38680
+ ...ourFiles
38681
+ }).slice(-FILE_LINT_CACHE_MAX_FILE_COUNT);
38682
+ rulesets[rulesetHash] = {
38683
+ updatedAtMs: Date.now(),
38684
+ files: Object.fromEntries(cappedEntries)
38685
+ };
38686
+ const keptHashes = Object.entries(rulesets).sort(([, first], [, second]) => second.updatedAtMs - first.updatedAtMs).slice(0, 8).map(([hash]) => hash);
38687
+ const prunedRulesets = {};
38688
+ for (const hash of keptHashes) prunedRulesets[hash] = rulesets[hash];
38689
+ atomicWriteJson(cacheFilePath, {
38690
+ version: 1,
38691
+ rulesets: prunedRulesets
38692
+ });
38693
+ }
38694
+ };
38695
+ };
38696
+ const bundledRequire = createRequire(import.meta.url);
38697
+ const TOOLCHAIN_PACKAGE_SPECIFIERS = [
38698
+ "oxlint/package.json",
38699
+ "oxlint-plugin-react-doctor/package.json",
38700
+ "eslint-plugin-react-hooks/package.json"
38701
+ ];
38702
+ const resolveOxlintToolchainVersions = () => {
38703
+ const versions = [`node=${process.version}`];
38704
+ for (const specifier of TOOLCHAIN_PACKAGE_SPECIFIERS) try {
38705
+ const packageJson = bundledRequire(specifier);
38706
+ const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
38707
+ versions.push(`${specifier}=${version}`);
38708
+ } catch {
38709
+ versions.push(`${specifier}=missing`);
38710
+ }
38711
+ return versions;
38712
+ };
38234
38713
  const esmRequire = createRequire(import.meta.url);
38235
38714
  const resolveOxlintBinary = () => {
38236
38715
  const oxlintMainPath = esmRequire.resolve("oxlint");
@@ -38912,15 +39391,19 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
38912
39391
  };
38913
39392
  });
38914
39393
  };
38915
- const SANITIZED_ENV = (() => {
38916
- const sanitized = {};
38917
- for (const [name, value] of Object.entries(process.env)) {
39394
+ const buildOxlintChildEnv = (sourceEnv) => {
39395
+ const childEnv = {};
39396
+ for (const [name, value] of Object.entries(sourceEnv)) {
38918
39397
  if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
38919
39398
  if (name.startsWith("npm_config_")) continue;
38920
- sanitized[name] = value;
39399
+ childEnv[name] = value;
38921
39400
  }
38922
- return sanitized;
38923
- })();
39401
+ const isCompileCacheDisabled = Boolean(sourceEnv.NODE_DISABLE_COMPILE_CACHE);
39402
+ const isCompileCacheAlreadySet = childEnv.NODE_COMPILE_CACHE !== void 0;
39403
+ if (!isCompileCacheDisabled && !isCompileCacheAlreadySet) childEnv.NODE_COMPILE_CACHE = Path.join(os.tmpdir(), NODE_COMPILE_CACHE_DIR_NAME);
39404
+ return childEnv;
39405
+ };
39406
+ const SANITIZED_ENV = buildOxlintChildEnv(process.env);
38924
39407
  /**
38925
39408
  * Spawn one oxlint subprocess with hard ceilings on wall time and
38926
39409
  * output size. Returns stdout on success; raises a tagged
@@ -38937,7 +39420,11 @@ const SANITIZED_ENV = (() => {
38937
39420
  * The first three are splittable (the caller's binary-split retry
38938
39421
  * shrinks the batch and re-spawns); the fourth isn't.
38939
39422
  */
38940
- const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES) => new Promise((resolve, reject) => {
39423
+ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES, abortSignal) => new Promise((resolve, reject) => {
39424
+ if (abortSignal?.aborted) {
39425
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
39426
+ return;
39427
+ }
38941
39428
  const child = spawn(nodeBinaryPath, args, {
38942
39429
  cwd: rootDirectory,
38943
39430
  env: SANITIZED_ENV,
@@ -38947,7 +39434,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
38947
39434
  "pipe"
38948
39435
  ]
38949
39436
  });
39437
+ const onAbort = () => {
39438
+ child.kill("SIGKILL");
39439
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
39440
+ };
39441
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
39442
+ const clearAbortListener = () => abortSignal?.removeEventListener("abort", onAbort);
38950
39443
  const timeoutHandle = setTimeout(() => {
39444
+ clearAbortListener();
38951
39445
  child.kill("SIGKILL");
38952
39446
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
38953
39447
  kind: "timeout",
@@ -38982,10 +39476,12 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
38982
39476
  });
38983
39477
  child.on("error", (error) => {
38984
39478
  clearTimeout(timeoutHandle);
39479
+ clearAbortListener();
38985
39480
  reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: error }) }));
38986
39481
  });
38987
39482
  child.on("close", (_code, signal) => {
38988
39483
  clearTimeout(timeoutHandle);
39484
+ clearAbortListener();
38989
39485
  if (didKillForSize) {
38990
39486
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
38991
39487
  kind: "output-too-large",
@@ -39052,26 +39548,28 @@ const isParallelismRelatedSpawnError = (error) => {
39052
39548
  * loop with a slimmer config in that case.
39053
39549
  */
39054
39550
  const spawnLintBatches = async (input) => {
39055
- const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
39551
+ const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes, splitTotalBudgetMs = OXLINT_SPLIT_TOTAL_BUDGET_MS, splitMaxDepth = 8, signal } = input;
39056
39552
  const requestedConcurrency = resolveScanConcurrency(input.concurrency ?? 1);
39057
39553
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
39058
39554
  const runBatchPass = async (concurrency) => {
39059
39555
  const allDiagnostics = [];
39060
39556
  const droppedFiles = [];
39061
39557
  let firstDropReason = null;
39062
- const spawnLintBatch = async (batch) => {
39558
+ const splitDeadlineMs = Date.now() + splitTotalBudgetMs;
39559
+ const spawnLintBatch = async (batch, depth) => {
39063
39560
  const batchArgs = [...baseArgs, ...batch];
39064
39561
  try {
39065
- return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes), project, rootDirectory);
39562
+ return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes, signal), project, rootDirectory);
39066
39563
  } catch (error) {
39067
39564
  if (!isSplittableReactDoctorError(error)) throw error;
39068
- if (batch.length <= 1) {
39565
+ const splitBudgetExhausted = Date.now() >= splitDeadlineMs || depth >= splitMaxDepth;
39566
+ if (batch.length <= 1 || splitBudgetExhausted) {
39069
39567
  droppedFiles.push(...batch);
39070
- if (firstDropReason === null) firstDropReason = error.message;
39568
+ if (firstDropReason === null) firstDropReason = splitBudgetExhausted && batch.length > 1 ? `${error.message} (split budget exhausted after ${splitMaxDepth} levels / ${splitTotalBudgetMs / MILLISECONDS_PER_SECOND}s)` : error.message;
39071
39569
  return [];
39072
39570
  }
39073
39571
  const splitIndex = Math.ceil(batch.length / 2);
39074
- return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
39572
+ return [...await spawnLintBatch(batch.slice(0, splitIndex), depth + 1), ...await spawnLintBatch(batch.slice(splitIndex), depth + 1)];
39075
39573
  }
39076
39574
  };
39077
39575
  let startedFileCount = 0;
@@ -39088,7 +39586,7 @@ const spawnLintBatches = async (input) => {
39088
39586
  try {
39089
39587
  const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
39090
39588
  startedFileCount += batch.length;
39091
- const batchDiagnostics = await spawnLintBatch(batch);
39589
+ const batchDiagnostics = await spawnLintBatch(batch, 0);
39092
39590
  scannedFileCount += batch.length;
39093
39591
  if (onFileProgress) {
39094
39592
  displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
@@ -39149,6 +39647,22 @@ const validateRuleRegistration = () => {
39149
39647
  ].filter((entry) => entry !== null).join("; ");
39150
39648
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
39151
39649
  };
39650
+ const hashFileContents = (filePath) => {
39651
+ try {
39652
+ return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
39653
+ } catch {
39654
+ return null;
39655
+ }
39656
+ };
39657
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
39658
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
39659
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
39660
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
39661
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
39662
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
39663
+ return Path.join(os.tmpdir(), "react-doctor-cache", projectCacheSubdir(projectDirectory));
39664
+ };
39665
+ const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
39152
39666
  /**
39153
39667
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39154
39668
  * the runner: once for the primary scan, once for the
@@ -39207,7 +39721,7 @@ const reactHooksJsPluginDropNote = (error) => {
39207
39721
  * 6. always restore disable directives + clean up the temp dir
39208
39722
  */
39209
39723
  const runOxlint = async (options) => {
39210
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, spawnTimeoutMs, outputMaxBytes } = options;
39724
+ 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;
39211
39725
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39212
39726
  const severityControls = buildRuleSeverityControls(userConfig);
39213
39727
  validateRuleRegistration();
@@ -39224,30 +39738,156 @@ const runOxlint = async (options) => {
39224
39738
  serverAuthFunctionNames,
39225
39739
  severityControls,
39226
39740
  userPlugins,
39227
- disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
39741
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
39742
+ ruleSelection: overrides.ruleSelection
39228
39743
  });
39229
39744
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
39230
39745
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
39231
39746
  const configPath = Path.join(configDirectory, "oxlintrc.json");
39232
39747
  try {
39233
- const baseArgs = [
39234
- resolveOxlintBinary(),
39235
- "-c",
39236
- configPath,
39237
- "--format",
39238
- "json"
39239
- ];
39748
+ const oxlintBinary = resolveOxlintBinary();
39749
+ const sharedArgs = [];
39750
+ let tsconfigContent = null;
39240
39751
  if (project.hasTypeScript) {
39241
39752
  const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
39242
- if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
39753
+ if (tsconfigRelativePath) {
39754
+ sharedArgs.push("--tsconfig", tsconfigRelativePath);
39755
+ try {
39756
+ tsconfigContent = NFS.readFileSync(Path.resolve(rootDirectory, tsconfigRelativePath), "utf8");
39757
+ } catch {
39758
+ tsconfigContent = null;
39759
+ }
39760
+ }
39243
39761
  }
39244
39762
  const combinedPatterns = collectIgnorePatterns(rootDirectory);
39245
39763
  if (combinedPatterns.length > 0) {
39246
39764
  const combinedIgnorePath = Path.join(configDirectory, "combined.ignore");
39247
39765
  NFS.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
39248
- baseArgs.push("--ignore-path", combinedIgnorePath);
39766
+ sharedArgs.push("--ignore-path", combinedIgnorePath);
39767
+ }
39768
+ const makeBaseArgs = (oxlintConfigPath) => [
39769
+ oxlintBinary,
39770
+ "-c",
39771
+ oxlintConfigPath,
39772
+ "--format",
39773
+ "json",
39774
+ ...sharedArgs
39775
+ ];
39776
+ const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
39777
+ const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
39778
+ const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
39779
+ if (files.length === 0) return {
39780
+ diagnostics: [],
39781
+ didDropReactHooksJsPlugin: false,
39782
+ hadPartialFailure: false
39783
+ };
39784
+ let hadPartialFailure = false;
39785
+ const reportPartialFailure = (reason) => {
39786
+ hadPartialFailure = true;
39787
+ onPartialFailure?.(reason);
39788
+ };
39789
+ const passConfigPath = Path.join(configDirectory, configFileName);
39790
+ const passBaseArgs = makeBaseArgs(passConfigPath);
39791
+ const passFileBatches = batchIncludePaths(passBaseArgs, files);
39792
+ const spawnPass = () => spawnLintBatches({
39793
+ baseArgs: passBaseArgs,
39794
+ fileBatches: passFileBatches,
39795
+ rootDirectory,
39796
+ nodeBinaryPath,
39797
+ project,
39798
+ onPartialFailure: reportPartialFailure,
39799
+ onFileProgress: fileProgress,
39800
+ spawnTimeoutMs,
39801
+ outputMaxBytes,
39802
+ concurrency: options.concurrency,
39803
+ signal: options.signal
39804
+ });
39805
+ writeOxlintConfig(passConfigPath, buildConfigForPass({}));
39806
+ try {
39807
+ return {
39808
+ diagnostics: await spawnPass(),
39809
+ didDropReactHooksJsPlugin: false,
39810
+ hadPartialFailure
39811
+ };
39812
+ } catch (error) {
39813
+ const reactHooksJsDropNote = reactHooksJsPluginDropNote(error);
39814
+ if (reactHooksJsDropNote === null) throw error;
39815
+ writeOxlintConfig(passConfigPath, buildConfigForPass({ disableReactHooksJsPlugin: true }));
39816
+ const diagnostics = await spawnPass();
39817
+ reportPartialFailure(reactHooksJsDropNote);
39818
+ return {
39819
+ diagnostics,
39820
+ didDropReactHooksJsPlugin: true,
39821
+ hadPartialFailure
39822
+ };
39823
+ }
39824
+ };
39825
+ if (perFileLintCacheEnabled && respectInlineDisables && !project.hasReactCompiler && extendsPaths.length === 0 && userPlugins.length === 0) {
39826
+ const rulesetHash = computeRulesetHash({
39827
+ config: buildConfig({
39828
+ extendsPaths: [],
39829
+ ruleSelection: "cacheable"
39830
+ }),
39831
+ toolchainVersions: resolveOxlintToolchainVersions(),
39832
+ ignorePatterns: combinedPatterns,
39833
+ tsconfigContent
39834
+ });
39835
+ const cache = createFileLintCache(resolveReactDoctorCacheDir(rootDirectory), rulesetHash);
39836
+ const cacheKeyByFile = /* @__PURE__ */ new Map();
39837
+ const missFiles = [];
39838
+ const replayedDiagnostics = [];
39839
+ for (const candidateFile of candidateFiles) {
39840
+ const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
39841
+ if (contentHash === null) {
39842
+ missFiles.push(candidateFile);
39843
+ continue;
39844
+ }
39845
+ const cacheKey = `${candidateFile.replaceAll("\\", "/")}${contentHash}`;
39846
+ cacheKeyByFile.set(candidateFile, cacheKey);
39847
+ const cachedDiagnostics = cache.lookup(cacheKey);
39848
+ if (cachedDiagnostics === null) missFiles.push(candidateFile);
39849
+ else replayedDiagnostics.push(...cachedDiagnostics);
39850
+ }
39851
+ const cacheHitFileCount = candidateFiles.length - missFiles.length;
39852
+ const cacheableResult = await runConfigOverFiles((overrides) => buildConfig({
39853
+ extendsPaths: [],
39854
+ ruleSelection: "cacheable",
39855
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
39856
+ }), "oxlintrc.cacheable.json", missFiles, void 0);
39857
+ const sidecarResult = await runConfigOverFiles(() => buildConfig({
39858
+ extendsPaths: [],
39859
+ ruleSelection: "sidecar"
39860
+ }), "oxlintrc.sidecar.json", candidateFiles, options.onFileProgress);
39861
+ onCacheStats?.(cacheHitFileCount, candidateFiles.length);
39862
+ const missFileByNormalizedPath = /* @__PURE__ */ new Map();
39863
+ for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
39864
+ const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
39865
+ let isAttributionSound = true;
39866
+ for (const diagnostic of cacheableResult.diagnostics) {
39867
+ const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
39868
+ if (missFile === void 0) {
39869
+ isAttributionSound = false;
39870
+ break;
39871
+ }
39872
+ const fileDiagnostics = freshDiagnosticsByFile.get(missFile) ?? [];
39873
+ fileDiagnostics.push(diagnostic);
39874
+ freshDiagnosticsByFile.set(missFile, fileDiagnostics);
39875
+ }
39876
+ if (!cacheableResult.didDropReactHooksJsPlugin && !cacheableResult.hadPartialFailure && isAttributionSound) {
39877
+ for (const missFile of missFiles) {
39878
+ const cacheKey = cacheKeyByFile.get(missFile);
39879
+ if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
39880
+ }
39881
+ cache.persist();
39882
+ }
39883
+ return dedupeDiagnostics([
39884
+ ...replayedDiagnostics,
39885
+ ...cacheableResult.diagnostics,
39886
+ ...sidecarResult.diagnostics
39887
+ ]);
39249
39888
  }
39250
- const fileBatches = batchIncludePaths(baseArgs, includePaths !== void 0 ? includePaths : listSourceFiles(rootDirectory));
39889
+ const baseArgs = makeBaseArgs(configPath);
39890
+ const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
39251
39891
  const runBatches = () => spawnLintBatches({
39252
39892
  baseArgs,
39253
39893
  fileBatches,
@@ -39258,7 +39898,8 @@ const runOxlint = async (options) => {
39258
39898
  onFileProgress: options.onFileProgress,
39259
39899
  spawnTimeoutMs,
39260
39900
  outputMaxBytes,
39261
- concurrency: options.concurrency
39901
+ concurrency: options.concurrency,
39902
+ signal: options.signal
39262
39903
  });
39263
39904
  writeOxlintConfig(configPath, buildConfig({ extendsPaths }));
39264
39905
  try {
@@ -39337,9 +39978,11 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
39337
39978
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
39338
39979
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
39339
39980
  const concurrency = yield* OxlintConcurrency;
39981
+ const lintBatchOrdering = yield* LintBatchOrdering;
39982
+ const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
39340
39983
  const collectedFailures = [];
39341
39984
  const diagnostics = yield* tryPromise({
39342
- try: () => runOxlint({
39985
+ try: (signal) => runOxlint({
39343
39986
  rootDirectory: input.rootDirectory,
39344
39987
  project: input.project,
39345
39988
  includePaths: input.includePaths ? [...input.includePaths] : void 0,
@@ -39354,9 +39997,13 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
39354
39997
  collectedFailures.push(reason);
39355
39998
  },
39356
39999
  onFileProgress: input.onFileProgress,
40000
+ perFileLintCacheEnabled,
40001
+ onCacheStats: input.onCacheStats,
39357
40002
  spawnTimeoutMs,
39358
40003
  outputMaxBytes,
39359
- concurrency
40004
+ concurrency,
40005
+ signal,
40006
+ lintBatchOrdering
39360
40007
  }),
39361
40008
  catch: ensureReactDoctorError
39362
40009
  });
@@ -39748,14 +40395,49 @@ const parseArtifactFromBody = (body) => {
39748
40395
  }
39749
40396
  return null;
39750
40397
  };
39751
- const fetchSocketArtifact = (dependency) => tryPromise(async (signal) => {
40398
+ const isSupplyChainCacheDisabled = () => {
40399
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
40400
+ return noCache === "1" || noCache === "true";
40401
+ };
40402
+ const supplyChainCacheFile = (cacheDirectory, dependency) => {
40403
+ const purlHash = crypto.createHash("sha256").update(toPurl(dependency)).digest("hex").slice(0, 16);
40404
+ return Path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR, `${purlHash}.json`);
40405
+ };
40406
+ const readCachedSocketBody = (cacheFile) => {
40407
+ try {
40408
+ const entry = JSON.parse(NFS.readFileSync(cacheFile, "utf-8"));
40409
+ 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;
40410
+ } catch {}
40411
+ return null;
40412
+ };
40413
+ const writeCachedSocketBody = (cacheFile, body) => {
40414
+ try {
40415
+ NFS.mkdirSync(Path.dirname(cacheFile), { recursive: true });
40416
+ NFS.writeFileSync(cacheFile, JSON.stringify({
40417
+ fetchedAtMs: Date.now(),
40418
+ body
40419
+ }));
40420
+ } catch {}
40421
+ };
40422
+ const fetchSocketArtifact = (dependency, cacheDirectory) => tryPromise(async (signal) => {
40423
+ const cacheFile = cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency);
40424
+ if (cacheFile !== null) {
40425
+ const cachedBody = readCachedSocketBody(cacheFile);
40426
+ if (cachedBody !== null) {
40427
+ const cachedArtifact = parseArtifactFromBody(cachedBody);
40428
+ if (cachedArtifact !== null) return cachedArtifact;
40429
+ }
40430
+ }
39752
40431
  const requestUrl = `${SOCKET_FREE_PURL_API_BASE}/${encodeURIComponent(toPurl(dependency))}`;
39753
40432
  const response = await fetch(requestUrl, {
39754
40433
  headers: { "User-Agent": SOCKET_FREE_USER_AGENT },
39755
40434
  signal
39756
40435
  });
39757
40436
  if (!response.ok) return null;
39758
- return parseArtifactFromBody(await response.text());
40437
+ const body = await response.text();
40438
+ const artifact = parseArtifactFromBody(body);
40439
+ if (artifact !== null && cacheFile !== null) writeCachedSocketBody(cacheFile, body);
40440
+ return artifact;
39759
40441
  }).pipe(timeout(FETCH_TIMEOUT_MS), orElseSucceed(() => null), tap$1((artifact) => {
39760
40442
  const scoreAttributes = {};
39761
40443
  if (artifact !== null) {
@@ -39860,7 +40542,8 @@ const checkSupplyChain = (input) => gen(function* () {
39860
40542
  const packageJsonPath = Path.join(input.rootDirectory, "package.json");
39861
40543
  const dependencies = collectDependenciesToScore(readPackageJson(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
39862
40544
  if (dependencies.length === 0) return [];
39863
- const artifacts = yield* forEach$1(dependencies, fetchSocketArtifact, { concurrency: 8 });
40545
+ const cacheDirectory = isSupplyChainCacheDisabled() ? null : resolveReactDoctorCacheDir(input.rootDirectory);
40546
+ const artifacts = yield* forEach$1(dependencies, (dependency) => fetchSocketArtifact(dependency, cacheDirectory), { concurrency: 8 }).pipe(timeoutOption(input.totalTimeoutMs ?? 9e4), map$3((maybeArtifacts) => getOrElse$1(maybeArtifacts, () => [])));
39864
40547
  const diagnostics = [];
39865
40548
  for (let index = 0; index < dependencies.length; index += 1) {
39866
40549
  const artifact = artifacts[index];
@@ -39885,6 +40568,10 @@ const checkSupplyChain = (input) => gen(function* () {
39885
40568
  * The underlying `checkSupplyChain` Effect is total/fail-open — per-package
39886
40569
  * timeouts and network failures recover to "skip" — so the stream never
39887
40570
  * fails, mirroring `DeadCode`'s stream shape so the two compose the same way.
40571
+ * The orchestrator (`run-inspect.ts`) consumes this stream on a background
40572
+ * fiber whose network time overlaps the lint pass, joined under a generous
40573
+ * wall-clock budget; a budget expiry is the same fail-open outcome as a Socket
40574
+ * outage.
39888
40575
  */
39889
40576
  var SupplyChain = class SupplyChain extends Service()("react-doctor/SupplyChain") {
39890
40577
  static layerNode = succeed$3(SupplyChain, SupplyChain.of({ run: (input) => unwrap(checkSupplyChain(input).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)), withSpan("SupplyChain.run"))) }));
@@ -39943,18 +40630,42 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
39943
40630
  *
39944
40631
  * Phases:
39945
40632
  *
39946
- * 1. Config.resolve(directory) → Project.discover → Git metadata
40633
+ * 1. Config.resolve(directory) → Project.discover → Git metadata.
40634
+ * The GitHub viewer-permission lookup is forked onto a background
40635
+ * fiber here and joined late (it feeds score metadata, not
40636
+ * diagnostics).
39947
40637
  * 2. beforeLint hook (e.g. CLI renders the project-detection block)
39948
40638
  * 3. environment checks (reduced-motion + pnpm hardening +
39949
- * expo/react-native + security scan)
39950
- * 4. Linter.run + DeadCode.run forked as concurrent fibers so
39951
- * their wall-clock times overlap. Progress spinners stay
39952
- * sequential (lint first, then dead-code) for clean terminal
39953
- * output. GitHub viewer permission also runs as a background
39954
- * fiber during this phase.
39955
- * 5. afterLint hook
39956
- * 6. Reporter.finalize
39957
- * 7. Score.compute against the surface-filtered diagnostic set
40639
+ * expo/react-native + security scan), collected synchronously
40640
+ * 4. The supply-chain check (Socket.dev) is forked onto a background
40641
+ * fiber so its ~100% network-bound time overlaps the ~100%
40642
+ * CPU/subprocess-bound lint pass below, collapsing two serial
40643
+ * phases into roughly `max(supplyChain, lint)`. It is capped by
40644
+ * `SupplyChainOverlapTimeoutMs` (measured from fork) so a hung
40645
+ * socket can't drag out its join; on timeout it fails open to no
40646
+ * diagnostics — the same outcome class as a Socket outage.
40647
+ * 5. Linter.run runs; DeadCode.run runs concurrently (forked child
40648
+ * fiber) ONLY when the memory gate has headroom to run the 8 GB
40649
+ * dead-code child alongside the oxlint workers — or when overlap is
40650
+ * forced via REACT_DOCTOR_DEAD_CODE_OVERLAP. Otherwise dead-code
40651
+ * runs sequentially after lint, exactly as it did pre-overlap. The
40652
+ * fiber is joined (or interrupted, SIGKILLing its worker, on lint
40653
+ * failure) before diagnostics are concatenated. The afterLint hook
40654
+ * fires between lint and dead-code. Progress spinner labels AND the
40655
+ * final diagnostic / score order stay independent of execution
40656
+ * order, so terminal output is identical either way; supply-chain
40657
+ * rides alongside without a spinner.
40658
+ * 6. Join the supply-chain fiber, then assemble the diagnostics in a
40659
+ * FIXED order (env, supply-chain, lint, dead-code) so the output is
40660
+ * byte-identical regardless of which fiber settled first. The
40661
+ * viewer-permission fiber is joined later, during score-metadata
40662
+ * assembly (it feeds score metadata, not diagnostics). The per-element
40663
+ * `Reporter.emit` side-channel now interleaves supply-chain with lint
40664
+ * emits, so capture-order assertions must target the deterministic
40665
+ * concat below, not emit order (production `Reporter.layerNoop` makes
40666
+ * emit a no-op).
40667
+ * 7. Reporter.finalize
40668
+ * 8. Score.compute against the surface-filtered diagnostic set
39958
40669
  *
39959
40670
  * The orchestrator owns spinner lifecycle via `Progress`; callers
39960
40671
  * choose `Progress.layerOra(...)` for CLI feedback or
@@ -40012,10 +40723,21 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40012
40723
  ignoredTags: input.ignoredTags
40013
40724
  })
40014
40725
  ])));
40015
- const supplyChainCollected = !isDiffMode || (input.supplyChainManifestChanged ?? false) ? yield* runCollect(applyPerElementPipeline(supplyChainService.run({
40726
+ const shouldRunSupplyChain = !isDiffMode || (input.supplyChainManifestChanged ?? false);
40727
+ const supplyChainOverlapTimeout = yield* SupplyChainOverlapTimeoutMs;
40728
+ const supplyChainFiber = yield* forkChild(shouldRunSupplyChain ? runCollect(applyPerElementPipeline(supplyChainService.run({
40016
40729
  rootDirectory: scanDirectory,
40017
40730
  userConfig: resolvedConfig.config
40018
- }))) : [];
40731
+ }))).pipe(map$3((diagnostics) => ({
40732
+ diagnostics,
40733
+ timedOut: false
40734
+ })), timeout(supplyChainOverlapTimeout), orElseSucceed(() => ({
40735
+ diagnostics: [],
40736
+ timedOut: true
40737
+ }))) : succeed$2({
40738
+ diagnostics: [],
40739
+ timedOut: false
40740
+ }));
40019
40741
  const lintFailure = yield* make$13({
40020
40742
  didFail: false,
40021
40743
  reason: null,
@@ -40026,12 +40748,49 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40026
40748
  didFail: false,
40027
40749
  reason: null
40028
40750
  });
40029
- const scanConcurrency = yield* OxlintConcurrency;
40751
+ const scanConcurrency = resolveScanConcurrency(yield* OxlintConcurrency);
40752
+ const lintPhaseTimeoutMs = yield* LintPhaseTimeoutMs;
40753
+ const deadCodePhaseTimeoutMs = yield* DeadCodePhaseTimeoutMs;
40754
+ const resolveDeadCodePhaseTimeoutMs = (scaledPhaseTimeoutMs) => deadCodePhaseTimeoutMs === 15e4 ? scaledPhaseTimeoutMs : deadCodePhaseTimeoutMs;
40030
40755
  const workerCountSuffix = scanConcurrency > 1 ? ` ${highlighter.dim(`[~${scanConcurrency} workers]`)}` : "";
40756
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
40757
+ const deadCodeOverlapMode = yield* DeadCodeOverlap;
40758
+ const shouldOverlapDeadCode = shouldRunDeadCode && deadCodeOverlapMode === "on";
40759
+ const deadCodeParseConcurrency = shouldOverlapDeadCode ? Math.max(1, Math.floor(scanConcurrency * DEAD_CODE_OVERLAP_PARSE_SHARE)) : void 0;
40760
+ const lintConcurrency = deadCodeParseConcurrency === void 0 ? scanConcurrency : Math.max(1, scanConcurrency - deadCodeParseConcurrency);
40761
+ const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
40762
+ rootDirectory: scanDirectory,
40763
+ userConfig: resolvedConfig.config,
40764
+ parseConcurrency: deadCodeParseConcurrency,
40765
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
40766
+ }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
40767
+ yield* set(deadCodeFailure, {
40768
+ didFail: true,
40769
+ reason: error.message
40770
+ });
40771
+ return empty$4;
40772
+ })))))).pipe(timeoutOption(deadCodeTimeout.phaseTimeoutMs), flatMap$2(match$3({
40773
+ onNone: () => set(deadCodeFailure, {
40774
+ didFail: true,
40775
+ reason: `Dead-code analysis exceeded ${Math.round(deadCodeTimeout.phaseTimeoutMs / MILLISECONDS_PER_SECOND)}s and was skipped.`
40776
+ }).pipe(as([])),
40777
+ onSome: succeed$2
40778
+ })));
40779
+ const overlapDeadCodeTimeout = resolveDeadCodeTimeout({
40780
+ sourceFileCount: project.sourceFileCount,
40781
+ deadCodeConcurrency: deadCodeParseConcurrency ?? scanConcurrency,
40782
+ fullConcurrency: scanConcurrency
40783
+ });
40784
+ const deadCodeFiber = shouldOverlapDeadCode ? yield* forkChild(buildCollectDeadCode({
40785
+ workerTimeoutMs: overlapDeadCodeTimeout.workerTimeoutMs,
40786
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(overlapDeadCodeTimeout.phaseTimeoutMs)
40787
+ })) : null;
40031
40788
  const scanProgress = yield* progressService.start("Scanning...");
40032
40789
  const scanStartTime = Date.now();
40033
40790
  let lastReportedTotalFileCount = 0;
40034
- const lintCollected = yield* runCollect(applyPerElementPipeline(linterService.run({
40791
+ let lintCacheHitFileCount = null;
40792
+ let lintCacheTotalFileCount = null;
40793
+ const baseLintStream = linterService.run({
40035
40794
  rootDirectory: scanDirectory,
40036
40795
  project,
40037
40796
  includePaths: lintIncludePaths ?? void 0,
@@ -40045,6 +40804,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40045
40804
  onFileProgress: (scannedFileCount, totalFileCount) => {
40046
40805
  lastReportedTotalFileCount = totalFileCount;
40047
40806
  runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
40807
+ },
40808
+ onCacheStats: (cacheHitFileCount, totalConsideredFileCount) => {
40809
+ lintCacheHitFileCount = cacheHitFileCount;
40810
+ lintCacheTotalFileCount = totalConsideredFileCount;
40048
40811
  }
40049
40812
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
40050
40813
  yield* set(lintFailure, {
@@ -40054,36 +40817,54 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40054
40817
  reasonKind: error.reason._tag === "OxlintUnavailable" ? error.reason.kind : null
40055
40818
  });
40056
40819
  return empty$4;
40057
- }))))));
40820
+ }))));
40821
+ const lintCollected = yield* runCollect(applyPerElementPipeline(shouldOverlapDeadCode ? baseLintStream.pipe(provideService(OxlintConcurrency, lintConcurrency)) : baseLintStream)).pipe(timeoutOption(lintPhaseTimeoutMs), flatMap$2(match$3({
40822
+ onNone: () => set(lintFailure, {
40823
+ didFail: true,
40824
+ reason: `Lint analysis exceeded ${lintPhaseTimeoutMs / MILLISECONDS_PER_SECOND}s and was skipped.`,
40825
+ reasonTag: "OxlintBatchExceeded",
40826
+ reasonKind: null
40827
+ }).pipe(as([])),
40828
+ onSome: succeed$2
40829
+ })));
40058
40830
  const lintFailureState = yield* get$2(lintFailure);
40059
40831
  yield* afterLint(lintFailureState.didFail);
40060
40832
  if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
40061
40833
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
40062
40834
  const scannedFilesLabel = `${totalFileCount} ${totalFileCount === 1 ? "file" : "files"}`;
40063
- const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
40064
- const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`).pipe(andThen(runCollect(applyPerElementPipeline(deadCodeService.run({
40065
- rootDirectory: scanDirectory,
40066
- userConfig: resolvedConfig.config
40067
- }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
40068
- yield* set(deadCodeFailure, {
40069
- didFail: true,
40070
- reason: error.message
40835
+ let deadCodeCollected = [];
40836
+ if (lintFailureState.didFail) {
40837
+ if (deadCodeFiber !== null) yield* interrupt(deadCodeFiber);
40838
+ } else if (shouldRunDeadCode) {
40839
+ yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`);
40840
+ const sequentialDeadCodeTimeout = resolveDeadCodeTimeout({
40841
+ sourceFileCount: totalFileCount,
40842
+ deadCodeConcurrency: scanConcurrency,
40843
+ fullConcurrency: scanConcurrency
40071
40844
  });
40072
- return empty$4;
40073
- }))))))));
40074
- const deadCodeFailureState = yield* get$2(deadCodeFailure);
40845
+ deadCodeCollected = deadCodeFiber !== null ? yield* join(deadCodeFiber) : yield* buildCollectDeadCode({
40846
+ workerTimeoutMs: sequentialDeadCodeTimeout.workerTimeoutMs,
40847
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(sequentialDeadCodeTimeout.phaseTimeoutMs)
40848
+ });
40849
+ }
40850
+ const deadCodeFailureState = lintFailureState.didFail ? {
40851
+ didFail: false,
40852
+ reason: null
40853
+ } : yield* get$2(deadCodeFailure);
40075
40854
  const scanElapsedMilliseconds = Date.now() - scanStartTime;
40076
40855
  const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1);
40077
40856
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
40078
40857
  else if (input.suppressScanSummary) yield* scanProgress.stop();
40079
40858
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
40859
+ const supplyChainResult = yield* join(supplyChainFiber);
40860
+ const supplyChainCollected = supplyChainResult.diagnostics;
40080
40861
  yield* reporterService.finalize;
40081
- const finalDiagnostics = assignFixGroups([
40862
+ const finalDiagnostics = sortDiagnosticsStable(assignFixGroups([
40082
40863
  ...envCollected,
40083
40864
  ...supplyChainCollected,
40084
40865
  ...lintCollected,
40085
40866
  ...deadCodeCollected
40086
- ]);
40867
+ ]));
40087
40868
  const githubViewerPermission = yield* join(githubViewerPermissionFiber);
40088
40869
  const scoreMetadata = {
40089
40870
  ...repo !== null ? { repo } : {},
@@ -40119,9 +40900,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40119
40900
  lintPartialFailures,
40120
40901
  didDeadCodeFail: deadCodeFailureState.didFail,
40121
40902
  deadCodeFailureReason: deadCodeFailureState.reason,
40903
+ deadCodeOverlapped: shouldOverlapDeadCode,
40122
40904
  scannedFileCount: totalFileCount,
40123
40905
  scannedFilePaths,
40124
- scanElapsedMilliseconds
40906
+ scanElapsedMilliseconds,
40907
+ scanConcurrency,
40908
+ supplyChainOverlapTimedOut: supplyChainResult.timedOut,
40909
+ lintCacheHitFileCount,
40910
+ lintCacheTotalFileCount
40125
40911
  };
40126
40912
  }).pipe(withSpan("runInspect", { attributes: {
40127
40913
  "inspect.directory": input.directory,
@@ -40129,7 +40915,7 @@ const runInspect = (input, hooks = {}) => gen(function* () {
40129
40915
  "inspect.runDeadCode": input.runDeadCode,
40130
40916
  "inspect.isCi": input.isCi,
40131
40917
  "inspect.scoreSurface": input.scoreSurface ?? "score"
40132
- } }));
40918
+ } }), (scanProgram) => flatMap$2(ScanDeadlineMs, (scanDeadlineMs) => scanProgram.pipe(timeout(scanDeadlineMs), catchTag$1("TimeoutError", () => new ReactDoctorError({ reason: new ScanDeadlineExceeded({ detail: `${scanDeadlineMs / MILLISECONDS_PER_SECOND}s elapsed` }) })))));
40133
40919
  const parseNodeVersion = (versionString) => {
40134
40920
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
40135
40921
  return {
@@ -40673,6 +41459,7 @@ const clearCaches = () => {
40673
41459
  clearIgnorePatternsCache();
40674
41460
  clearPackageRoleCache();
40675
41461
  clearAutoSuppressionCaches();
41462
+ clearMinifiedFileCache();
40676
41463
  };
40677
41464
  const toJsonReport = (result, options) => buildJsonReport({
40678
41465
  version: options.version,
@@ -40696,4 +41483,4 @@ const toJsonReport = (result, options) => buildJsonReport({
40696
41483
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
40697
41484
 
40698
41485
  //# sourceMappingURL=index.js.map
40699
- //# debugId=03972752-ed17-5a0b-a633-71b652d2f457
41486
+ //# debugId=dccb6d63-63d4-5e91-af21-2756a08fe93a