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/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="80f0c020-6340-5bba-86a5-7cdbaecdf52d")}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]="6c903b87-8faf-5702-b078-03465e326385")}catch(e){}}();
3
3
  import { createRequire } from "node:module";
4
4
  import * as NodeChildProcess from "node:child_process";
5
5
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
@@ -9,7 +9,7 @@ import * as NFS from "node:fs";
9
9
  import fs, { mkdtempSync, rmSync } from "node:fs";
10
10
  import process$1 from "node: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, { tmpdir } from "node:os";
15
15
  import { parseJSON5 } from "confbox";
@@ -26,7 +26,7 @@ import tty from "node:tty";
26
26
  import { codeFrameColumns } from "@babel/code-frame";
27
27
  import Conf from "conf";
28
28
  import basePrompts from "prompts";
29
- import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
29
+ import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource, isSkillAgentType } from "agent-install";
30
30
  import { generateCode, loadFile, writeFile } from "magicast";
31
31
  import { getConfigFromVariableDeclaration, getDefaultExportOptions } from "magicast/helpers";
32
32
  //#region \0rolldown/runtime.js
@@ -10277,7 +10277,7 @@ const provideContext$1 = /* @__PURE__ */ dual(2, (self, context) => {
10277
10277
  return updateContext$1(self, merge$3(context));
10278
10278
  });
10279
10279
  /** @internal */
10280
- const provideService$1 = function() {
10280
+ const provideService$3 = function() {
10281
10281
  if (arguments.length === 1) return dual(2, (self, impl) => provideServiceImpl(self, arguments[0], impl));
10282
10282
  return dual(3, (self, service, impl) => provideServiceImpl(self, service, impl)).apply(this, arguments);
10283
10283
  };
@@ -10508,7 +10508,7 @@ const constScopeEmpty = { _tag: "Empty" };
10508
10508
  /** @internal */
10509
10509
  const scope = scopeTag;
10510
10510
  /** @internal */
10511
- const provideScope = /* @__PURE__ */ provideService$1(scopeTag);
10511
+ const provideScope = /* @__PURE__ */ provideService$3(scopeTag);
10512
10512
  /** @internal */
10513
10513
  const scoped$1 = (self) => withFiber$1((fiber) => {
10514
10514
  const prev = fiber.context;
@@ -10949,9 +10949,9 @@ const makeLatchUnsafe = (open) => new Latch(open ?? false);
10949
10949
  /** @internal */
10950
10950
  const makeLatch = (open) => sync$2(() => makeLatchUnsafe(open));
10951
10951
  /** @internal */
10952
- const withTracer$1 = /* @__PURE__ */ dual(2, (effect, tracer) => provideService$1(effect, Tracer, tracer));
10952
+ const withTracer$1 = /* @__PURE__ */ dual(2, (effect, tracer) => provideService$3(effect, Tracer, tracer));
10953
10953
  /** @internal */
10954
- const withTracerEnabled$1 = /* @__PURE__ */ provideService$1(TracerEnabled);
10954
+ const withTracerEnabled$1 = /* @__PURE__ */ provideService$3(TracerEnabled);
10955
10955
  const bigint0 = /* @__PURE__ */ BigInt(0);
10956
10956
  const NoopSpanProto = {
10957
10957
  _tag: "Span",
@@ -11032,7 +11032,7 @@ const useSpan$1 = (name, ...args) => {
11032
11032
  }));
11033
11033
  });
11034
11034
  };
11035
- const provideParentSpan = /* @__PURE__ */ provideService$1(ParentSpan);
11035
+ const provideParentSpan = /* @__PURE__ */ provideService$3(ParentSpan);
11036
11036
  /** @internal */
11037
11037
  const withParentSpan$1 = function() {
11038
11038
  const dataFirst = isEffect$1(arguments[0]);
@@ -12599,7 +12599,7 @@ var CurrentMemoMap = class extends Service()("effect/Layer/CurrentMemoMap") {
12599
12599
  * @category memo map
12600
12600
  * @since 2.0.0
12601
12601
  */
12602
- const buildWithMemoMap = /* @__PURE__ */ dual(3, (self, memoMap, scope) => provideService$1(map$4(self.build(memoMap, scope), add(CurrentMemoMap, memoMap)), CurrentMemoMap, memoMap));
12602
+ const buildWithMemoMap = /* @__PURE__ */ dual(3, (self, memoMap, scope) => provideService$3(map$4(self.build(memoMap, scope), add(CurrentMemoMap, memoMap)), CurrentMemoMap, memoMap));
12603
12603
  /**
12604
12604
  * Builds a layer into an `Effect` value. Any resources associated with this
12605
12605
  * layer will be released when the specified scope is closed unless their scope
@@ -13952,7 +13952,7 @@ const provide$1 = /* @__PURE__ */ dual((args) => isEffect$1(args[0]), (self, sou
13952
13952
  /** @internal */
13953
13953
  const repeatOrElse = /* @__PURE__ */ dual(3, (self, schedule, orElse) => flatMap$4(toStepWithMetadata(schedule), (step) => {
13954
13954
  let meta = CurrentMetadata.defaultValue();
13955
- return catch_$2(forever$2(tap$2(flatMap$4(suspend$3(() => provideService$1(self, CurrentMetadata, meta)), step), (meta_) => sync$2(() => {
13955
+ return catch_$2(forever$2(tap$2(flatMap$4(suspend$3(() => provideService$3(self, CurrentMetadata, meta)), step), (meta_) => sync$2(() => {
13956
13956
  meta = meta_;
13957
13957
  })), { disableYield: true }), (error) => isDone$2(error) ? succeed$5(error.value) : orElse(error, meta.attempt === 0 ? none() : some(meta)));
13958
13958
  }));
@@ -13960,7 +13960,7 @@ const repeatOrElse = /* @__PURE__ */ dual(3, (self, schedule, orElse) => flatMap
13960
13960
  const retryOrElse = /* @__PURE__ */ dual(3, (self, policy, orElse) => flatMap$4(toStepWithMetadata(policy), (step) => {
13961
13961
  let meta = CurrentMetadata.defaultValue();
13962
13962
  let lastError;
13963
- const loop = catch_$2(suspend$3(() => provideService$1(self, CurrentMetadata, meta)), (error) => {
13963
+ const loop = catch_$2(suspend$3(() => provideService$3(self, CurrentMetadata, meta)), (error) => {
13964
13964
  lastError = error;
13965
13965
  return flatMap$4(step(error), (meta_) => {
13966
13966
  meta = meta_;
@@ -16076,7 +16076,7 @@ const updateContext = updateContext$1;
16076
16076
  * @category Context
16077
16077
  * @since 2.0.0
16078
16078
  */
16079
- const provideService = provideService$1;
16079
+ const provideService$2 = provideService$3;
16080
16080
  /**
16081
16081
  * Scopes all resources used in this workflow to the lifetime of the workflow,
16082
16082
  * ensuring that their finalizers are run as soon as this workflow completes
@@ -21091,6 +21091,20 @@ function decodeUnknownOption$1(schema, options) {
21091
21091
  return asOption(decodeUnknownEffect(schema, options));
21092
21092
  }
21093
21093
  /**
21094
+ * Creates a synchronous decoder for `unknown` input.
21095
+ *
21096
+ * **Details**
21097
+ *
21098
+ * The returned function returns the decoded `Type` on success and throws an
21099
+ * `Error` with the `SchemaIssue.Issue` in its `cause` on decoding failure.
21100
+ *
21101
+ * @category decoding
21102
+ * @since 3.10.0
21103
+ */
21104
+ function decodeUnknownSync$1(schema, options) {
21105
+ return asSync(decodeUnknownEffect(schema, options));
21106
+ }
21107
+ /**
21094
21108
  * Creates an effectful encoder for `unknown` input.
21095
21109
  *
21096
21110
  * **Details**
@@ -21392,6 +21406,40 @@ function isSchemaError(u) {
21392
21406
  */
21393
21407
  const decodeUnknownOption = decodeUnknownOption$1;
21394
21408
  /**
21409
+ * Decodes an `unknown` input against a schema synchronously, returning the
21410
+ * decoded value or throwing an `Error` whose cause contains the schema issue.
21411
+ * Use this when you want to validate data at a boundary and treat a schema
21412
+ * mismatch as an exception. For typed input use `decodeSync`.
21413
+ *
21414
+ * **Details**
21415
+ *
21416
+ * Only service-free schemas can be decoded synchronously. For non-throwing
21417
+ * alternatives see `decodeUnknownOption`, `decodeUnknownExit`, or
21418
+ * `decodeUnknownEffect`. Options may be provided either when creating the
21419
+ * decoder or when applying it; application options override creation options.
21420
+ *
21421
+ * **Example** (Decoding with a transformation schema)
21422
+ *
21423
+ * ```ts
21424
+ * import { Schema } from "effect"
21425
+ *
21426
+ * const NumberFromString = Schema.NumberFromString
21427
+ *
21428
+ * console.log(Schema.decodeUnknownSync(NumberFromString)("42"))
21429
+ * // Output: 42
21430
+ *
21431
+ * Schema.decodeUnknownSync(NumberFromString)("not a number")
21432
+ * // throws SchemaError: NumberFromString
21433
+ * // └─ Encoded side transformation failure
21434
+ * // └─ NumberFromString
21435
+ * // └─ Expected a numeric string, actual "not a number"
21436
+ * ```
21437
+ *
21438
+ * @category decoding
21439
+ * @since 4.0.0
21440
+ */
21441
+ const decodeUnknownSync = decodeUnknownSync$1;
21442
+ /**
21395
21443
  * Encodes an `unknown` input against a schema synchronously, throwing a
21396
21444
  * {@link SchemaError} on failure. Use this when you want to serialize data at a
21397
21445
  * boundary and treat a schema mismatch as an unrecoverable error. For
@@ -28271,6 +28319,14 @@ const runWith = (self, f, onHalt) => suspend$2(() => {
28271
28319
  return catchDone(flatMap$2(toTransform(self)(done$1(), scope), f), onHalt ? onHalt : succeed$2).pipe(onExit$2((exit) => close(scope, exit)));
28272
28320
  });
28273
28321
  /**
28322
+ * Provides a concrete service for a context key, removing that service
28323
+ * requirement from the returned channel.
28324
+ *
28325
+ * @category services
28326
+ * @since 2.0.0
28327
+ */
28328
+ 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))));
28329
+ /**
28274
28330
  * Runs a channel and applies an effect to each output element.
28275
28331
  *
28276
28332
  * **Example** (Running effects for each output)
@@ -29659,6 +29715,44 @@ const splitLines = (self) => self.channel.pipe(pipeTo(splitLines$1()), fromChann
29659
29715
  */
29660
29716
  const ensuring = /* @__PURE__ */ dual(2, (self, finalizer) => fromChannel(ensuring$1(self.channel, finalizer)));
29661
29717
  /**
29718
+ * Provides the stream with a single required service, eliminating that
29719
+ * requirement from its environment.
29720
+ *
29721
+ * **Example** (Providing a stream service)
29722
+ *
29723
+ * ```ts
29724
+ * import { Console, Context, Effect, Stream } from "effect"
29725
+ *
29726
+ * class Greeter extends Context.Service<Greeter, {
29727
+ * greet: (name: string) => string
29728
+ * }>()("Greeter") {}
29729
+ *
29730
+ * const stream = Stream.fromEffect(
29731
+ * Effect.service(Greeter).pipe(
29732
+ * Effect.map((greeter) => greeter.greet("Ada"))
29733
+ * )
29734
+ * )
29735
+ *
29736
+ * const program = Effect.gen(function*() {
29737
+ * const collected = yield* Stream.runCollect(
29738
+ * stream.pipe(
29739
+ * Stream.provideService(Greeter, {
29740
+ * greet: (name) => `Hello, ${name}`
29741
+ * })
29742
+ * )
29743
+ * )
29744
+ * yield* Console.log(collected)
29745
+ * })
29746
+ *
29747
+ * Effect.runPromise(program)
29748
+ * //=> ["Hello, Ada"]
29749
+ * ```
29750
+ *
29751
+ * @category services
29752
+ * @since 2.0.0
29753
+ */
29754
+ const provideService = /* @__PURE__ */ dual(3, (self, key, service) => fromChannel(provideService$1(self.channel, key, service)));
29755
+ /**
29662
29756
  * Runs a stream with a sink and returns the sink result.
29663
29757
  *
29664
29758
  * **Example** (Running a stream with a sink)
@@ -33088,7 +33182,7 @@ const make$8 = /* @__PURE__ */ fnUntraced(function* (options) {
33088
33182
  const runFork = runForkWith(services);
33089
33183
  const exportInterval = max(fromInputUnsafe(options.exportInterval), zero);
33090
33184
  let disabledUntil = void 0;
33091
- const client = filterStatusOk(get$4(services, HttpClient)).pipe(transformResponse(provideService(TracerPropagationEnabled, false)), retryTransient({
33185
+ const client = filterStatusOk(get$4(services, HttpClient)).pipe(transformResponse(provideService$2(TracerPropagationEnabled, false)), retryTransient({
33092
33186
  schedule: policy,
33093
33187
  times: 3
33094
33188
  }));
@@ -35885,15 +35979,21 @@ const isMinifiedSource = (absolutePath) => {
35885
35979
  if (fileDescriptor !== void 0) NFS.closeSync(fileDescriptor);
35886
35980
  }
35887
35981
  };
35888
- const isLargeMinifiedFile = (absolutePath) => {
35889
- let sizeBytes;
35982
+ const cachedIsLargeMinifiedByPath = /* @__PURE__ */ new Map();
35983
+ const statSourceFileSize = (absolutePath) => {
35890
35984
  try {
35891
- sizeBytes = NFS.statSync(absolutePath).size;
35985
+ return NFS.statSync(absolutePath).size;
35892
35986
  } catch {
35893
- return false;
35987
+ return null;
35894
35988
  }
35895
- if (sizeBytes < 2e4) return false;
35896
- return isMinifiedSource(absolutePath);
35989
+ };
35990
+ const isLargeMinifiedFile = (absolutePath, knownSizeBytes) => {
35991
+ const cached = cachedIsLargeMinifiedByPath.get(absolutePath);
35992
+ if (cached !== void 0) return cached;
35993
+ const sizeBytes = knownSizeBytes === void 0 ? statSourceFileSize(absolutePath) : knownSizeBytes;
35994
+ const result = sizeBytes !== null && sizeBytes >= 2e4 && isMinifiedSource(absolutePath);
35995
+ cachedIsLargeMinifiedByPath.set(absolutePath, result);
35996
+ return result;
35897
35997
  };
35898
35998
  const isErrnoException = (error) => error instanceof Error && "code" in error;
35899
35999
  const IGNORABLE_READDIR_ERROR_CODES = new Set([
@@ -36762,6 +36862,8 @@ const DOCS_URL = "https://react.doctor/docs";
36762
36862
  const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
36763
36863
  const FETCH_TIMEOUT_MS = 1e4;
36764
36864
  const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
36865
+ const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
36866
+ const PER_WORKER_MEM_BUDGET_BYTES = 1024 * 1024 * 1024;
36765
36867
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
36766
36868
  const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
36767
36869
  const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
@@ -36838,7 +36940,16 @@ const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
36838
36940
  const SKILL_NAME = "react-doctor";
36839
36941
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
36840
36942
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
36943
+ const NODE_COMPILE_CACHE_DIR_NAME = "node-compile-cache";
36944
+ const DEAD_CODE_WORKER_TIMEOUT_MS = 12e4;
36945
+ const OXLINT_SPLIT_TOTAL_BUDGET_MS = 18e4;
36946
+ const DEAD_CODE_PHASE_TIMEOUT_MS = 15e4;
36947
+ const LINT_PHASE_TIMEOUT_MS = 3e5;
36948
+ const SCAN_TOTAL_DEADLINE_MS = 9e5;
36841
36949
  const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192;
36950
+ const DEAD_CODE_TIMEOUT_CEILING_MS = 6e5;
36951
+ const DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS = 3e4;
36952
+ const DEAD_CODE_OVERLAP_PARSE_SHARE = .4;
36842
36953
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
36843
36954
  const REACT_SERVER_DOM_PACKAGES = [
36844
36955
  "react-server-dom-webpack",
@@ -36873,9 +36984,13 @@ const CONFIG_CACHE_TTL_MS = 300 * 1e3;
36873
36984
  const SOCKET_FREE_PURL_API_BASE = "https://firewall-api.socket.dev/purl";
36874
36985
  const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
36875
36986
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
36987
+ const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
36988
+ const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
36876
36989
  const SUPPLY_CHAIN_PLUGIN = "socket";
36877
36990
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
36878
36991
  const SUPPLY_CHAIN_CATEGORY = "Security";
36992
+ const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
36993
+ const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
36879
36994
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
36880
36995
  const TSCONFIG_FILENAME = "tsconfig.json";
36881
36996
  const isRelativeExtendsValue = (extendsValue) => extendsValue.startsWith("./") || extendsValue.startsWith("../") || Path.isAbsolute(extendsValue);
@@ -37565,7 +37680,10 @@ for (const [legacyRuleKey, nativeRuleKey] of Object.entries(LEGACY_RULE_KEY_TO_N
37565
37680
  NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.set(nativeRuleKey, aliases);
37566
37681
  }
37567
37682
  const getLegacyRuleKeysForNative = (ruleKey) => NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.get(ruleKey) ?? [];
37568
- const canonicalizeRuleKey = (ruleKey) => LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey] ?? ruleKey;
37683
+ const canonicalizeRuleKey = (ruleKey) => {
37684
+ const nativeRuleKey = LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey];
37685
+ return typeof nativeRuleKey === "string" ? nativeRuleKey : ruleKey;
37686
+ };
37569
37687
  const isReactDoctorShortIdOf = (bareRuleKey, qualifiedRuleKey) => !bareRuleKey.includes("/") && qualifiedRuleKey === `react-doctor/${bareRuleKey}`;
37570
37688
  const isSameRuleKey = (candidateRuleKey, targetRuleKey) => {
37571
37689
  const canonicalCandidate = canonicalizeRuleKey(candidateRuleKey);
@@ -38227,6 +38345,11 @@ var OxlintBatchExceeded = class extends TaggedErrorClass()("OxlintBatchExceeded"
38227
38345
  }
38228
38346
  }
38229
38347
  };
38348
+ var ScanDeadlineExceeded = class extends TaggedErrorClass()("ScanDeadlineExceeded", { detail: String$1 }) {
38349
+ get message() {
38350
+ return `Scan exceeded its overall time budget: ${this.detail}`;
38351
+ }
38352
+ };
38230
38353
  var OxlintSpawnFailed = class extends TaggedErrorClass()("OxlintSpawnFailed", { cause: Unknown }) {
38231
38354
  get message() {
38232
38355
  return `Failed to run oxlint: ${pretty(fail$6(this.cause))}`;
@@ -38290,6 +38413,7 @@ var GitBaseBranchInvalid = class extends TaggedErrorClass()("GitBaseBranchInvali
38290
38413
  const ReactDoctorErrorReason = Union([
38291
38414
  OxlintUnavailable,
38292
38415
  OxlintBatchExceeded,
38416
+ ScanDeadlineExceeded,
38293
38417
  OxlintSpawnFailed,
38294
38418
  OxlintOutputUnparseable,
38295
38419
  ConfigParseFailed,
@@ -38362,15 +38486,105 @@ const layerOtlp = unwrap$3(gen(function* () {
38362
38486
  }).pipe(provide$2(layer$9));
38363
38487
  }).pipe(orDie));
38364
38488
  /**
38365
- * Resolves a requested lint worker count to a clamped integer within
38366
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
38367
- * machine's CPU cores; out-of-range or non-finite requests degrade to
38489
+ * Read a positive-millisecond timeout from an env var, falling back to
38490
+ * `defaultMs` when the var is unset, non-finite, or not strictly positive.
38491
+ */
38492
+ const readPositiveEnvMs = (envVarName, defaultMs) => {
38493
+ const rawValue = process.env[envVarName];
38494
+ if (rawValue === void 0) return defaultMs;
38495
+ const parsedValue = Number(rawValue);
38496
+ if (!Number.isFinite(parsedValue) || parsedValue <= 0) return defaultMs;
38497
+ return parsedValue;
38498
+ };
38499
+ const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
38500
+ const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
38501
+ const CGROUP_UNLIMITED_SENTINEL_BYTES = Number.MAX_SAFE_INTEGER;
38502
+ /**
38503
+ * Parses one raw cgroup memory-limit file value into a positive byte count, or
38504
+ * `undefined` when it represents "no limit" (the v2 `"max"` literal, an empty
38505
+ * read, a non-positive / non-finite value, or v1's near-2^63 unlimited
38506
+ * sentinel). Pure and exported so the classification is unit-testable without
38507
+ * touching the filesystem.
38508
+ */
38509
+ const parseCgroupMemoryLimitBytes = (raw) => {
38510
+ if (raw === void 0) return void 0;
38511
+ const trimmed = raw.trim();
38512
+ if (trimmed === "" || trimmed === "max") return void 0;
38513
+ const parsed = Number(trimmed);
38514
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= CGROUP_UNLIMITED_SENTINEL_BYTES) return;
38515
+ return parsed;
38516
+ };
38517
+ const CGROUP_MEMORY_LIMIT_PATHS = [CGROUP_V2_MEMORY_MAX_PATH, CGROUP_V1_MEMORY_LIMIT_PATH];
38518
+ /**
38519
+ * Reads this process's cgroup memory limit in bytes from the first candidate
38520
+ * path that yields a real limit, or `undefined` when none does — no cgroup, no
38521
+ * limit, or the files are unreadable (e.g. macOS / Windows dev machines).
38522
+ * `os.totalmem()` reports the HOST total and ignores cgroup memory limits, so a
38523
+ * memory-constrained container over-reports total memory; `resolveAutoScan-
38524
+ * Concurrency` takes `min(totalmem, this)` to honor the limit.
38525
+ *
38526
+ * The cgroup v2 read is the mount-root `memory.max`, which IS the container's
38527
+ * limit under the standard cgroup-namespace setup CI runners use (the
38528
+ * container's own cgroup is the root of its namespaced view). A process in a
38529
+ * non-namespaced nested/delegated cgroup whose root reads `"max"` is not
38530
+ * detected here and falls back to the host total; the EAGAIN/ENOMEM serial
38531
+ * replay in `spawnLintBatches` remains the runtime backstop for that case.
38532
+ *
38533
+ * `candidatePaths` is injectable so tests exercise the v2-wins-over-v1
38534
+ * precedence, the skip-unreadable fallback, and the all-missing case without a
38535
+ * real `/sys/fs/cgroup`.
38536
+ */
38537
+ const readCgroupMemoryLimitBytes = (candidatePaths = CGROUP_MEMORY_LIMIT_PATHS) => {
38538
+ for (const limitPath of candidatePaths) {
38539
+ let raw;
38540
+ try {
38541
+ raw = fs.readFileSync(limitPath, "utf8");
38542
+ } catch {
38543
+ continue;
38544
+ }
38545
+ const limitBytes = parseCgroupMemoryLimitBytes(raw);
38546
+ if (limitBytes !== void 0) return limitBytes;
38547
+ }
38548
+ };
38549
+ /**
38550
+ * Clamps a requested lint worker count to `[MIN_SCAN_CONCURRENCY,
38551
+ * HARD_MAX_SCAN_CONCURRENCY]` as a finite integer. This is the explicit-pin and
38552
+ * spawn-boundary clamp — the memory-and-core-budgeted auto count comes from
38553
+ * `resolveAutoScanConcurrency`. Out-of-range or non-finite requests degrade to
38368
38554
  * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
38369
38555
  */
38370
38556
  const resolveScanConcurrency = (requested) => {
38371
- const desired = requested === "auto" ? os.availableParallelism() : requested;
38372
- if (!Number.isFinite(desired) || desired < 1) return 1;
38373
- return Math.max(1, Math.min(Math.floor(desired), 16));
38557
+ if (!Number.isFinite(requested) || requested < 1) return 1;
38558
+ return Math.min(Math.floor(requested), 32);
38559
+ };
38560
+ const readSystemFacts = () => ({
38561
+ availableCores: os.availableParallelism(),
38562
+ totalMemoryBytes: os.totalmem(),
38563
+ cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
38564
+ });
38565
+ /**
38566
+ * Auto lint-worker count: the smaller of the (cgroup-CPU-aware) core count and
38567
+ * the number of `PER_WORKER_MEM_BUDGET_BYTES` workers that fit in available
38568
+ * memory, then clamped to `[MIN, HARD_MAX]` by `resolveScanConcurrency`.
38569
+ *
38570
+ * `os.availableParallelism()` already respects cgroup CPU quotas, so the core
38571
+ * term needs no help. Available memory is `os.totalmem()` floored by the cgroup
38572
+ * memory limit — `os.freemem()` is deliberately NOT used: it excludes
38573
+ * reclaimable page cache and reads near-zero on macOS / cache-heavy Linux, which
38574
+ * would collapse the auto path to a single worker. `os.totalmem()` reports the
38575
+ * host total even inside a container, so the cgroup limit (read directly,
38576
+ * because Node doesn't fold it into `totalmem()`) is the real ceiling there.
38577
+ *
38578
+ * `facts` is injectable so tests exercise core-bound, memory-bound, cgroup-
38579
+ * limited, and ceiling cases without mocking `os` or the filesystem.
38580
+ */
38581
+ const resolveAutoScanConcurrency = (facts = readSystemFacts()) => {
38582
+ const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
38583
+ const memoryBoundedWorkers = Math.floor(availableMemoryBytes / PER_WORKER_MEM_BUDGET_BYTES);
38584
+ return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
38585
+ };
38586
+ const resolveLintBatchOrdering = () => {
38587
+ return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "cost" ? "cost" : "arrival";
38374
38588
  };
38375
38589
  /**
38376
38590
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -38378,11 +38592,38 @@ const resolveScanConcurrency = (requested) => {
38378
38592
  * microVMs without recompiling react-doctor. Tests override via
38379
38593
  * `Layer.succeed(OxlintSpawnTimeoutMs, ...)`.
38380
38594
  */
38381
- var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => {
38382
- const raw = process.env["REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS"];
38383
- if (raw === void 0) return OXLINT_SPAWN_TIMEOUT_MS;
38595
+ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS", OXLINT_SPAWN_TIMEOUT_MS) }) {};
38596
+ /**
38597
+ * Effect-side cap on the lint phase. The env var lets CI / eval runners
38598
+ * raise the phase budget for slow large repos without recompiling.
38599
+ * Tests override via `Layer.succeed(LintPhaseTimeoutMs, ...)`.
38600
+ */
38601
+ var LintPhaseTimeoutMs = class extends Reference("react-doctor/LintPhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_LINT_PHASE_TIMEOUT_MS", LINT_PHASE_TIMEOUT_MS) }) {};
38602
+ /**
38603
+ * Effect-side cap on the dead-code phase, sitting above the in-worker
38604
+ * timeout as a runtime-independent backstop. The env var raises it for
38605
+ * type-heavy projects; tests override via
38606
+ * `Layer.succeed(DeadCodePhaseTimeoutMs, ...)`.
38607
+ */
38608
+ var DeadCodePhaseTimeoutMs = class extends Reference("react-doctor/DeadCodePhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_DEAD_CODE_PHASE_TIMEOUT_MS", DEAD_CODE_PHASE_TIMEOUT_MS) }) {};
38609
+ /**
38610
+ * Overall scan deadline backstop, bounding everything the per-phase
38611
+ * timeouts don't (wedged git / IO). The env var raises it for very
38612
+ * large repos; tests override via `Layer.succeed(ScanDeadlineMs, ...)`.
38613
+ */
38614
+ var ScanDeadlineMs = class extends Reference("react-doctor/ScanDeadlineMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_SCAN_DEADLINE_MS", SCAN_TOTAL_DEADLINE_MS) }) {};
38615
+ /**
38616
+ * Wall-clock budget for the supply-chain check when it runs on a background
38617
+ * fiber overlapping the lint pass. Reads from the env var on startup so the
38618
+ * eval harness can raise the budget under sandbox microVMs (slower network)
38619
+ * without recompiling react-doctor. Tests override via
38620
+ * `Layer.succeed(SupplyChainOverlapTimeoutMs, ...)`.
38621
+ */
38622
+ var SupplyChainOverlapTimeoutMs = class extends Reference("react-doctor/SupplyChainOverlapTimeoutMs", { defaultValue: () => {
38623
+ const raw = process.env["REACT_DOCTOR_SUPPLY_CHAIN_TIMEOUT_MS"];
38624
+ if (raw === void 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
38384
38625
  const parsed = Number(raw);
38385
- if (!Number.isFinite(parsed) || parsed <= 0) return OXLINT_SPAWN_TIMEOUT_MS;
38626
+ if (!Number.isFinite(parsed) || parsed <= 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
38386
38627
  return parsed;
38387
38628
  } }) {};
38388
38629
  /**
@@ -38393,31 +38634,93 @@ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTime
38393
38634
  */
38394
38635
  var OxlintOutputMaxBytes = class extends Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
38395
38636
  /**
38396
- * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
38397
- * to auto-detected CPU cores (parallel) so large repos scan fast out of
38398
- * the box; `spawnLintBatches` transparently falls back to a single worker
38399
- * if a parallel run exhausts system resources. The CLI's `--no-parallel`
38400
- * flag forces serial via `Layer.succeed`; the `REACT_DOCTOR_PARALLEL` env
38401
- * var seeds the default for programmatic / CI callers that never touch the
38402
- * flag parallelism is opt-OUT, so only the explicit serial values pin
38403
- * one worker:
38404
- *
38405
- * - unset / `auto` / `true` / `on` → available CPU cores (clamped)
38637
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults to a
38638
+ * memory-and-core-budgeted auto count (`resolveAutoScanConcurrency`) so large
38639
+ * repos scan fast out of the box without OOMing the native binding on a
38640
+ * high-core / low-memory box; `spawnLintBatches` transparently falls back to a
38641
+ * single worker if a parallel run still exhausts system resources. The CLI's
38642
+ * `--no-parallel` flag forces serial via `Layer.succeed`; the
38643
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic / CI
38644
+ * callers that never touch the flag — parallelism is opt-OUT, so only the
38645
+ * explicit serial values pin one worker:
38646
+ *
38647
+ * - unset / `auto` / `true` / `on` → memory-and-core-budgeted auto count
38406
38648
  * - `0` / `false` / `off` → `1` (serial)
38407
38649
  * - a positive integer → that many workers (clamped)
38408
- * - any other value → available CPU cores (clamped)
38650
+ * - any other value → memory-and-core-budgeted auto count
38409
38651
  *
38410
38652
  * The resolved value is always within
38411
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
38653
+ * `[MIN_SCAN_CONCURRENCY, HARD_MAX_SCAN_CONCURRENCY]`.
38412
38654
  */
38413
38655
  var OxlintConcurrency = class extends Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
38414
38656
  const raw = process.env["REACT_DOCTOR_PARALLEL"];
38415
- if (raw === void 0) return resolveScanConcurrency("auto");
38657
+ if (raw === void 0) return resolveAutoScanConcurrency();
38416
38658
  const normalized = raw.trim().toLowerCase();
38417
38659
  if (normalized === "0" || normalized === "false" || normalized === "off") return 1;
38418
38660
  const parsed = Number.parseInt(normalized, 10);
38419
38661
  if (Number.isInteger(parsed) && parsed > 0) return resolveScanConcurrency(parsed);
38420
- return resolveScanConcurrency("auto");
38662
+ return resolveAutoScanConcurrency();
38663
+ } }) {};
38664
+ /**
38665
+ * Three-state control for overlapping the dead-code pass with the lint pass —
38666
+ * forking dead-code as a child fiber that runs DURING lint instead of strictly
38667
+ * after it.
38668
+ *
38669
+ * - `"auto"` (default) / `"off"` → strictly SEQUENTIAL: dead-code runs after
38670
+ * lint with the full core budget. Both deslop's parse pool and the oxlint
38671
+ * pool are CPU-bound and each size themselves to all cores, so overlapping
38672
+ * them only oversubscribes (~2x the cores) and starves the parse pass past
38673
+ * its timeout — for no wall-clock win, since there are no spare cores to
38674
+ * absorb the second pass. Sequential is both faster per-phase and safe.
38675
+ * - `"on"` → force the overlap anyway. The orchestrator then SPLITS the core
38676
+ * budget (`DEAD_CODE_OVERLAP_PARSE_SHARE`): deslop's parse pool is capped
38677
+ * and lint shrinks to the remainder, so the two sum to the cores instead of
38678
+ * doubling them, and the dead-code timeout scales up for the reduced share.
38679
+ *
38680
+ * Seeded from `REACT_DOCTOR_DEAD_CODE_OVERLAP` so operators get a redeploy-free
38681
+ * switch; tests pin it via `Layer.succeed(DeadCodeOverlap, ...)`.
38682
+ */
38683
+ var DeadCodeOverlap = class extends Reference("react-doctor/DeadCodeOverlap", { defaultValue: () => {
38684
+ const raw = process.env["REACT_DOCTOR_DEAD_CODE_OVERLAP"]?.trim().toLowerCase();
38685
+ if (raw === "on" || raw === "true" || raw === "1") return "on";
38686
+ if (raw === "off" || raw === "false" || raw === "0") return "off";
38687
+ return "auto";
38688
+ } }) {};
38689
+ /**
38690
+ * How the full-scan lint pass orders its file batches. `"arrival"` (the
38691
+ * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
38692
+ * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
38693
+ * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
38694
+ * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
38695
+ * batch is a straggler (and can trip the per-batch timeout + split), measurably
38696
+ * regressing the common full-scan case. LPT needs the heavy files SPREAD across
38697
+ * batches before `cost` earns the default. Tests override via
38698
+ * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
38699
+ * — they pass user-scoped `includePaths` that skip discovery and stay in
38700
+ * arrival order; only the full-scan branch reads it.
38701
+ */
38702
+ var LintBatchOrdering = class extends Reference("react-doctor/LintBatchOrdering", { defaultValue: resolveLintBatchOrdering }) {};
38703
+ const CACHE_DISABLED_VALUES$1 = new Set(["1", "true"]);
38704
+ /**
38705
+ * Whether the per-file lint cache (`runners/oxlint/file-lint-cache.ts`) is
38706
+ * active. Defaults ON — repeat scans re-lint only the files whose content
38707
+ * changed, and correctness is guaranteed byte-identical to a cold scan by the
38708
+ * always-fresh cross-file sidecar. Opt-OUT, two knobs (matching the whole-repo
38709
+ * scan cache's `REACT_DOCTOR_NO_CACHE`):
38710
+ *
38711
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch; disables BOTH the
38712
+ * whole-repo scan cache and this per-file cache.
38713
+ * - `REACT_DOCTOR_NO_FILE_CACHE` — granular: bust only the per-file cache
38714
+ * while keeping the whole-repo short-circuit.
38715
+ *
38716
+ * Tests override via `Layer.succeed(PerFileLintCacheEnabled, false)`.
38717
+ */
38718
+ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintCacheEnabled", { defaultValue: () => {
38719
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
38720
+ const noFileCache = process.env["REACT_DOCTOR_NO_FILE_CACHE"]?.toLowerCase() ?? "";
38721
+ if (CACHE_DISABLED_VALUES$1.has(noCache)) return false;
38722
+ if (CACHE_DISABLED_VALUES$1.has(noFileCache)) return false;
38723
+ return true;
38421
38724
  } }) {};
38422
38725
  const DIAGNOSTIC_SURFACES = [
38423
38726
  "cli",
@@ -38466,6 +38769,12 @@ const BOOLEAN_FIELD_NAMES = [
38466
38769
  "adoptExistingLintConfig"
38467
38770
  ];
38468
38771
  const STRING_FIELD_NAMES = ["rootDir"];
38772
+ const STRING_ARRAY_FIELD_NAMES = [
38773
+ "projects",
38774
+ "textComponents",
38775
+ "rawTextWrapperComponents",
38776
+ "serverAuthFunctionNames"
38777
+ ];
38469
38778
  const SURFACE_CONTROL_FIELD_NAMES = [
38470
38779
  "includeTags",
38471
38780
  "excludeTags",
@@ -38567,6 +38876,7 @@ const validateConfigTypes = (config) => {
38567
38876
  const validated = { ...config };
38568
38877
  for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
38569
38878
  for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
38879
+ for (const fieldName of STRING_ARRAY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateStringArrayField(fieldName, value));
38570
38880
  applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
38571
38881
  for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
38572
38882
  applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
@@ -38851,6 +39161,8 @@ const assignFixGroups = (diagnostics) => {
38851
39161
  };
38852
39162
  });
38853
39163
  };
39164
+ const compareStrings = (left, right) => left < right ? -1 : left > right ? 1 : 0;
39165
+ 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));
38854
39166
  const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
38855
39167
  const buildExpoCheckContext = (rootDirectory, expoVersion) => {
38856
39168
  const packageJson = readPackageJson$1(Path.join(rootDirectory, "package.json"));
@@ -39357,10 +39669,15 @@ const buildHardeningDiagnostic = (input) => ({
39357
39669
  column: input.column ?? 0,
39358
39670
  category: "Security"
39359
39671
  });
39360
- const checkPnpmHardening = (rootDirectory) => {
39361
- if (!isPnpmManagedProject(rootDirectory)) return [];
39362
- const workspacePath = Path.join(rootDirectory, PNPM_WORKSPACE_FILE);
39363
- const settings = parseHardeningSettings(isFile(workspacePath) ? NFS.readFileSync(workspacePath, "utf-8") : "");
39672
+ const checkPnpmHardening = (scanDirectory) => {
39673
+ if (!isPnpmManagedProject(scanDirectory)) return [];
39674
+ const workspacePath = Path.join(scanDirectory, PNPM_WORKSPACE_FILE);
39675
+ const hasWorkspaceFile = isFile(workspacePath);
39676
+ if (!hasWorkspaceFile) {
39677
+ const monorepoRoot = findMonorepoRoot(scanDirectory);
39678
+ if (monorepoRoot !== null && isFile(Path.join(monorepoRoot, PNPM_WORKSPACE_FILE))) return [];
39679
+ }
39680
+ const settings = parseHardeningSettings(hasWorkspaceFile ? NFS.readFileSync(workspacePath, "utf-8") : "");
39364
39681
  const diagnostics = [];
39365
39682
  if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
39366
39683
  message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
@@ -40161,6 +40478,22 @@ process.stdin.on("end", () => {
40161
40478
  ...(workerInput.ignorePatterns.length > 0
40162
40479
  ? { ignorePatterns: workerInput.ignorePatterns }
40163
40480
  : {}),
40481
+ // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
40482
+ // unusedDependencies, circularDependencies). Everything else deslop can compute
40483
+ // is pure wasted work for us, and it's the bulk of the runtime:
40484
+ // - semantic: a full TS Program for unusedTypes/enum/class-members/
40485
+ // misclassifiedDependencies (~37-45% of the phase).
40486
+ // - reportCodeQuality: the duplicate-block, complexity, feature-flag,
40487
+ // TypeScript-smell, private-type-leak and re-export-cycle detectors. These
40488
+ // are the single most expensive pass — duplicate-block detection alone was
40489
+ // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
40490
+ // speedup on a large repo.
40491
+ // Both are provably safe: the consumed graph findings are computed by their own
40492
+ // detectors, independent of these passes (confirmed byte-identical on
40493
+ // excalidraw + mui-material + sentry). tsConfigPath stays — the module resolver
40494
+ // needs it for path-alias resolution in the import graph.
40495
+ semantic: { enabled: false },
40496
+ reportCodeQuality: false,
40164
40497
  };
40165
40498
  const result = await analyze(defineConfig(config));
40166
40499
  emit({ ok: true, result: normalizeResult(result) });
@@ -40290,7 +40623,11 @@ const createDeadCodeWorker = (input) => {
40290
40623
  "pipe",
40291
40624
  "pipe"
40292
40625
  ],
40293
- windowsHide: true
40626
+ windowsHide: true,
40627
+ env: input.parseConcurrency === void 0 ? process.env : {
40628
+ ...process.env,
40629
+ DESLOP_PARSE_CONCURRENCY: String(input.parseConcurrency)
40630
+ }
40294
40631
  });
40295
40632
  const stdoutChunks = [];
40296
40633
  const stderrChunks = [];
@@ -40335,28 +40672,25 @@ const createDeadCodeWorker = (input) => {
40335
40672
  }
40336
40673
  };
40337
40674
  };
40338
- const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve, reject) => {
40675
+ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs, abortSignal) => new Promise((resolve, reject) => {
40339
40676
  let didSettle = false;
40340
- const timeoutHandle = setTimeout(() => {
40341
- if (didSettle) return;
40342
- didSettle = true;
40343
- handle.terminate?.();
40344
- reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`));
40345
- }, timeoutMs);
40346
- timeoutHandle.unref?.();
40347
- handle.result.then((value) => {
40677
+ const settle = (finish) => {
40348
40678
  if (didSettle) return;
40349
40679
  didSettle = true;
40350
40680
  clearTimeout(timeoutHandle);
40681
+ abortSignal?.removeEventListener("abort", onAbort);
40351
40682
  handle.terminate?.();
40352
- resolve(value);
40353
- }, (error) => {
40354
- if (didSettle) return;
40355
- didSettle = true;
40356
- clearTimeout(timeoutHandle);
40357
- handle.terminate?.();
40358
- reject(error);
40359
- });
40683
+ finish();
40684
+ };
40685
+ const onAbort = () => settle(() => reject(/* @__PURE__ */ new Error("Dead-code worker aborted.")));
40686
+ const timeoutHandle = setTimeout(() => settle(() => reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`))), timeoutMs);
40687
+ timeoutHandle.unref?.();
40688
+ if (abortSignal?.aborted) {
40689
+ onAbort();
40690
+ return;
40691
+ }
40692
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
40693
+ handle.result.then((value) => settle(() => resolve(value)), (error) => settle(() => reject(error)));
40360
40694
  });
40361
40695
  const checkDeadCode = async (options) => {
40362
40696
  const rootDirectory = toCanonicalPath(options.rootDirectory);
@@ -40368,8 +40702,9 @@ const checkDeadCode = async (options) => {
40368
40702
  entryPatterns,
40369
40703
  tsConfigPath: resolveTsConfigPath(rootDirectory),
40370
40704
  ignorePatterns,
40371
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js")
40372
- }), options.workerTimeoutMs ?? 12e4));
40705
+ deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
40706
+ parseConcurrency: options.parseConcurrency
40707
+ }), options.workerTimeoutMs ?? 12e4, options.abortSignal));
40373
40708
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
40374
40709
  const diagnostics = [];
40375
40710
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -40467,7 +40802,37 @@ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
40467
40802
  return true;
40468
40803
  };
40469
40804
  const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
40470
- const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(Path.resolve(rootDirectory, relativePath)));
40805
+ /**
40806
+ * Budget for the dead-code phase, scaled to the work. deslop's graph build is
40807
+ * CPU-bound and roughly linear in file count, so a fixed 120s cap is too tight
40808
+ * for a large repo (where the pass legitimately runs that long) and is then
40809
+ * tipped over by any concurrent load — silently dropping every dead-code
40810
+ * finding. Scaling the budget with file count (and inversely with the core
40811
+ * share when overlapped) lets the pass complete, while the ceiling still
40812
+ * reclaims a genuinely wedged worker. Returns the in-worker SIGKILL deadline
40813
+ * and the Effect-side phase backstop that sits a margin above it.
40814
+ */
40815
+ const resolveDeadCodeTimeout = (input) => {
40816
+ const coreShareFactor = Math.max(1, input.fullConcurrency / Math.max(1, input.deadCodeConcurrency));
40817
+ const workerTimeoutMs = Math.min(DEAD_CODE_TIMEOUT_CEILING_MS, Math.max(DEAD_CODE_WORKER_TIMEOUT_MS, Math.ceil(input.sourceFileCount * 30 * coreShareFactor)));
40818
+ return {
40819
+ workerTimeoutMs,
40820
+ phaseTimeoutMs: workerTimeoutMs + DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS
40821
+ };
40822
+ };
40823
+ const collectSizedSourceFiles = (rootDirectory, relativePaths) => {
40824
+ const entries = [];
40825
+ for (const relativePath of relativePaths) {
40826
+ const absolutePath = Path.resolve(rootDirectory, relativePath);
40827
+ const sizeBytes = statSourceFileSize(absolutePath);
40828
+ if (isLargeMinifiedFile(absolutePath, sizeBytes)) continue;
40829
+ entries.push({
40830
+ path: relativePath,
40831
+ sizeBytes: sizeBytes ?? 0
40832
+ });
40833
+ }
40834
+ return entries;
40835
+ };
40471
40836
  const listSourceFilesViaGit = (rootDirectory) => {
40472
40837
  const result = spawnSync("git", [
40473
40838
  "ls-files",
@@ -40500,7 +40865,8 @@ const listSourceFilesViaFilesystem = (rootDirectory) => {
40500
40865
  }
40501
40866
  return filePaths;
40502
40867
  };
40503
- const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
40868
+ const listSourceFilesWithSize = (rootDirectory) => collectSizedSourceFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
40869
+ const listSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).map((entry) => entry.path);
40504
40870
  const resolveLintIncludePaths = (rootDirectory, userConfig, project) => {
40505
40871
  if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
40506
40872
  const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
@@ -40543,9 +40909,12 @@ var Config = class Config extends Service()("react-doctor/Config") {
40543
40909
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
40544
40910
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
40545
40911
  return yield* tryPromise({
40546
- try: () => checkDeadCode({
40912
+ try: (signal) => checkDeadCode({
40547
40913
  rootDirectory: input.rootDirectory,
40548
- userConfig: input.userConfig
40914
+ userConfig: input.userConfig,
40915
+ parseConcurrency: input.parseConcurrency,
40916
+ workerTimeoutMs: input.workerTimeoutMs,
40917
+ abortSignal: signal
40549
40918
  }),
40550
40919
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
40551
40920
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -40736,43 +41105,46 @@ var Git = class Git extends Service()("react-doctor/Git") {
40736
41105
  * reason: GitInvocationFailed })` so the rest of the codebase
40737
41106
  * sees a single failure channel.
40738
41107
  */
40739
- const runCommand = (input) => scoped(gen(function* () {
40740
- const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
40741
- cwd: input.directory,
40742
- env: input.env,
40743
- extendEnv: true
40744
- }));
40745
- const maxStdoutBytes = input.maxStdoutBytes;
40746
- const stdoutByteCount = yield* make$13(0);
40747
- const [stdout, stderr, status] = yield* all([
40748
- 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({
40749
- args: [...input.args],
40750
- directory: input.directory,
40751
- cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
40752
- }) })) : void_)))))),
40753
- mkString(decodeText(handle.stderr)),
40754
- handle.exitCode
40755
- ], { concurrency: 3 });
40756
- return {
40757
- status,
40758
- stdout,
40759
- stderr
40760
- };
40761
- })).pipe(catchTag$1("PlatformError", (cause) => {
40762
- if (input.command !== "git") return succeed$2({
41108
+ const runCommand = (input) => {
41109
+ const foldSpawnFailure = (cause) => input.command !== "git" ? succeed$2({
40763
41110
  status: 127,
40764
41111
  stdout: "",
40765
41112
  stderr: String(cause)
40766
- });
40767
- return new ReactDoctorError({ reason: new GitInvocationFailed({
41113
+ }) : fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
40768
41114
  args: [...input.args],
40769
41115
  directory: input.directory,
40770
41116
  cause
40771
- }) });
40772
- }), withSpan("git.exec", { attributes: {
40773
- "git.command": input.command,
40774
- "git.subcommand": input.args[0] ?? ""
40775
- } }));
41117
+ }) }));
41118
+ return scoped(gen(function* () {
41119
+ if (!isDirectory(input.directory)) return yield* foldSpawnFailure(`spawn ENOTDIR (cwd is not a directory: ${input.directory})`);
41120
+ const argvLengthChars = input.command.length + 1 + input.args.reduce((total, arg) => total + arg.length + 1, 0);
41121
+ if (argvLengthChars > 24e3) return yield* foldSpawnFailure(`spawn ENAMETOOLONG (${argvLengthChars} argv chars exceed ${SPAWN_ARGS_MAX_LENGTH_CHARS})`);
41122
+ const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
41123
+ cwd: input.directory,
41124
+ env: input.env,
41125
+ extendEnv: true
41126
+ }));
41127
+ const maxStdoutBytes = input.maxStdoutBytes;
41128
+ const stdoutByteCount = yield* make$13(0);
41129
+ const [stdout, stderr, status] = yield* all([
41130
+ 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({
41131
+ args: [...input.args],
41132
+ directory: input.directory,
41133
+ cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
41134
+ }) })) : void_)))))),
41135
+ mkString(decodeText(handle.stderr)),
41136
+ handle.exitCode
41137
+ ], { concurrency: 3 });
41138
+ return {
41139
+ status,
41140
+ stdout,
41141
+ stderr
41142
+ };
41143
+ })).pipe(catchTag$1("PlatformError", foldSpawnFailure), withSpan("git.exec", { attributes: {
41144
+ "git.command": input.command,
41145
+ "git.subcommand": input.args[0] ?? ""
41146
+ } }));
41147
+ };
40776
41148
  const runGit = (directory, args) => runCommand({
40777
41149
  command: "git",
40778
41150
  args,
@@ -40805,7 +41177,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40805
41177
  "rev-parse",
40806
41178
  "--verify",
40807
41179
  branch
40808
- ]).pipe(map$3((result) => result.status === 0));
41180
+ ]).pipe(map$3((result) => result.status === 0), catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(false) : fail$4(error)));
40809
41181
  const headSha = (directory) => runGit(directory, ["rev-parse", "HEAD"]).pipe(map$3((result) => result.status === 0 ? trimOrNull(result.stdout) : null));
40810
41182
  const mergeBase = (input) => isSafeGitRevision(input.ref) ? runGit(input.directory, [
40811
41183
  "merge-base",
@@ -41019,7 +41391,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
41019
41391
  ]);
41020
41392
  if (result.status !== 0) return null;
41021
41393
  return parseChangedLineRanges(result.stdout);
41022
- }).pipe(withSpan("Git.changedLineRanges"))
41394
+ }).pipe(catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(null) : fail$4(error)), withSpan("Git.changedLineRanges"))
41023
41395
  });
41024
41396
  })).pipe(provide$2(layer$3.pipe(provide$2(mergeAll$1(layer$2, layer$1)))));
41025
41397
  /**
@@ -41258,6 +41630,14 @@ const neutralizeDisableDirectives = async (rootDirectory, includePaths) => {
41258
41630
  process.removeListener("exit", onExit);
41259
41631
  };
41260
41632
  };
41633
+ const ROOT_DIRECTORY_PLACEHOLDER = "<root>";
41634
+ const normalizeConfigForHash = (config) => {
41635
+ const clone = JSON.parse(JSON.stringify(config));
41636
+ if (clone?.settings?.["react-doctor"]) clone.settings["react-doctor"].rootDirectory = ROOT_DIRECTORY_PLACEHOLDER;
41637
+ if (Array.isArray(clone?.jsPlugins)) clone.jsPlugins = clone.jsPlugins.map((_, index) => `<plugin:${index}>`);
41638
+ return clone;
41639
+ };
41640
+ 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");
41261
41641
  /**
41262
41642
  * Loads a plugin module via the local require resolver and extracts
41263
41643
  * `(name, ruleNames)` from either `module.exports.meta + rules` or
@@ -41412,8 +41792,8 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
41412
41792
  }
41413
41793
  return enabled;
41414
41794
  };
41415
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false }) => {
41416
- const reactHooksJsPlugin = disableReactHooksJsPlugin ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
41795
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection }) => {
41796
+ const reactHooksJsPlugin = disableReactHooksJsPlugin || ruleSelection === "sidecar" ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
41417
41797
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
41418
41798
  const jsPlugins = [];
41419
41799
  if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
@@ -41422,6 +41802,8 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41422
41802
  for (const registryEntry of REACT_DOCTOR_RULES) {
41423
41803
  const rule = reactDoctorPlugin.rules[registryEntry.id];
41424
41804
  if (!rule) continue;
41805
+ if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
41806
+ if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
41425
41807
  if (rule.scan !== void 0) continue;
41426
41808
  if (customRulesOnly && registryEntry.originallyExternal) continue;
41427
41809
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -41436,7 +41818,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41436
41818
  enabledReactDoctorRules[registryEntry.key] = severity;
41437
41819
  }
41438
41820
  const userPluginRules = {};
41439
- for (const userPlugin of userPlugins) {
41821
+ if (ruleSelection !== "sidecar") for (const userPlugin of userPlugins) {
41440
41822
  Object.assign(userPluginRules, buildUserPluginRules(userPlugin, severityControls));
41441
41823
  jsPlugins.push(userPlugin.entry);
41442
41824
  }
@@ -41466,6 +41848,100 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41466
41848
  }
41467
41849
  };
41468
41850
  };
41851
+ const atomicWriteJson = (filePath, value) => {
41852
+ try {
41853
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
41854
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
41855
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
41856
+ NFS.renameSync(temporaryPath, filePath);
41857
+ } catch {
41858
+ return;
41859
+ }
41860
+ };
41861
+ const failOpenReadJson = (filePath, fallback) => {
41862
+ try {
41863
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
41864
+ } catch {
41865
+ return fallback;
41866
+ }
41867
+ };
41868
+ const validateDiagnostic = decodeUnknownSync(Diagnostic);
41869
+ const decodeFileDiagnostics = (raw) => {
41870
+ if (!Array.isArray(raw)) return null;
41871
+ try {
41872
+ for (const entry of raw) validateDiagnostic(entry);
41873
+ return raw;
41874
+ } catch {
41875
+ return null;
41876
+ }
41877
+ };
41878
+ const emptyCache = () => ({
41879
+ version: 1,
41880
+ rulesets: {}
41881
+ });
41882
+ const loadRulesetEntries = (cacheFilePath, rulesetHash) => {
41883
+ const entries = /* @__PURE__ */ new Map();
41884
+ const persisted = failOpenReadJson(cacheFilePath, emptyCache());
41885
+ if (persisted.version !== 1 || !isRecord$2(persisted.rulesets)) return entries;
41886
+ const bucket = persisted.rulesets[rulesetHash];
41887
+ if (!isRecord$2(bucket) || !isRecord$2(bucket.files)) return entries;
41888
+ for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
41889
+ const decoded = decodeFileDiagnostics(rawDiagnostics);
41890
+ if (decoded !== null) entries.set(fileKey, decoded);
41891
+ }
41892
+ return entries;
41893
+ };
41894
+ const createFileLintCache = (cacheDirectory, rulesetHash) => {
41895
+ const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
41896
+ const entries = loadRulesetEntries(cacheFilePath, rulesetHash);
41897
+ return {
41898
+ lookup: (fileKey) => entries.get(fileKey) ?? null,
41899
+ store: (fileKey, diagnostics) => {
41900
+ entries.delete(fileKey);
41901
+ entries.set(fileKey, diagnostics);
41902
+ },
41903
+ persist: () => {
41904
+ const onDisk = failOpenReadJson(cacheFilePath, emptyCache());
41905
+ const rulesets = onDisk.version === 1 && isRecord$2(onDisk.rulesets) ? { ...onDisk.rulesets } : {};
41906
+ const existingBucket = rulesets[rulesetHash];
41907
+ const existingFiles = isRecord$2(existingBucket) && isRecord$2(existingBucket.files) ? existingBucket.files : {};
41908
+ const ourFiles = {};
41909
+ for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
41910
+ const cappedEntries = Object.entries({
41911
+ ...existingFiles,
41912
+ ...ourFiles
41913
+ }).slice(-FILE_LINT_CACHE_MAX_FILE_COUNT);
41914
+ rulesets[rulesetHash] = {
41915
+ updatedAtMs: Date.now(),
41916
+ files: Object.fromEntries(cappedEntries)
41917
+ };
41918
+ const keptHashes = Object.entries(rulesets).sort(([, first], [, second]) => second.updatedAtMs - first.updatedAtMs).slice(0, 8).map(([hash]) => hash);
41919
+ const prunedRulesets = {};
41920
+ for (const hash of keptHashes) prunedRulesets[hash] = rulesets[hash];
41921
+ atomicWriteJson(cacheFilePath, {
41922
+ version: 1,
41923
+ rulesets: prunedRulesets
41924
+ });
41925
+ }
41926
+ };
41927
+ };
41928
+ const bundledRequire$2 = createRequire(import.meta.url);
41929
+ const TOOLCHAIN_PACKAGE_SPECIFIERS$1 = [
41930
+ "oxlint/package.json",
41931
+ "oxlint-plugin-react-doctor/package.json",
41932
+ "eslint-plugin-react-hooks/package.json"
41933
+ ];
41934
+ const resolveOxlintToolchainVersions = () => {
41935
+ const versions = [`node=${process.version}`];
41936
+ for (const specifier of TOOLCHAIN_PACKAGE_SPECIFIERS$1) try {
41937
+ const packageJson = bundledRequire$2(specifier);
41938
+ const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
41939
+ versions.push(`${specifier}=${version}`);
41940
+ } catch {
41941
+ versions.push(`${specifier}=missing`);
41942
+ }
41943
+ return versions;
41944
+ };
41469
41945
  const esmRequire = createRequire(import.meta.url);
41470
41946
  const resolveOxlintBinary = () => {
41471
41947
  const oxlintMainPath = esmRequire.resolve("oxlint");
@@ -42147,15 +42623,19 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
42147
42623
  };
42148
42624
  });
42149
42625
  };
42150
- const SANITIZED_ENV = (() => {
42151
- const sanitized = {};
42152
- for (const [name, value] of Object.entries(process.env)) {
42626
+ const buildOxlintChildEnv = (sourceEnv) => {
42627
+ const childEnv = {};
42628
+ for (const [name, value] of Object.entries(sourceEnv)) {
42153
42629
  if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
42154
42630
  if (name.startsWith("npm_config_")) continue;
42155
- sanitized[name] = value;
42631
+ childEnv[name] = value;
42156
42632
  }
42157
- return sanitized;
42158
- })();
42633
+ const isCompileCacheDisabled = Boolean(sourceEnv.NODE_DISABLE_COMPILE_CACHE);
42634
+ const isCompileCacheAlreadySet = childEnv.NODE_COMPILE_CACHE !== void 0;
42635
+ if (!isCompileCacheDisabled && !isCompileCacheAlreadySet) childEnv.NODE_COMPILE_CACHE = Path.join(os.tmpdir(), NODE_COMPILE_CACHE_DIR_NAME);
42636
+ return childEnv;
42637
+ };
42638
+ const SANITIZED_ENV = buildOxlintChildEnv(process.env);
42159
42639
  /**
42160
42640
  * Spawn one oxlint subprocess with hard ceilings on wall time and
42161
42641
  * output size. Returns stdout on success; raises a tagged
@@ -42172,7 +42652,11 @@ const SANITIZED_ENV = (() => {
42172
42652
  * The first three are splittable (the caller's binary-split retry
42173
42653
  * shrinks the batch and re-spawns); the fourth isn't.
42174
42654
  */
42175
- const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES) => new Promise((resolve, reject) => {
42655
+ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES, abortSignal) => new Promise((resolve, reject) => {
42656
+ if (abortSignal?.aborted) {
42657
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
42658
+ return;
42659
+ }
42176
42660
  const child = spawn(nodeBinaryPath, args, {
42177
42661
  cwd: rootDirectory,
42178
42662
  env: SANITIZED_ENV,
@@ -42182,7 +42666,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42182
42666
  "pipe"
42183
42667
  ]
42184
42668
  });
42669
+ const onAbort = () => {
42670
+ child.kill("SIGKILL");
42671
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
42672
+ };
42673
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
42674
+ const clearAbortListener = () => abortSignal?.removeEventListener("abort", onAbort);
42185
42675
  const timeoutHandle = setTimeout(() => {
42676
+ clearAbortListener();
42186
42677
  child.kill("SIGKILL");
42187
42678
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42188
42679
  kind: "timeout",
@@ -42217,10 +42708,12 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42217
42708
  });
42218
42709
  child.on("error", (error) => {
42219
42710
  clearTimeout(timeoutHandle);
42711
+ clearAbortListener();
42220
42712
  reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: error }) }));
42221
42713
  });
42222
42714
  child.on("close", (_code, signal) => {
42223
42715
  clearTimeout(timeoutHandle);
42716
+ clearAbortListener();
42224
42717
  if (didKillForSize) {
42225
42718
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42226
42719
  kind: "output-too-large",
@@ -42287,26 +42780,28 @@ const isParallelismRelatedSpawnError = (error) => {
42287
42780
  * loop with a slimmer config in that case.
42288
42781
  */
42289
42782
  const spawnLintBatches = async (input) => {
42290
- const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
42783
+ const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes, splitTotalBudgetMs = OXLINT_SPLIT_TOTAL_BUDGET_MS, splitMaxDepth = 8, signal } = input;
42291
42784
  const requestedConcurrency = resolveScanConcurrency(input.concurrency ?? 1);
42292
42785
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
42293
42786
  const runBatchPass = async (concurrency) => {
42294
42787
  const allDiagnostics = [];
42295
42788
  const droppedFiles = [];
42296
42789
  let firstDropReason = null;
42297
- const spawnLintBatch = async (batch) => {
42790
+ const splitDeadlineMs = Date.now() + splitTotalBudgetMs;
42791
+ const spawnLintBatch = async (batch, depth) => {
42298
42792
  const batchArgs = [...baseArgs, ...batch];
42299
42793
  try {
42300
- return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes), project, rootDirectory);
42794
+ return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes, signal), project, rootDirectory);
42301
42795
  } catch (error) {
42302
42796
  if (!isSplittableReactDoctorError(error)) throw error;
42303
- if (batch.length <= 1) {
42797
+ const splitBudgetExhausted = Date.now() >= splitDeadlineMs || depth >= splitMaxDepth;
42798
+ if (batch.length <= 1 || splitBudgetExhausted) {
42304
42799
  droppedFiles.push(...batch);
42305
- if (firstDropReason === null) firstDropReason = error.message;
42800
+ if (firstDropReason === null) firstDropReason = splitBudgetExhausted && batch.length > 1 ? `${error.message} (split budget exhausted after ${splitMaxDepth} levels / ${splitTotalBudgetMs / MILLISECONDS_PER_SECOND}s)` : error.message;
42306
42801
  return [];
42307
42802
  }
42308
42803
  const splitIndex = Math.ceil(batch.length / 2);
42309
- return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
42804
+ return [...await spawnLintBatch(batch.slice(0, splitIndex), depth + 1), ...await spawnLintBatch(batch.slice(splitIndex), depth + 1)];
42310
42805
  }
42311
42806
  };
42312
42807
  let startedFileCount = 0;
@@ -42323,7 +42818,7 @@ const spawnLintBatches = async (input) => {
42323
42818
  try {
42324
42819
  const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
42325
42820
  startedFileCount += batch.length;
42326
- const batchDiagnostics = await spawnLintBatch(batch);
42821
+ const batchDiagnostics = await spawnLintBatch(batch, 0);
42327
42822
  scannedFileCount += batch.length;
42328
42823
  if (onFileProgress) {
42329
42824
  displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
@@ -42384,6 +42879,22 @@ const validateRuleRegistration = () => {
42384
42879
  ].filter((entry) => entry !== null).join("; ");
42385
42880
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
42386
42881
  };
42882
+ const hashFileContents = (filePath) => {
42883
+ try {
42884
+ return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
42885
+ } catch {
42886
+ return null;
42887
+ }
42888
+ };
42889
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
42890
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
42891
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
42892
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
42893
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
42894
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
42895
+ return Path.join(os.tmpdir(), "react-doctor-cache", projectCacheSubdir(projectDirectory));
42896
+ };
42897
+ const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
42387
42898
  /**
42388
42899
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
42389
42900
  * the runner: once for the primary scan, once for the
@@ -42442,7 +42953,7 @@ const reactHooksJsPluginDropNote = (error) => {
42442
42953
  * 6. always restore disable directives + clean up the temp dir
42443
42954
  */
42444
42955
  const runOxlint = async (options) => {
42445
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, spawnTimeoutMs, outputMaxBytes } = options;
42956
+ 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;
42446
42957
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
42447
42958
  const severityControls = buildRuleSeverityControls(userConfig);
42448
42959
  validateRuleRegistration();
@@ -42459,30 +42970,156 @@ const runOxlint = async (options) => {
42459
42970
  serverAuthFunctionNames,
42460
42971
  severityControls,
42461
42972
  userPlugins,
42462
- disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
42973
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
42974
+ ruleSelection: overrides.ruleSelection
42463
42975
  });
42464
42976
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
42465
42977
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
42466
42978
  const configPath = Path.join(configDirectory, "oxlintrc.json");
42467
42979
  try {
42468
- const baseArgs = [
42469
- resolveOxlintBinary(),
42470
- "-c",
42471
- configPath,
42472
- "--format",
42473
- "json"
42474
- ];
42980
+ const oxlintBinary = resolveOxlintBinary();
42981
+ const sharedArgs = [];
42982
+ let tsconfigContent = null;
42475
42983
  if (project.hasTypeScript) {
42476
42984
  const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
42477
- if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
42985
+ if (tsconfigRelativePath) {
42986
+ sharedArgs.push("--tsconfig", tsconfigRelativePath);
42987
+ try {
42988
+ tsconfigContent = NFS.readFileSync(Path.resolve(rootDirectory, tsconfigRelativePath), "utf8");
42989
+ } catch {
42990
+ tsconfigContent = null;
42991
+ }
42992
+ }
42478
42993
  }
42479
42994
  const combinedPatterns = collectIgnorePatterns(rootDirectory);
42480
42995
  if (combinedPatterns.length > 0) {
42481
42996
  const combinedIgnorePath = Path.join(configDirectory, "combined.ignore");
42482
42997
  NFS.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
42483
- baseArgs.push("--ignore-path", combinedIgnorePath);
42998
+ sharedArgs.push("--ignore-path", combinedIgnorePath);
42484
42999
  }
42485
- const fileBatches = batchIncludePaths(baseArgs, includePaths !== void 0 ? includePaths : listSourceFiles(rootDirectory));
43000
+ const makeBaseArgs = (oxlintConfigPath) => [
43001
+ oxlintBinary,
43002
+ "-c",
43003
+ oxlintConfigPath,
43004
+ "--format",
43005
+ "json",
43006
+ ...sharedArgs
43007
+ ];
43008
+ const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
43009
+ const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
43010
+ const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
43011
+ if (files.length === 0) return {
43012
+ diagnostics: [],
43013
+ didDropReactHooksJsPlugin: false,
43014
+ hadPartialFailure: false
43015
+ };
43016
+ let hadPartialFailure = false;
43017
+ const reportPartialFailure = (reason) => {
43018
+ hadPartialFailure = true;
43019
+ onPartialFailure?.(reason);
43020
+ };
43021
+ const passConfigPath = Path.join(configDirectory, configFileName);
43022
+ const passBaseArgs = makeBaseArgs(passConfigPath);
43023
+ const passFileBatches = batchIncludePaths(passBaseArgs, files);
43024
+ const spawnPass = () => spawnLintBatches({
43025
+ baseArgs: passBaseArgs,
43026
+ fileBatches: passFileBatches,
43027
+ rootDirectory,
43028
+ nodeBinaryPath,
43029
+ project,
43030
+ onPartialFailure: reportPartialFailure,
43031
+ onFileProgress: fileProgress,
43032
+ spawnTimeoutMs,
43033
+ outputMaxBytes,
43034
+ concurrency: options.concurrency,
43035
+ signal: options.signal
43036
+ });
43037
+ writeOxlintConfig(passConfigPath, buildConfigForPass({}));
43038
+ try {
43039
+ return {
43040
+ diagnostics: await spawnPass(),
43041
+ didDropReactHooksJsPlugin: false,
43042
+ hadPartialFailure
43043
+ };
43044
+ } catch (error) {
43045
+ const reactHooksJsDropNote = reactHooksJsPluginDropNote(error);
43046
+ if (reactHooksJsDropNote === null) throw error;
43047
+ writeOxlintConfig(passConfigPath, buildConfigForPass({ disableReactHooksJsPlugin: true }));
43048
+ const diagnostics = await spawnPass();
43049
+ reportPartialFailure(reactHooksJsDropNote);
43050
+ return {
43051
+ diagnostics,
43052
+ didDropReactHooksJsPlugin: true,
43053
+ hadPartialFailure
43054
+ };
43055
+ }
43056
+ };
43057
+ if (perFileLintCacheEnabled && respectInlineDisables && !project.hasReactCompiler && extendsPaths.length === 0 && userPlugins.length === 0) {
43058
+ const rulesetHash = computeRulesetHash({
43059
+ config: buildConfig({
43060
+ extendsPaths: [],
43061
+ ruleSelection: "cacheable"
43062
+ }),
43063
+ toolchainVersions: resolveOxlintToolchainVersions(),
43064
+ ignorePatterns: combinedPatterns,
43065
+ tsconfigContent
43066
+ });
43067
+ const cache = createFileLintCache(resolveReactDoctorCacheDir(rootDirectory), rulesetHash);
43068
+ const cacheKeyByFile = /* @__PURE__ */ new Map();
43069
+ const missFiles = [];
43070
+ const replayedDiagnostics = [];
43071
+ for (const candidateFile of candidateFiles) {
43072
+ const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
43073
+ if (contentHash === null) {
43074
+ missFiles.push(candidateFile);
43075
+ continue;
43076
+ }
43077
+ const cacheKey = `${candidateFile.replaceAll("\\", "/")}${contentHash}`;
43078
+ cacheKeyByFile.set(candidateFile, cacheKey);
43079
+ const cachedDiagnostics = cache.lookup(cacheKey);
43080
+ if (cachedDiagnostics === null) missFiles.push(candidateFile);
43081
+ else replayedDiagnostics.push(...cachedDiagnostics);
43082
+ }
43083
+ const cacheHitFileCount = candidateFiles.length - missFiles.length;
43084
+ const cacheableResult = await runConfigOverFiles((overrides) => buildConfig({
43085
+ extendsPaths: [],
43086
+ ruleSelection: "cacheable",
43087
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
43088
+ }), "oxlintrc.cacheable.json", missFiles, void 0);
43089
+ const sidecarResult = await runConfigOverFiles(() => buildConfig({
43090
+ extendsPaths: [],
43091
+ ruleSelection: "sidecar"
43092
+ }), "oxlintrc.sidecar.json", candidateFiles, options.onFileProgress);
43093
+ onCacheStats?.(cacheHitFileCount, candidateFiles.length);
43094
+ const missFileByNormalizedPath = /* @__PURE__ */ new Map();
43095
+ for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
43096
+ const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
43097
+ let isAttributionSound = true;
43098
+ for (const diagnostic of cacheableResult.diagnostics) {
43099
+ const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
43100
+ if (missFile === void 0) {
43101
+ isAttributionSound = false;
43102
+ break;
43103
+ }
43104
+ const fileDiagnostics = freshDiagnosticsByFile.get(missFile) ?? [];
43105
+ fileDiagnostics.push(diagnostic);
43106
+ freshDiagnosticsByFile.set(missFile, fileDiagnostics);
43107
+ }
43108
+ if (!cacheableResult.didDropReactHooksJsPlugin && !cacheableResult.hadPartialFailure && isAttributionSound) {
43109
+ for (const missFile of missFiles) {
43110
+ const cacheKey = cacheKeyByFile.get(missFile);
43111
+ if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
43112
+ }
43113
+ cache.persist();
43114
+ }
43115
+ return dedupeDiagnostics([
43116
+ ...replayedDiagnostics,
43117
+ ...cacheableResult.diagnostics,
43118
+ ...sidecarResult.diagnostics
43119
+ ]);
43120
+ }
43121
+ const baseArgs = makeBaseArgs(configPath);
43122
+ const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
42486
43123
  const runBatches = () => spawnLintBatches({
42487
43124
  baseArgs,
42488
43125
  fileBatches,
@@ -42493,7 +43130,8 @@ const runOxlint = async (options) => {
42493
43130
  onFileProgress: options.onFileProgress,
42494
43131
  spawnTimeoutMs,
42495
43132
  outputMaxBytes,
42496
- concurrency: options.concurrency
43133
+ concurrency: options.concurrency,
43134
+ signal: options.signal
42497
43135
  });
42498
43136
  writeOxlintConfig(configPath, buildConfig({ extendsPaths }));
42499
43137
  try {
@@ -42572,9 +43210,11 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
42572
43210
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
42573
43211
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
42574
43212
  const concurrency = yield* OxlintConcurrency;
43213
+ const lintBatchOrdering = yield* LintBatchOrdering;
43214
+ const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
42575
43215
  const collectedFailures = [];
42576
43216
  const diagnostics = yield* tryPromise({
42577
- try: () => runOxlint({
43217
+ try: (signal) => runOxlint({
42578
43218
  rootDirectory: input.rootDirectory,
42579
43219
  project: input.project,
42580
43220
  includePaths: input.includePaths ? [...input.includePaths] : void 0,
@@ -42589,9 +43229,13 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
42589
43229
  collectedFailures.push(reason);
42590
43230
  },
42591
43231
  onFileProgress: input.onFileProgress,
43232
+ perFileLintCacheEnabled,
43233
+ onCacheStats: input.onCacheStats,
42592
43234
  spawnTimeoutMs,
42593
43235
  outputMaxBytes,
42594
- concurrency
43236
+ concurrency,
43237
+ signal,
43238
+ lintBatchOrdering
42595
43239
  }),
42596
43240
  catch: ensureReactDoctorError
42597
43241
  });
@@ -42983,14 +43627,49 @@ const parseArtifactFromBody = (body) => {
42983
43627
  }
42984
43628
  return null;
42985
43629
  };
42986
- const fetchSocketArtifact = (dependency) => tryPromise(async (signal) => {
43630
+ const isSupplyChainCacheDisabled = () => {
43631
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
43632
+ return noCache === "1" || noCache === "true";
43633
+ };
43634
+ const supplyChainCacheFile = (cacheDirectory, dependency) => {
43635
+ const purlHash = crypto.createHash("sha256").update(toPurl(dependency)).digest("hex").slice(0, 16);
43636
+ return Path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR, `${purlHash}.json`);
43637
+ };
43638
+ const readCachedSocketBody = (cacheFile) => {
43639
+ try {
43640
+ const entry = JSON.parse(NFS.readFileSync(cacheFile, "utf-8"));
43641
+ 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;
43642
+ } catch {}
43643
+ return null;
43644
+ };
43645
+ const writeCachedSocketBody = (cacheFile, body) => {
43646
+ try {
43647
+ NFS.mkdirSync(Path.dirname(cacheFile), { recursive: true });
43648
+ NFS.writeFileSync(cacheFile, JSON.stringify({
43649
+ fetchedAtMs: Date.now(),
43650
+ body
43651
+ }));
43652
+ } catch {}
43653
+ };
43654
+ const fetchSocketArtifact = (dependency, cacheDirectory) => tryPromise(async (signal) => {
43655
+ const cacheFile = cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency);
43656
+ if (cacheFile !== null) {
43657
+ const cachedBody = readCachedSocketBody(cacheFile);
43658
+ if (cachedBody !== null) {
43659
+ const cachedArtifact = parseArtifactFromBody(cachedBody);
43660
+ if (cachedArtifact !== null) return cachedArtifact;
43661
+ }
43662
+ }
42987
43663
  const requestUrl = `${SOCKET_FREE_PURL_API_BASE}/${encodeURIComponent(toPurl(dependency))}`;
42988
43664
  const response = await fetch(requestUrl, {
42989
43665
  headers: { "User-Agent": SOCKET_FREE_USER_AGENT },
42990
43666
  signal
42991
43667
  });
42992
43668
  if (!response.ok) return null;
42993
- return parseArtifactFromBody(await response.text());
43669
+ const body = await response.text();
43670
+ const artifact = parseArtifactFromBody(body);
43671
+ if (artifact !== null && cacheFile !== null) writeCachedSocketBody(cacheFile, body);
43672
+ return artifact;
42994
43673
  }).pipe(timeout(FETCH_TIMEOUT_MS), orElseSucceed(() => null), tap$1((artifact) => {
42995
43674
  const scoreAttributes = {};
42996
43675
  if (artifact !== null) {
@@ -43095,7 +43774,8 @@ const checkSupplyChain = (input) => gen(function* () {
43095
43774
  const packageJsonPath = Path.join(input.rootDirectory, "package.json");
43096
43775
  const dependencies = collectDependenciesToScore(readPackageJson$1(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
43097
43776
  if (dependencies.length === 0) return [];
43098
- const artifacts = yield* forEach$1(dependencies, fetchSocketArtifact, { concurrency: 8 });
43777
+ const cacheDirectory = isSupplyChainCacheDisabled() ? null : resolveReactDoctorCacheDir(input.rootDirectory);
43778
+ const artifacts = yield* forEach$1(dependencies, (dependency) => fetchSocketArtifact(dependency, cacheDirectory), { concurrency: 8 }).pipe(timeoutOption(input.totalTimeoutMs ?? 9e4), map$3((maybeArtifacts) => getOrElse$1(maybeArtifacts, () => [])));
43099
43779
  const diagnostics = [];
43100
43780
  for (let index = 0; index < dependencies.length; index += 1) {
43101
43781
  const artifact = artifacts[index];
@@ -43120,6 +43800,10 @@ const checkSupplyChain = (input) => gen(function* () {
43120
43800
  * The underlying `checkSupplyChain` Effect is total/fail-open — per-package
43121
43801
  * timeouts and network failures recover to "skip" — so the stream never
43122
43802
  * fails, mirroring `DeadCode`'s stream shape so the two compose the same way.
43803
+ * The orchestrator (`run-inspect.ts`) consumes this stream on a background
43804
+ * fiber whose network time overlaps the lint pass, joined under a generous
43805
+ * wall-clock budget; a budget expiry is the same fail-open outcome as a Socket
43806
+ * outage.
43123
43807
  */
43124
43808
  var SupplyChain = class SupplyChain extends Service()("react-doctor/SupplyChain") {
43125
43809
  static layerNode = succeed$3(SupplyChain, SupplyChain.of({ run: (input) => unwrap(checkSupplyChain(input).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)), withSpan("SupplyChain.run"))) }));
@@ -43178,18 +43862,42 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
43178
43862
  *
43179
43863
  * Phases:
43180
43864
  *
43181
- * 1. Config.resolve(directory) → Project.discover → Git metadata
43865
+ * 1. Config.resolve(directory) → Project.discover → Git metadata.
43866
+ * The GitHub viewer-permission lookup is forked onto a background
43867
+ * fiber here and joined late (it feeds score metadata, not
43868
+ * diagnostics).
43182
43869
  * 2. beforeLint hook (e.g. CLI renders the project-detection block)
43183
43870
  * 3. environment checks (reduced-motion + pnpm hardening +
43184
- * expo/react-native + security scan)
43185
- * 4. Linter.run + DeadCode.run forked as concurrent fibers so
43186
- * their wall-clock times overlap. Progress spinners stay
43187
- * sequential (lint first, then dead-code) for clean terminal
43188
- * output. GitHub viewer permission also runs as a background
43189
- * fiber during this phase.
43190
- * 5. afterLint hook
43191
- * 6. Reporter.finalize
43192
- * 7. Score.compute against the surface-filtered diagnostic set
43871
+ * expo/react-native + security scan), collected synchronously
43872
+ * 4. The supply-chain check (Socket.dev) is forked onto a background
43873
+ * fiber so its ~100% network-bound time overlaps the ~100%
43874
+ * CPU/subprocess-bound lint pass below, collapsing two serial
43875
+ * phases into roughly `max(supplyChain, lint)`. It is capped by
43876
+ * `SupplyChainOverlapTimeoutMs` (measured from fork) so a hung
43877
+ * socket can't drag out its join; on timeout it fails open to no
43878
+ * diagnostics — the same outcome class as a Socket outage.
43879
+ * 5. Linter.run runs; DeadCode.run runs concurrently (forked child
43880
+ * fiber) ONLY when the memory gate has headroom to run the 8 GB
43881
+ * dead-code child alongside the oxlint workers — or when overlap is
43882
+ * forced via REACT_DOCTOR_DEAD_CODE_OVERLAP. Otherwise dead-code
43883
+ * runs sequentially after lint, exactly as it did pre-overlap. The
43884
+ * fiber is joined (or interrupted, SIGKILLing its worker, on lint
43885
+ * failure) before diagnostics are concatenated. The afterLint hook
43886
+ * fires between lint and dead-code. Progress spinner labels AND the
43887
+ * final diagnostic / score order stay independent of execution
43888
+ * order, so terminal output is identical either way; supply-chain
43889
+ * rides alongside without a spinner.
43890
+ * 6. Join the supply-chain fiber, then assemble the diagnostics in a
43891
+ * FIXED order (env, supply-chain, lint, dead-code) so the output is
43892
+ * byte-identical regardless of which fiber settled first. The
43893
+ * viewer-permission fiber is joined later, during score-metadata
43894
+ * assembly (it feeds score metadata, not diagnostics). The per-element
43895
+ * `Reporter.emit` side-channel now interleaves supply-chain with lint
43896
+ * emits, so capture-order assertions must target the deterministic
43897
+ * concat below, not emit order (production `Reporter.layerNoop` makes
43898
+ * emit a no-op).
43899
+ * 7. Reporter.finalize
43900
+ * 8. Score.compute against the surface-filtered diagnostic set
43193
43901
  *
43194
43902
  * The orchestrator owns spinner lifecycle via `Progress`; callers
43195
43903
  * choose `Progress.layerOra(...)` for CLI feedback or
@@ -43247,10 +43955,21 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43247
43955
  ignoredTags: input.ignoredTags
43248
43956
  })
43249
43957
  ])));
43250
- const supplyChainCollected = !isDiffMode || (input.supplyChainManifestChanged ?? false) ? yield* runCollect(applyPerElementPipeline(supplyChainService.run({
43958
+ const shouldRunSupplyChain = !isDiffMode || (input.supplyChainManifestChanged ?? false);
43959
+ const supplyChainOverlapTimeout = yield* SupplyChainOverlapTimeoutMs;
43960
+ const supplyChainFiber = yield* forkChild(shouldRunSupplyChain ? runCollect(applyPerElementPipeline(supplyChainService.run({
43251
43961
  rootDirectory: scanDirectory,
43252
43962
  userConfig: resolvedConfig.config
43253
- }))) : [];
43963
+ }))).pipe(map$3((diagnostics) => ({
43964
+ diagnostics,
43965
+ timedOut: false
43966
+ })), timeout(supplyChainOverlapTimeout), orElseSucceed(() => ({
43967
+ diagnostics: [],
43968
+ timedOut: true
43969
+ }))) : succeed$2({
43970
+ diagnostics: [],
43971
+ timedOut: false
43972
+ }));
43254
43973
  const lintFailure = yield* make$13({
43255
43974
  didFail: false,
43256
43975
  reason: null,
@@ -43261,12 +43980,49 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43261
43980
  didFail: false,
43262
43981
  reason: null
43263
43982
  });
43264
- const scanConcurrency = yield* OxlintConcurrency;
43983
+ const scanConcurrency = resolveScanConcurrency(yield* OxlintConcurrency);
43984
+ const lintPhaseTimeoutMs = yield* LintPhaseTimeoutMs;
43985
+ const deadCodePhaseTimeoutMs = yield* DeadCodePhaseTimeoutMs;
43986
+ const resolveDeadCodePhaseTimeoutMs = (scaledPhaseTimeoutMs) => deadCodePhaseTimeoutMs === 15e4 ? scaledPhaseTimeoutMs : deadCodePhaseTimeoutMs;
43265
43987
  const workerCountSuffix = scanConcurrency > 1 ? ` ${highlighter.dim(`[~${scanConcurrency} workers]`)}` : "";
43988
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
43989
+ const deadCodeOverlapMode = yield* DeadCodeOverlap;
43990
+ const shouldOverlapDeadCode = shouldRunDeadCode && deadCodeOverlapMode === "on";
43991
+ const deadCodeParseConcurrency = shouldOverlapDeadCode ? Math.max(1, Math.floor(scanConcurrency * DEAD_CODE_OVERLAP_PARSE_SHARE)) : void 0;
43992
+ const lintConcurrency = deadCodeParseConcurrency === void 0 ? scanConcurrency : Math.max(1, scanConcurrency - deadCodeParseConcurrency);
43993
+ const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
43994
+ rootDirectory: scanDirectory,
43995
+ userConfig: resolvedConfig.config,
43996
+ parseConcurrency: deadCodeParseConcurrency,
43997
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
43998
+ }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43999
+ yield* set(deadCodeFailure, {
44000
+ didFail: true,
44001
+ reason: error.message
44002
+ });
44003
+ return empty$4;
44004
+ })))))).pipe(timeoutOption(deadCodeTimeout.phaseTimeoutMs), flatMap$2(match$3({
44005
+ onNone: () => set(deadCodeFailure, {
44006
+ didFail: true,
44007
+ reason: `Dead-code analysis exceeded ${Math.round(deadCodeTimeout.phaseTimeoutMs / MILLISECONDS_PER_SECOND)}s and was skipped.`
44008
+ }).pipe(as([])),
44009
+ onSome: succeed$2
44010
+ })));
44011
+ const overlapDeadCodeTimeout = resolveDeadCodeTimeout({
44012
+ sourceFileCount: project.sourceFileCount,
44013
+ deadCodeConcurrency: deadCodeParseConcurrency ?? scanConcurrency,
44014
+ fullConcurrency: scanConcurrency
44015
+ });
44016
+ const deadCodeFiber = shouldOverlapDeadCode ? yield* forkChild(buildCollectDeadCode({
44017
+ workerTimeoutMs: overlapDeadCodeTimeout.workerTimeoutMs,
44018
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(overlapDeadCodeTimeout.phaseTimeoutMs)
44019
+ })) : null;
43266
44020
  const scanProgress = yield* progressService.start("Scanning...");
43267
44021
  const scanStartTime = Date.now();
43268
44022
  let lastReportedTotalFileCount = 0;
43269
- const lintCollected = yield* runCollect(applyPerElementPipeline(linterService.run({
44023
+ let lintCacheHitFileCount = null;
44024
+ let lintCacheTotalFileCount = null;
44025
+ const baseLintStream = linterService.run({
43270
44026
  rootDirectory: scanDirectory,
43271
44027
  project,
43272
44028
  includePaths: lintIncludePaths ?? void 0,
@@ -43280,6 +44036,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43280
44036
  onFileProgress: (scannedFileCount, totalFileCount) => {
43281
44037
  lastReportedTotalFileCount = totalFileCount;
43282
44038
  runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
44039
+ },
44040
+ onCacheStats: (cacheHitFileCount, totalConsideredFileCount) => {
44041
+ lintCacheHitFileCount = cacheHitFileCount;
44042
+ lintCacheTotalFileCount = totalConsideredFileCount;
43283
44043
  }
43284
44044
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43285
44045
  yield* set(lintFailure, {
@@ -43289,36 +44049,54 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43289
44049
  reasonKind: error.reason._tag === "OxlintUnavailable" ? error.reason.kind : null
43290
44050
  });
43291
44051
  return empty$4;
43292
- }))))));
44052
+ }))));
44053
+ const lintCollected = yield* runCollect(applyPerElementPipeline(shouldOverlapDeadCode ? baseLintStream.pipe(provideService(OxlintConcurrency, lintConcurrency)) : baseLintStream)).pipe(timeoutOption(lintPhaseTimeoutMs), flatMap$2(match$3({
44054
+ onNone: () => set(lintFailure, {
44055
+ didFail: true,
44056
+ reason: `Lint analysis exceeded ${lintPhaseTimeoutMs / MILLISECONDS_PER_SECOND}s and was skipped.`,
44057
+ reasonTag: "OxlintBatchExceeded",
44058
+ reasonKind: null
44059
+ }).pipe(as([])),
44060
+ onSome: succeed$2
44061
+ })));
43293
44062
  const lintFailureState = yield* get$2(lintFailure);
43294
44063
  yield* afterLint(lintFailureState.didFail);
43295
44064
  if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
43296
44065
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
43297
44066
  const scannedFilesLabel = `${totalFileCount} ${totalFileCount === 1 ? "file" : "files"}`;
43298
- const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
43299
- const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`).pipe(andThen(runCollect(applyPerElementPipeline(deadCodeService.run({
43300
- rootDirectory: scanDirectory,
43301
- userConfig: resolvedConfig.config
43302
- }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43303
- yield* set(deadCodeFailure, {
43304
- didFail: true,
43305
- reason: error.message
44067
+ let deadCodeCollected = [];
44068
+ if (lintFailureState.didFail) {
44069
+ if (deadCodeFiber !== null) yield* interrupt(deadCodeFiber);
44070
+ } else if (shouldRunDeadCode) {
44071
+ yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`);
44072
+ const sequentialDeadCodeTimeout = resolveDeadCodeTimeout({
44073
+ sourceFileCount: totalFileCount,
44074
+ deadCodeConcurrency: scanConcurrency,
44075
+ fullConcurrency: scanConcurrency
43306
44076
  });
43307
- return empty$4;
43308
- }))))))));
43309
- const deadCodeFailureState = yield* get$2(deadCodeFailure);
44077
+ deadCodeCollected = deadCodeFiber !== null ? yield* join(deadCodeFiber) : yield* buildCollectDeadCode({
44078
+ workerTimeoutMs: sequentialDeadCodeTimeout.workerTimeoutMs,
44079
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(sequentialDeadCodeTimeout.phaseTimeoutMs)
44080
+ });
44081
+ }
44082
+ const deadCodeFailureState = lintFailureState.didFail ? {
44083
+ didFail: false,
44084
+ reason: null
44085
+ } : yield* get$2(deadCodeFailure);
43310
44086
  const scanElapsedMilliseconds = Date.now() - scanStartTime;
43311
44087
  const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1);
43312
44088
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
43313
44089
  else if (input.suppressScanSummary) yield* scanProgress.stop();
43314
44090
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
44091
+ const supplyChainResult = yield* join(supplyChainFiber);
44092
+ const supplyChainCollected = supplyChainResult.diagnostics;
43315
44093
  yield* reporterService.finalize;
43316
- const finalDiagnostics = assignFixGroups([
44094
+ const finalDiagnostics = sortDiagnosticsStable(assignFixGroups([
43317
44095
  ...envCollected,
43318
44096
  ...supplyChainCollected,
43319
44097
  ...lintCollected,
43320
44098
  ...deadCodeCollected
43321
- ]);
44099
+ ]));
43322
44100
  const githubViewerPermission = yield* join(githubViewerPermissionFiber);
43323
44101
  const scoreMetadata = {
43324
44102
  ...repo !== null ? { repo } : {},
@@ -43354,9 +44132,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43354
44132
  lintPartialFailures,
43355
44133
  didDeadCodeFail: deadCodeFailureState.didFail,
43356
44134
  deadCodeFailureReason: deadCodeFailureState.reason,
44135
+ deadCodeOverlapped: shouldOverlapDeadCode,
43357
44136
  scannedFileCount: totalFileCount,
43358
44137
  scannedFilePaths,
43359
- scanElapsedMilliseconds
44138
+ scanElapsedMilliseconds,
44139
+ scanConcurrency,
44140
+ supplyChainOverlapTimedOut: supplyChainResult.timedOut,
44141
+ lintCacheHitFileCount,
44142
+ lintCacheTotalFileCount
43360
44143
  };
43361
44144
  }).pipe(withSpan("runInspect", { attributes: {
43362
44145
  "inspect.directory": input.directory,
@@ -43364,7 +44147,7 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43364
44147
  "inspect.runDeadCode": input.runDeadCode,
43365
44148
  "inspect.isCi": input.isCi,
43366
44149
  "inspect.scoreSurface": input.scoreSurface ?? "score"
43367
- } }));
44150
+ } }), (scanProgram) => flatMap$2(ScanDeadlineMs, (scanDeadlineMs) => scanProgram.pipe(timeout(scanDeadlineMs), catchTag$1("TimeoutError", () => new ReactDoctorError({ reason: new ScanDeadlineExceeded({ detail: `${scanDeadlineMs / MILLISECONDS_PER_SECOND}s elapsed` }) })))));
43368
44151
  const parseNodeVersion = (versionString) => {
43369
44152
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
43370
44153
  return {
@@ -44070,6 +44853,7 @@ const NANOSECONDS_PER_SECOND = 1000000000n;
44070
44853
  const METRIC = {
44071
44854
  cliInvoked: "cli.invoked",
44072
44855
  cliError: "cli.error",
44856
+ cliEnvironmentError: "cli.env_error",
44073
44857
  projectDetected: "project.detected",
44074
44858
  projectPathSelected: "project.path_selected",
44075
44859
  projectConfigSelected: "project.config_selected",
@@ -44142,7 +44926,7 @@ const makeNoopConsole = () => ({
44142
44926
  });
44143
44927
  //#endregion
44144
44928
  //#region src/cli/utils/version.ts
44145
- const VERSION = "0.5.7";
44929
+ const VERSION = "0.5.8-dev.229ea2e";
44146
44930
  //#endregion
44147
44931
  //#region src/cli/utils/json-mode.ts
44148
44932
  let context = null;
@@ -44295,7 +45079,8 @@ const buildRunContext = () => {
44295
45079
  terminalKind: detectTerminalKind(),
44296
45080
  jsonMode: isJsonModeActive(),
44297
45081
  debug: isDebugFlagEnabled(),
44298
- invokedVia: detectInvokedVia()
45082
+ invokedVia: detectInvokedVia(),
45083
+ lintBatchOrdering: resolveLintBatchOrdering()
44299
45084
  };
44300
45085
  };
44301
45086
  //#endregion
@@ -44368,7 +45153,8 @@ const buildSentryScope = (runContext = buildRunContext()) => {
44368
45153
  jsonMode: runContext.jsonMode,
44369
45154
  debug: runContext.debug,
44370
45155
  invokedVia: runContext.invokedVia,
44371
- nodeMajor: runContext.nodeMajor
45156
+ nodeMajor: runContext.nodeMajor,
45157
+ lintBatchOrdering: runContext.lintBatchOrdering
44372
45158
  };
44373
45159
  const contexts = { run: { ...runContext } };
44374
45160
  const projectInfo = getSentryProjectInfo();
@@ -44504,13 +45290,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
44504
45290
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
44505
45291
  * standard `SENTRY_RELEASE` override.
44506
45292
  */
44507
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.7`;
45293
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.8-dev.229ea2e`;
44508
45294
  /**
44509
45295
  * Deployment environment shown in Sentry's environment filter. Defaults to
44510
45296
  * `production` for tagged releases and `development` for dev/unbuilt versions,
44511
45297
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
44512
45298
  */
44513
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.7") ? "development" : "production");
45299
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.8-dev.229ea2e") ? "development" : "production");
44514
45300
  /**
44515
45301
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
44516
45302
  * (set to `0` to disable tracing) and falls back to
@@ -44713,7 +45499,7 @@ const externalSpanFrom = (sentrySpan) => {
44713
45499
  * in-memory tracer — identical to the prior default behavior.
44714
45500
  */
44715
45501
  const applyObservability = (program, rootSentrySpan) => {
44716
- if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(provideService(ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(provide(layerOtlp));
45502
+ if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(provideService$2(ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(provide(layerOtlp));
44717
45503
  if (rootSentrySpan) return program.pipe(withTracer(makeSentryTracer(rootSentrySpan)));
44718
45504
  return program.pipe(provide(layerOtlp));
44719
45505
  };
@@ -48186,6 +48972,9 @@ const buildOutcomeAttributes = (input) => {
48186
48972
  "migration.largestRuleBucketSites": largestRuleBucket ? largestRuleBucket.siteCount : null,
48187
48973
  "migration.largestRuleBucketRule": largestRuleBucket ? largestRuleBucket.ruleKey : null,
48188
48974
  scannedFileCount: result.scannedFileCount ?? null,
48975
+ lintCacheHitFiles: result.lintCacheHitFileCount ?? null,
48976
+ lintCacheTotalFiles: result.lintCacheTotalFileCount ?? null,
48977
+ lintCacheHitRatio: result.lintCacheTotalFileCount != null && result.lintCacheTotalFileCount > 0 ? (result.lintCacheHitFileCount ?? 0) / result.lintCacheTotalFileCount : null,
48189
48978
  elapsedMs: result.elapsedMilliseconds,
48190
48979
  scanPhaseMs: result.scanElapsedMilliseconds ?? null,
48191
48980
  score: result.score ? result.score.score : null,
@@ -48195,7 +48984,10 @@ const buildOutcomeAttributes = (input) => {
48195
48984
  didLintFail: input.didLintFail ?? null,
48196
48985
  lintFailureReasonKind: input.lintFailureReasonKind ?? null,
48197
48986
  lintPartialFailureCount: input.lintPartialFailureCount ?? null,
48198
- didDeadCodeFail: input.didDeadCodeFail ?? null
48987
+ lintDroppedFileCount: input.lintDroppedFileCount ?? null,
48988
+ didDeadCodeFail: input.didDeadCodeFail ?? null,
48989
+ supplyChainOverlapTimedOut: input.supplyChainOverlapTimedOut ?? null,
48990
+ deadCodeOverlapped: input.deadCodeOverlapped ?? null
48199
48991
  };
48200
48992
  for (const [category, count] of countByCategory) attributes[`diag.category.${toCategoryKey(category)}`] = count;
48201
48993
  if (result.baselineDelta) {
@@ -48264,6 +49056,32 @@ const recordRunEvent = (rootSpan, input) => {
48264
49056
  } catch {}
48265
49057
  };
48266
49058
  //#endregion
49059
+ //#region src/cli/utils/resolve-worker-telemetry.ts
49060
+ /**
49061
+ * Projects the resolved lint worker count into the `(workerCount, parallel)`
49062
+ * telemetry pair. `resolvedWorkerCount` is the count the scan actually fanned
49063
+ * out to (`InspectOutput.scanConcurrency`); `pinnedConcurrency` is the caller's
49064
+ * `inspect({ concurrency })` pin, used as the fallback when no scan resolved a
49065
+ * count (the pre-scan failure path, or a cache entry persisted before the
49066
+ * resolved count was tracked). `parallel` is derived from the count — NOT from
49067
+ * whether a count was pinned — so the common auto path (no pin) still reports
49068
+ * parallelism correctly instead of always reading `false`.
49069
+ */
49070
+ const resolveWorkerTelemetry = (resolvedWorkerCount, pinnedConcurrency) => {
49071
+ const workerCount = resolvedWorkerCount ?? pinnedConcurrency;
49072
+ return {
49073
+ workerCount,
49074
+ parallel: workerCount !== void 0 && workerCount > 1
49075
+ };
49076
+ };
49077
+ //#endregion
49078
+ //#region src/cli/utils/count-dropped-lint-files.ts
49079
+ const DROPPED_FILES_MESSAGE_PATTERN = /^(\d+) file\(s\) failed to lint and were skipped/;
49080
+ const countDroppedLintFiles = (lintPartialFailures) => lintPartialFailures.reduce((total, message) => {
49081
+ const match = DROPPED_FILES_MESSAGE_PATTERN.exec(message);
49082
+ return match ? total + Number(match[1]) : total;
49083
+ }, 0);
49084
+ //#endregion
48267
49085
  //#region src/cli/utils/path-format.ts
48268
49086
  const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
48269
49087
  //#endregion
@@ -48880,25 +49698,192 @@ const canAnimateOnboarding = (stream = process.stdout) => {
48880
49698
  return !isGitHookEnvironment() && !isCiEnvironment();
48881
49699
  };
48882
49700
  //#endregion
48883
- //#region src/cli/utils/onboarding-state.ts
48884
- const ONBOARDED_AT_KEY = "onboardedAt";
48885
- const getOnboardingStore = (options = {}) => new Conf({
49701
+ //#region src/cli/utils/now-iso.ts
49702
+ const nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
49703
+ const ONBOARDING_EVENT = "onboarding";
49704
+ const CI_PITCH_EVENT = "ci-pitch";
49705
+ const ACTION_UPGRADE_EVENT = "action-upgrade-v2";
49706
+ const SETUP_HINT_EVENT = "setup-hint";
49707
+ const HANDOFF_TARGET_PREFERENCE_ID = "handoff-target";
49708
+ const INSTALL_AGENTS_PREFERENCE_ID = "install-agents";
49709
+ const foldLegacyDecisions = (projects, legacy, eventId) => {
49710
+ for (const [hash, record] of Object.entries(legacy ?? {})) {
49711
+ const existing = projects[hash] ?? { rootDirectory: record.rootDirectory ?? "" };
49712
+ projects[hash] = {
49713
+ ...existing,
49714
+ events: {
49715
+ ...existing.events,
49716
+ [eventId]: {
49717
+ firedAt: record.at ?? nowIso(),
49718
+ version: 1,
49719
+ ...record.outcome ? { outcome: record.outcome } : {}
49720
+ }
49721
+ }
49722
+ };
49723
+ }
49724
+ };
49725
+ const migrateCliState = (state) => {
49726
+ if (state.schemaVersion === 2) return state;
49727
+ const projects = {};
49728
+ for (const [hash, record] of Object.entries(state.projects ?? {})) {
49729
+ const carried = {
49730
+ rootDirectory: record.rootDirectory,
49731
+ ...record.events ? { events: record.events } : {},
49732
+ ...record.migrations ? { migrations: record.migrations } : {}
49733
+ };
49734
+ projects[hash] = record.setupPrompt === false ? {
49735
+ ...carried,
49736
+ events: {
49737
+ ...carried.events,
49738
+ [SETUP_HINT_EVENT]: {
49739
+ firedAt: nowIso(),
49740
+ version: 1,
49741
+ outcome: "declined"
49742
+ }
49743
+ }
49744
+ } : carried;
49745
+ }
49746
+ foldLegacyDecisions(projects, state.ciPrompts, CI_PITCH_EVENT);
49747
+ foldLegacyDecisions(projects, state.actionUpgrades, ACTION_UPGRADE_EVENT);
49748
+ return {
49749
+ schemaVersion: 2,
49750
+ global: typeof state.onboardedAt === "string" ? { events: { [ONBOARDING_EVENT]: {
49751
+ firedAt: state.onboardedAt,
49752
+ version: 1
49753
+ } } } : {},
49754
+ projects
49755
+ };
49756
+ };
49757
+ const resolveConfigDir = (options) => options.cwd ?? (process.env["REACT_DOCTOR_CONFIG_DIR"] || void 0);
49758
+ const openStore = (options = {}) => new Conf({
48886
49759
  projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
48887
- cwd: options.cwd
49760
+ cwd: resolveConfigDir(options)
48888
49761
  });
48889
- const hasCompletedOnboarding = (options = {}) => {
49762
+ const openMigratedStore = (options) => {
49763
+ const store = openStore(options);
49764
+ if (store.store.schemaVersion !== 2) store.store = migrateCliState(store.store);
49765
+ return store;
49766
+ };
49767
+ const readCliState = (select, fallback, options = {}) => {
48890
49768
  try {
48891
- return typeof getOnboardingStore(options).get(ONBOARDED_AT_KEY) === "string";
49769
+ return select(openMigratedStore(options).store);
48892
49770
  } catch {
49771
+ return fallback;
49772
+ }
49773
+ };
49774
+ const updateCliState = (update, options = {}) => {
49775
+ try {
49776
+ const store = openMigratedStore(options);
49777
+ store.store = update(store.store);
48893
49778
  return true;
49779
+ } catch {
49780
+ return false;
48894
49781
  }
48895
49782
  };
49783
+ //#endregion
49784
+ //#region src/cli/utils/hash-project-root.ts
49785
+ const hashProjectRoot = (projectRoot) => createHash("sha256").update(Path.resolve(projectRoot)).digest("hex");
49786
+ //#endregion
49787
+ //#region src/cli/utils/cli-lifecycle.ts
49788
+ const versionOf = (item) => item.version ?? 1;
49789
+ const selectScope = (state, scoped, projectRoot) => scoped.scope === "global" ? state.global : projectRoot === void 0 ? void 0 : state.projects?.[hashProjectRoot(projectRoot)];
49790
+ const updateScope = (state, scoped, projectRoot, updateScopeState) => {
49791
+ if (scoped.scope === "global") return {
49792
+ ...state,
49793
+ global: updateScopeState(state.global ?? {})
49794
+ };
49795
+ if (projectRoot === void 0) return state;
49796
+ const projectKey = hashProjectRoot(projectRoot);
49797
+ const base = state.projects?.[projectKey] ?? { rootDirectory: Path.resolve(projectRoot) };
49798
+ return {
49799
+ ...state,
49800
+ projects: {
49801
+ ...state.projects,
49802
+ [projectKey]: {
49803
+ ...base,
49804
+ ...updateScopeState(base)
49805
+ }
49806
+ }
49807
+ };
49808
+ };
49809
+ const isGatePending = (gate, target = {}, options = {}) => {
49810
+ if (gate.scope === "project" && target.projectRoot === void 0) return false;
49811
+ return readCliState((state) => {
49812
+ const record = selectScope(state, gate, target.projectRoot)?.events?.[gate.id];
49813
+ return !record || record.version < versionOf(gate);
49814
+ }, gate.fireWhenUnknown ?? false, options);
49815
+ };
49816
+ const recordGate = (gate, target = {}, options = {}) => updateCliState((state) => updateScope(state, gate, target.projectRoot, (scope) => ({
49817
+ ...scope,
49818
+ events: {
49819
+ ...scope.events,
49820
+ [gate.id]: {
49821
+ firedAt: nowIso(),
49822
+ version: versionOf(gate),
49823
+ ...target.outcome ? { outcome: target.outcome } : {}
49824
+ }
49825
+ }
49826
+ })), options);
49827
+ const readPreference = (preference, target = {}, options = {}) => readCliState((state) => selectScope(state, preference, target.projectRoot)?.preferences?.[preference.id] ?? null, null, options);
49828
+ const writePreference = (preference, value, target = {}, options = {}) => updateCliState((state) => updateScope(state, preference, target.projectRoot, (scope) => ({
49829
+ ...scope,
49830
+ preferences: {
49831
+ ...scope.preferences,
49832
+ [preference.id]: value
49833
+ }
49834
+ })), options);
49835
+ const isMigrationPending = (migration, target = {}, options = {}) => {
49836
+ if (migration.scope === "project" && target.projectRoot === void 0) return false;
49837
+ return readCliState((state) => {
49838
+ const record = selectScope(state, migration, target.projectRoot)?.migrations?.[migration.id];
49839
+ return !record || record.version < versionOf(migration);
49840
+ }, false, options);
49841
+ };
49842
+ const recordMigration = (migration, projectRoot, record, options) => updateCliState((state) => updateScope(state, migration, projectRoot, (scope) => ({
49843
+ ...scope,
49844
+ migrations: {
49845
+ ...scope.migrations,
49846
+ [migration.id]: record
49847
+ }
49848
+ })), options);
49849
+ const runMigrations = async (migrations, target = {}, options = {}) => {
49850
+ const results = [];
49851
+ for (const migration of migrations) {
49852
+ if (!isMigrationPending(migration, target, options)) {
49853
+ results.push({
49854
+ id: migration.id,
49855
+ ran: false,
49856
+ applied: true
49857
+ });
49858
+ continue;
49859
+ }
49860
+ let applied = false;
49861
+ try {
49862
+ applied = await migration.run({ projectRoot: target.projectRoot });
49863
+ } catch {
49864
+ applied = false;
49865
+ }
49866
+ if (applied) recordMigration(migration, target.projectRoot, {
49867
+ ranAt: nowIso(),
49868
+ version: versionOf(migration)
49869
+ }, options);
49870
+ results.push({
49871
+ id: migration.id,
49872
+ ran: true,
49873
+ applied
49874
+ });
49875
+ }
49876
+ return results;
49877
+ };
49878
+ //#endregion
49879
+ //#region src/cli/utils/onboarding-state.ts
49880
+ const ONBOARDING_GATE = {
49881
+ id: ONBOARDING_EVENT,
49882
+ scope: "global"
49883
+ };
49884
+ const hasCompletedOnboarding = (options = {}) => !isGatePending(ONBOARDING_GATE, {}, options);
48896
49885
  const markOnboardingComplete = (options = {}) => {
48897
- try {
48898
- const store = getOnboardingStore(options);
48899
- if (typeof store.get(ONBOARDED_AT_KEY) === "string") return;
48900
- store.set(ONBOARDED_AT_KEY, (/* @__PURE__ */ new Date()).toISOString());
48901
- } catch {}
49886
+ if (isGatePending(ONBOARDING_GATE, {}, options)) recordGate(ONBOARDING_GATE, {}, options);
48902
49887
  };
48903
49888
  //#endregion
48904
49889
  //#region src/cli/utils/render-project-detection.ts
@@ -49384,7 +50369,7 @@ const readPackageJson = (projectRoot) => {
49384
50369
  return null;
49385
50370
  }
49386
50371
  };
49387
- const writeJsonFile$1 = (filePath, value) => {
50372
+ const writeJsonFile = (filePath, value) => {
49388
50373
  NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
49389
50374
  };
49390
50375
  const packageHasDependency = (projectRoot, dependencyName) => {
@@ -49473,12 +50458,12 @@ const resolveCacheFilePath = (projectDirectory) => {
49473
50458
  const readPersistedCache = (cacheFilePath) => {
49474
50459
  try {
49475
50460
  const parsed = JSON.parse(fs.readFileSync(cacheFilePath, "utf8"));
49476
- if (!isRecord(parsed) || parsed.version !== 1) return {
49477
- version: 1,
50461
+ if (!isRecord(parsed) || parsed.version !== 2) return {
50462
+ version: 2,
49478
50463
  entries: []
49479
50464
  };
49480
50465
  if (!Array.isArray(parsed.entries)) return {
49481
- version: 1,
50466
+ version: 2,
49482
50467
  entries: []
49483
50468
  };
49484
50469
  const entries = [];
@@ -49488,12 +50473,12 @@ const readPersistedCache = (cacheFilePath) => {
49488
50473
  entries.push(entry);
49489
50474
  }
49490
50475
  return {
49491
- version: 1,
50476
+ version: 2,
49492
50477
  entries
49493
50478
  };
49494
50479
  } catch {
49495
50480
  return {
49496
- version: 1,
50481
+ version: 2,
49497
50482
  entries: []
49498
50483
  };
49499
50484
  }
@@ -49546,7 +50531,7 @@ const buildScanResultCacheKey = (input) => {
49546
50531
  if (headSha === null) return null;
49547
50532
  if (stringifyStableJson(input.userConfig) === null) return null;
49548
50533
  const cacheKeyJson = stringifyStableJson({
49549
- schemaVersion: 1,
50534
+ schemaVersion: 2,
49550
50535
  projectIdentity: resolveProjectIdentity(input.projectDirectory),
49551
50536
  headSha,
49552
50537
  reactDoctorVersion: input.version,
@@ -49566,6 +50551,7 @@ const buildScanResultCacheKey = (input) => {
49566
50551
  adoptExistingLintConfig: input.options.adoptExistingLintConfig,
49567
50552
  ignoredTags: [...input.options.ignoredTags].sort(),
49568
50553
  concurrency: input.options.concurrency,
50554
+ lintBatchOrdering: resolveLintBatchOrdering(),
49569
50555
  baselineRef: input.options.baseline?.ref,
49570
50556
  changedLineRanges: input.options.changedLineRanges ?? void 0,
49571
50557
  noScore: input.options.noScore,
@@ -49583,7 +50569,7 @@ const createScanResultCache = (projectDirectory) => {
49583
50569
  for (const entry of persistedCache.entries) entries.set(entry.key, entry);
49584
50570
  const persist = () => {
49585
50571
  writePersistedCache(cacheFilePath, {
49586
- version: 1,
50572
+ version: 2,
49587
50573
  entries: [...entries.values()].sort((firstEntry, secondEntry) => secondEntry.createdAtMs - firstEntry.createdAtMs).slice(0, 20)
49588
50574
  });
49589
50575
  };
@@ -49599,7 +50585,7 @@ const createScanResultCache = (projectDirectory) => {
49599
50585
  }
49600
50586
  };
49601
50587
  };
49602
- const shouldStoreScanPayload = (payload) => !payload.didLintFail && !payload.didDeadCodeFail && payload.lintPartialFailures.length === 0;
50588
+ const shouldStoreScanPayload = (payload) => !payload.didLintFail && !payload.didDeadCodeFail && payload.lintPartialFailures.length === 0 && !payload.supplyChainOverlapTimedOut;
49603
50589
  //#endregion
49604
50590
  //#region src/inspect.ts
49605
50591
  const silentConsole = makeNoopConsole();
@@ -49663,21 +50649,24 @@ const deriveScope = (options) => {
49663
50649
  if (options.changedLineRanges !== null) return "lines";
49664
50650
  return options.includePaths.length > 0 ? "files" : "full";
49665
50651
  };
49666
- const buildRunEventConfig = (options, userConfig, hasCustomConfig) => ({
49667
- scope: deriveScope(options),
49668
- parallel: options.concurrency !== void 0,
49669
- workerCount: options.concurrency,
49670
- lint: options.lint,
49671
- deadCode: options.deadCode,
49672
- scoreOnly: options.scoreOnly,
49673
- noScore: options.noScore,
49674
- respectInlineDisables: options.respectInlineDisables,
49675
- showWarnings: options.warnings,
49676
- usedOutputDir: options.outputDirectory !== null,
49677
- ignoredTagCount: options.ignoredTags.size,
49678
- hasCustomConfig,
49679
- userConfig
49680
- });
50652
+ const buildRunEventConfig = (options, userConfig, hasCustomConfig, resolvedWorkerCount) => {
50653
+ const { workerCount, parallel } = resolveWorkerTelemetry(resolvedWorkerCount, options.concurrency);
50654
+ return {
50655
+ scope: deriveScope(options),
50656
+ parallel,
50657
+ workerCount,
50658
+ lint: options.lint,
50659
+ deadCode: options.deadCode,
50660
+ scoreOnly: options.scoreOnly,
50661
+ noScore: options.noScore,
50662
+ respectInlineDisables: options.respectInlineDisables,
50663
+ showWarnings: options.warnings,
50664
+ usedOutputDir: options.outputDirectory !== null,
50665
+ ignoredTagCount: options.ignoredTags.size,
50666
+ hasCustomConfig,
50667
+ userConfig
50668
+ };
50669
+ };
49681
50670
  const inspect = async (directory, inputOptions = {}) => {
49682
50671
  const startTime = performance$1.now();
49683
50672
  const isConcurrentScan = inputOptions.concurrentScan === true;
@@ -49769,7 +50758,7 @@ const runBaselineComparison = async (params) => {
49769
50758
  resolveLocalGithubViewerPermission: false,
49770
50759
  suppressScanSummary: true,
49771
50760
  supplyChainManifestChanged: params.options.supplyChainManifestChanged
49772
- }, {}).pipe(provide(baseLayers), provideService(Console, silentConsole))));
50761
+ }, {}).pipe(provide(baseLayers), provideService$2(Console, silentConsole))));
49773
50762
  if (baseOutput.didLintFail) return null;
49774
50763
  const delta = computeDiagnosticDelta({
49775
50764
  headDiagnostics: params.headDiagnostics,
@@ -49854,7 +50843,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49854
50843
  runId: getRunId(),
49855
50844
  resolveLocalGithubViewerPermission: !options.noScore,
49856
50845
  suppressScanSummary: options.suppressRendering,
49857
- supplyChainManifestChanged: options.supplyChainManifestChanged
50846
+ supplyChainManifestChanged: options.supplyChainManifestChanged,
50847
+ concurrentScan: options.concurrentScan
49858
50848
  }, { beforeLint: (projectInfo, lintIncludePaths) => gen(function* () {
49859
50849
  recordSentryProjectContext(projectInfo, rootSentrySpan, { concurrentScan: options.concurrentScan });
49860
50850
  recordCount(METRIC.projectDetected, 1);
@@ -49868,7 +50858,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49868
50858
  lintSourceFileCount
49869
50859
  });
49870
50860
  }) });
49871
- const output = await runPromise(restoreLegacyThrow(applyObservability(options.silent ? program.pipe(provide(layers), provideService(Console, silentConsole)) : program.pipe(provide(layers)), rootSentrySpan)));
50861
+ const output = await runPromise(restoreLegacyThrow(applyObservability(options.silent ? program.pipe(provide(layers), provideService$2(Console, silentConsole)) : program.pipe(provide(layers)), rootSentrySpan)));
49872
50862
  const didLintFail = lintBindingMissing || output.didLintFail;
49873
50863
  const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
49874
50864
  if (!options.scoreOnly && !lintBindingMissing && output.didLintFail && lintFailureReason !== null) if (output.lintFailureReasonKind === "native-binding-missing") runConsole(log(highlighter.gray(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`)));
@@ -49905,12 +50895,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49905
50895
  lintPartialFailures: output.lintPartialFailures,
49906
50896
  didDeadCodeFail: output.didDeadCodeFail,
49907
50897
  deadCodeFailureReason: output.deadCodeFailureReason,
50898
+ deadCodeOverlapped: output.deadCodeOverlapped,
49908
50899
  directory: output.resolvedDirectory,
49909
50900
  scannedFileCount: output.scannedFileCount,
49910
50901
  scannedFilePaths: output.scannedFilePaths,
49911
50902
  scanElapsedMilliseconds: output.scanElapsedMilliseconds,
50903
+ scanConcurrency: output.scanConcurrency,
49912
50904
  baselineDelta,
49913
- lintFailureReasonKind: lintBindingMissing ? "native-binding-missing" : output.lintFailureReasonKind
50905
+ lintFailureReasonKind: lintBindingMissing ? "native-binding-missing" : output.lintFailureReasonKind,
50906
+ supplyChainOverlapTimedOut: output.supplyChainOverlapTimedOut
49914
50907
  };
49915
50908
  if (cacheKey !== null && scanResultCache !== null && shouldStoreScanPayload(payload)) scanResultCache.store(cacheKey, payload);
49916
50909
  const result = await renderAndRecordScan({
@@ -49921,12 +50914,14 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49921
50914
  startTime,
49922
50915
  rootSentrySpan,
49923
50916
  scanMode: baselineDelta ? "baseline" : isDiffMode ? "diff" : "full",
49924
- baselineDegraded
50917
+ baselineDegraded,
50918
+ lintCacheHitFileCount: output.lintCacheHitFileCount,
50919
+ lintCacheTotalFileCount: output.lintCacheTotalFileCount
49925
50920
  });
49926
50921
  recordOnboardingCompletion(options);
49927
50922
  return result;
49928
50923
  };
49929
- const runMaybeSilent = (effect, silent) => silent ? effect.pipe(provideService(Console, silentConsole)) : effect;
50924
+ const runMaybeSilent = (effect, silent) => silent ? effect.pipe(provideService$2(Console, silentConsole)) : effect;
49930
50925
  const renderCachedProjectDetection = async (input) => {
49931
50926
  if (input.options.scoreOnly || input.options.suppressRendering) return;
49932
50927
  await runPromise(runMaybeSilent(printProjectDetection({
@@ -49954,14 +50949,17 @@ const renderAndRecordScan = async (input) => {
49954
50949
  scannedFileCount: input.payload.scannedFileCount,
49955
50950
  scannedFilePaths: input.payload.scannedFilePaths,
49956
50951
  scanElapsedMilliseconds: input.payload.scanElapsedMilliseconds,
50952
+ lintCacheHitFileCount: input.lintCacheHitFileCount ?? null,
50953
+ lintCacheTotalFileCount: input.lintCacheTotalFileCount ?? null,
49957
50954
  baselineDelta: input.payload.baselineDelta
49958
50955
  }), input.options.silent));
50956
+ const { workerCount: resolvedWorkerCount, parallel } = resolveWorkerTelemetry(input.payload.scanConcurrency, input.options.concurrency);
49959
50957
  recordScanMetrics({
49960
50958
  result,
49961
50959
  mode: input.scanMode,
49962
50960
  baselineDegraded: input.baselineDegraded,
49963
- parallel: input.options.concurrency !== void 0,
49964
- workerCount: input.options.concurrency,
50961
+ parallel,
50962
+ workerCount: resolvedWorkerCount,
49965
50963
  lint: input.options.lint,
49966
50964
  deadCode: input.options.deadCode,
49967
50965
  scoreOnly: input.options.scoreOnly,
@@ -49971,19 +50969,22 @@ const renderAndRecordScan = async (input) => {
49971
50969
  didDeadCodeFail: input.payload.didDeadCodeFail
49972
50970
  });
49973
50971
  recordRunEvent(input.rootSentrySpan, {
49974
- ...buildRunEventConfig(input.options, input.userConfig, input.hasCustomConfig),
50972
+ ...buildRunEventConfig(input.options, input.userConfig, input.hasCustomConfig, resolvedWorkerCount),
49975
50973
  result,
49976
50974
  mode: input.scanMode,
49977
50975
  gateExempt: input.baselineDegraded,
49978
50976
  didLintFail: input.payload.didLintFail,
49979
50977
  lintFailureReasonKind: input.payload.lintFailureReasonKind,
49980
50978
  lintPartialFailureCount: input.payload.lintPartialFailures.length,
49981
- didDeadCodeFail: input.payload.didDeadCodeFail
50979
+ lintDroppedFileCount: countDroppedLintFiles(input.payload.lintPartialFailures),
50980
+ didDeadCodeFail: input.payload.didDeadCodeFail,
50981
+ supplyChainOverlapTimedOut: input.payload.supplyChainOverlapTimedOut,
50982
+ deadCodeOverlapped: input.payload.deadCodeOverlapped
49982
50983
  });
49983
50984
  return result;
49984
50985
  };
49985
50986
  const finalizeAndRender = (input) => gen(function* () {
49986
- const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds, baselineDelta } = input;
50987
+ const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds, lintCacheHitFileCount, lintCacheTotalFileCount, baselineDelta } = input;
49987
50988
  const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
49988
50989
  didLintFail,
49989
50990
  lintFailureReason,
@@ -50003,6 +51004,10 @@ const finalizeAndRender = (input) => gen(function* () {
50003
51004
  scannedFileCount,
50004
51005
  scannedFilePaths,
50005
51006
  scanElapsedMilliseconds,
51007
+ ...lintCacheTotalFileCount !== null ? {
51008
+ lintCacheHitFileCount,
51009
+ lintCacheTotalFileCount
51010
+ } : {},
50006
51011
  ...baselineDelta ? { baselineDelta } : {}
50007
51012
  });
50008
51013
  if (options.suppressRendering) return buildResult();
@@ -50084,7 +51089,8 @@ const getStagedSourceFiles = async (directory) => {
50084
51089
  return [...await runPromise(gen(function* () {
50085
51090
  return yield* (yield* StagedFiles).discoverSourceFiles(directory);
50086
51091
  }).pipe(provide(stagedFilesLayer)))];
50087
- } catch {
51092
+ } catch (error) {
51093
+ cliLogger.warn(`Failed to discover staged files: ${error instanceof Error ? error.message : String(error)}`);
50088
51094
  return [];
50089
51095
  }
50090
51096
  };
@@ -50103,6 +51109,35 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
50103
51109
  };
50104
51110
  };
50105
51111
  //#endregion
51112
+ //#region src/cli/utils/is-environment-error.ts
51113
+ const isNodeSystemError = (error) => error instanceof Error && typeof error.code === "string";
51114
+ const ENVIRONMENT_ERROR_CODES = new Set([
51115
+ "ENOSPC",
51116
+ "EIO",
51117
+ "EROFS",
51118
+ "EACCES",
51119
+ "EPERM",
51120
+ "ENOTDIR"
51121
+ ]);
51122
+ const isEnvironmentError = (error) => {
51123
+ if (!isNodeSystemError(error)) return false;
51124
+ if (error.code === "ENOENT") return error.syscall?.startsWith("spawn") ?? false;
51125
+ return error.code !== void 0 && ENVIRONMENT_ERROR_CODES.has(error.code);
51126
+ };
51127
+ const formatEnvironmentError = (error) => {
51128
+ if (!isNodeSystemError(error)) return error instanceof Error ? error.message : String(error);
51129
+ switch (error.code) {
51130
+ case "ENOSPC": return "No space left on device. Free up disk space and try again.";
51131
+ case "EIO": return "I/O error: the filesystem or disk may be failing. Check your system logs.";
51132
+ case "EROFS": return "Read-only filesystem: cannot write to this location.";
51133
+ case "EACCES":
51134
+ case "EPERM": return error.path ? `Permission denied accessing ${error.path}. Check file permissions and try again.` : "Permission denied. Check file permissions and try again.";
51135
+ case "ENOTDIR": return error.path ? `A file exists at ${error.path} or one of its parent paths where a directory was expected.` : "A file exists where a directory was expected.";
51136
+ case "ENOENT": return "Required command not found. Ensure the tool (e.g. git) is installed and on your PATH.";
51137
+ default: return error.message;
51138
+ }
51139
+ };
51140
+ //#endregion
50106
51141
  //#region src/cli/utils/handle-error.ts
50107
51142
  const OTLP_ENDPOINT_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_ENDPOINT";
50108
51143
  const OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_AUTH_HEADER";
@@ -50185,15 +51220,19 @@ const handleError = (error, options = {}) => {
50185
51220
  process.exitCode = 1;
50186
51221
  };
50187
51222
  /**
50188
- * Renderer for expected, user-actionable failures — a bad `--diff` value or
50189
- * a base branch that isn't fetched. Prints just the (already human-readable)
50190
- * message no "Something went wrong", prefilled issue, Discord link, or
50191
- * Sentry reference because there is no bug to report.
51223
+ * Renderer for expected, user-actionable failures — a bad `--diff` value,
51224
+ * a base branch that isn't fetched, or environment errors like disk-full or
51225
+ * permission-denied. Prints just the (already human-readable) message no
51226
+ * "Something went wrong", prefilled issue, Discord link, or Sentry reference
51227
+ * — because there is no bug to report.
50192
51228
  */
50193
51229
  const handleUserError = (error, options = {}) => {
51230
+ const isEnvError = isEnvironmentError(error);
51231
+ if (isEnvError) recordCount(METRIC.cliEnvironmentError, 1, { code: error.code ?? "unknown" });
51232
+ const message = isEnvError ? formatEnvironmentError(error) : formatErrorForReport(error);
50194
51233
  runSync(gen(function* () {
50195
51234
  yield* error$1("");
50196
- yield* error$1(highlighter.error(formatErrorForReport(error)));
51235
+ yield* error$1(highlighter.error(message));
50197
51236
  yield* error$1("");
50198
51237
  }));
50199
51238
  if (options.shouldExit !== false) process.exit(1);
@@ -50208,7 +51247,7 @@ const handleUserError = (error, options = {}) => {
50208
51247
  * `handleUserError` (a plain message — no "Something went wrong", prefilled
50209
51248
  * issue, Discord link, or Sentry reference), since there is no bug to report.
50210
51249
  *
50211
- * Three distinct shapes reach the CLI's catch blocks:
51250
+ * Four distinct shapes reach the CLI's catch blocks:
50212
51251
  *
50213
51252
  * - **Project-discovery failures** (`NoReactDependencyError`,
50214
51253
  * `ProjectNotFoundError`, `PackageJsonNotFoundError`, `NotADirectoryError`,
@@ -50221,12 +51260,19 @@ const handleUserError = (error, options = {}) => {
50221
51260
  * `--project` name.
50222
51261
  * - **Bad `--diff` input** (`GitBaseBranchInvalid` / `GitBaseBranchMissing`)
50223
51262
  * stays the tagged `ReactDoctorError`, so dispatch on the reason `_tag`.
51263
+ * - **Environment failures** (`ENOSPC`, `EIO`, `EROFS`, `EACCES`, `EPERM`,
51264
+ * `ENOTDIR`, plus a `spawn`-scoped `ENOENT` for a missing binary) — disk
51265
+ * full / failing / read-only, permission denied, or a path blocked by a
51266
+ * file. React Doctor cannot fix the user's environment; exit cleanly with an
51267
+ * actionable message instead of crashing. See `is-environment-error.ts` for
51268
+ * why the set stays narrow (codes that usually mean our bug keep reaching
51269
+ * Sentry).
50224
51270
  *
50225
51271
  * This composes the existing core narrowers rather than introducing a new
50226
51272
  * error-shape helper (AGENTS.md): it encodes CLI-layer reporting policy, not
50227
51273
  * knowledge of the `ReactDoctorError` shape.
50228
51274
  */
50229
- const isExpectedUserError = (error) => error instanceof CliInputError || isProjectDiscoveryError(error) || isReactDoctorError(error) && (error.reason._tag === "GitBaseBranchInvalid" || error.reason._tag === "GitBaseBranchMissing");
51275
+ const isExpectedUserError = (error) => error instanceof CliInputError || isProjectDiscoveryError(error) || isEnvironmentError(error) || isReactDoctorError(error) && (error.reason._tag === "GitBaseBranchInvalid" || error.reason._tag === "GitBaseBranchMissing");
50230
51276
  //#endregion
50231
51277
  //#region src/cli/utils/build-handoff-payload.ts
50232
51278
  const buildHandoffPayload = (input) => {
@@ -50307,6 +51353,20 @@ const detectAvailableAgents = async () => {
50307
51353
  const detected = new Set([...detectPathAvailableAgents(), ...await detectInstalledSkillAgents()]);
50308
51354
  return getSkillAgentTypes().filter((agent) => agent !== "universal" && detected.has(agent));
50309
51355
  };
51356
+ const DEFAULT_INSTALL_AGENTS = [
51357
+ "claude-code",
51358
+ "cursor",
51359
+ "codex",
51360
+ "opencode"
51361
+ ];
51362
+ const computeDefaultSelectedAgents = (detectedAgents, rememberedAgents) => {
51363
+ const detected = new Set(detectedAgents);
51364
+ const remembered = rememberedAgents.filter((agent) => detected.has(agent));
51365
+ if (remembered.length > 0) return remembered;
51366
+ const defaults = DEFAULT_INSTALL_AGENTS.filter((agent) => detected.has(agent));
51367
+ if (defaults.length > 0) return defaults;
51368
+ return detectedAgents.length === 1 ? [...detectedAgents] : [];
51369
+ };
50310
51370
  //#endregion
50311
51371
  //#region src/cli/utils/install-doctor-script.ts
50312
51372
  const DOCTOR_SCRIPT_NAME = "doctor";
@@ -50388,7 +51448,7 @@ const installDoctorScript = (options) => {
50388
51448
  };
50389
51449
  })();
50390
51450
  const scriptStatus = scriptTarget.status;
50391
- if (scriptStatus === "created") writeJsonFile$1(packageJsonPath, {
51451
+ if (scriptStatus === "created") writeJsonFile(packageJsonPath, {
50392
51452
  ...packageJson,
50393
51453
  scripts: {
50394
51454
  ...isRecord$1(scripts) ? scripts : {},
@@ -50542,54 +51602,35 @@ const upgradeReactDoctorWorkflowInPlace = (projectRoot) => {
50542
51602
  }
50543
51603
  };
50544
51604
  //#endregion
50545
- //#region src/cli/utils/hash-project-root.ts
50546
- const hashProjectRoot = (projectRoot) => createHash("sha256").update(Path.resolve(projectRoot)).digest("hex");
50547
- //#endregion
50548
- //#region src/cli/utils/project-decision-store.ts
50549
- const createProjectDecisionStore = (storeKey) => {
50550
- const getStore = (options = {}) => new Conf({
50551
- projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
50552
- cwd: options.cwd
50553
- });
50554
- return {
50555
- getConfigPath: (options = {}) => getStore(options).path,
50556
- hasHandled: (projectRoot, options = {}) => {
50557
- try {
50558
- return Boolean(getStore(options).get(storeKey, {})[hashProjectRoot(projectRoot)]);
50559
- } catch {
50560
- return true;
50561
- }
50562
- },
50563
- record: (projectRoot, outcome, options = {}) => {
50564
- try {
50565
- const store = getStore(options);
50566
- store.set(storeKey, {
50567
- ...store.get(storeKey, {}),
50568
- [hashProjectRoot(projectRoot)]: {
50569
- rootDirectory: Path.resolve(projectRoot),
50570
- outcome,
50571
- at: (/* @__PURE__ */ new Date()).toISOString()
50572
- }
50573
- });
50574
- return true;
50575
- } catch {
50576
- return false;
50577
- }
50578
- }
50579
- };
50580
- };
50581
- //#endregion
50582
51605
  //#region src/cli/utils/action-upgrade-prompt.ts
50583
- const store$1 = createProjectDecisionStore("actionUpgrades");
50584
- store$1.getConfigPath;
50585
- const hasHandledActionUpgrade = store$1.hasHandled;
50586
- const recordActionUpgradeDecision = store$1.record;
51606
+ const ACTION_UPGRADE_GATE = {
51607
+ id: ACTION_UPGRADE_EVENT,
51608
+ scope: "project"
51609
+ };
51610
+ const hasHandledActionUpgrade = (projectRoot, options = {}) => !isGatePending(ACTION_UPGRADE_GATE, { projectRoot }, options);
51611
+ const recordActionUpgradeDecision = (projectRoot, outcome, options = {}) => recordGate(ACTION_UPGRADE_GATE, {
51612
+ projectRoot,
51613
+ outcome
51614
+ }, options);
50587
51615
  //#endregion
50588
51616
  //#region src/cli/utils/ci-prompt-decision.ts
50589
- const store = createProjectDecisionStore("ciPrompts");
50590
- store.getConfigPath;
50591
- const hasHandledCiPrompt = store.hasHandled;
50592
- const recordCiPromptDecision = store.record;
51617
+ const CI_PITCH_GATE = {
51618
+ id: CI_PITCH_EVENT,
51619
+ scope: "project"
51620
+ };
51621
+ const hasHandledCiPrompt = (projectRoot, options = {}) => !isGatePending(CI_PITCH_GATE, { projectRoot }, options);
51622
+ const recordCiPromptDecision = (projectRoot, outcome, options = {}) => recordGate(CI_PITCH_GATE, {
51623
+ projectRoot,
51624
+ outcome
51625
+ }, options);
51626
+ //#endregion
51627
+ //#region src/cli/utils/handoff-target-preference.ts
51628
+ const HANDOFF_TARGET_PREFERENCE = {
51629
+ id: HANDOFF_TARGET_PREFERENCE_ID,
51630
+ scope: "global"
51631
+ };
51632
+ const readHandoffTarget = (options = {}) => readPreference(HANDOFF_TARGET_PREFERENCE, {}, options);
51633
+ const rememberHandoffTarget = (target, options = {}) => writePreference(HANDOFF_TARGET_PREFERENCE, target, {}, options);
50593
51634
  //#endregion
50594
51635
  //#region src/cli/utils/open-url.ts
50595
51636
  const resolveOpenCommand = (url) => {
@@ -50755,39 +51796,80 @@ const DEFAULT_PR_TITLE = "Add React Doctor to GitHub Actions";
50755
51796
  const DEFAULT_PR_BODY = `Adds a [React Doctor](https://www.react.doctor) scan to every pull request and every push to the default branch. The workflow file is documented inline.
50756
51797
 
50757
51798
  Docs: https://www.react.doctor/ci`;
50758
- const findUniqueBranchName = async (cwd) => {
50759
- if (!(await runCommand("git", [
51799
+ const findUniqueBranchName = async (cwd, run) => {
51800
+ if (!(await run("git", [
50760
51801
  "rev-parse",
50761
51802
  "--verify",
50762
51803
  NEW_BRANCH_PREFIX
50763
51804
  ], cwd)).success) return NEW_BRANCH_PREFIX;
50764
51805
  return `${NEW_BRANCH_PREFIX}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace(/[-:T]/g, "")}`;
50765
51806
  };
51807
+ const findExistingSetupPullRequest = async (cwd, run) => {
51808
+ const prList = await run("gh", [
51809
+ "pr",
51810
+ "list",
51811
+ "--state",
51812
+ "open",
51813
+ "--json",
51814
+ "headRefName,url",
51815
+ "--limit",
51816
+ String(100)
51817
+ ], cwd);
51818
+ if (!prList.success) return null;
51819
+ try {
51820
+ return JSON.parse(prList.stdout).find((pullRequest) => (pullRequest.headRefName ?? "").startsWith(NEW_BRANCH_PREFIX)) ?? null;
51821
+ } catch {
51822
+ return null;
51823
+ }
51824
+ };
51825
+ const hasUnrelatedTrackedChanges = async (cwd, workflowRelative, run) => {
51826
+ const statusProbe = await run("git", [
51827
+ "status",
51828
+ "--porcelain",
51829
+ "--",
51830
+ ".",
51831
+ `:!${workflowRelative}`
51832
+ ], cwd);
51833
+ if (!statusProbe.success) return true;
51834
+ return statusProbe.stdout.split(/\r?\n/).filter(Boolean).some((statusLine) => !statusLine.startsWith("??"));
51835
+ };
50766
51836
  const openWorkflowPullRequest = async (params) => {
50767
51837
  const workflowPath = Path.resolve(params.workflowPath);
50768
51838
  const commitMessage = params.commitMessage ?? DEFAULT_COMMIT_MESSAGE;
50769
51839
  const prTitle = params.prTitle ?? DEFAULT_PR_TITLE;
50770
51840
  const prBody = params.prBody ?? DEFAULT_PR_BODY;
50771
- const repoRootProbe = await runCommand("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
51841
+ const run = params.run ?? runCommand;
51842
+ const checkCommandAvailable = params.checkCommandAvailable ?? isCommandAvailable;
51843
+ const repoRootProbe = await run("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
50772
51844
  if (!repoRootProbe.success) return {
50773
51845
  status: "not-attempted",
50774
51846
  reason: "not-a-git-repo"
50775
51847
  };
50776
51848
  const cwd = repoRootProbe.stdout;
50777
- if (!isCommandAvailable("gh")) return {
51849
+ const workflowRelative = toForwardSlashes(Path.relative(cwd, workflowPath));
51850
+ if (!checkCommandAvailable("gh")) return {
50778
51851
  status: "not-attempted",
50779
51852
  reason: "gh-not-installed"
50780
51853
  };
50781
- if (!(await runCommand("gh", ["auth", "status"], cwd)).success) return {
51854
+ if (!(await run("gh", ["auth", "status"], cwd)).success) return {
50782
51855
  status: "not-attempted",
50783
51856
  reason: "gh-not-authenticated"
50784
51857
  };
50785
- const defaultBranch = params.baseBranch ?? await detectDefaultBranch(cwd);
51858
+ const existingSetupPullRequest = await findExistingSetupPullRequest(cwd, run);
51859
+ if (existingSetupPullRequest) return {
51860
+ status: "pr-exists",
51861
+ url: existingSetupPullRequest.url ?? ""
51862
+ };
51863
+ if (await hasUnrelatedTrackedChanges(cwd, workflowRelative, run)) return {
51864
+ status: "not-attempted",
51865
+ reason: "working-tree-dirty"
51866
+ };
51867
+ const defaultBranch = params.baseBranch ?? await detectDefaultBranch(cwd, run);
50786
51868
  if (!defaultBranch) return {
50787
51869
  status: "not-attempted",
50788
51870
  reason: "no-default-branch"
50789
51871
  };
50790
- const previousBranchProbe = await runCommand("git", [
51872
+ const previousBranchProbe = await run("git", [
50791
51873
  "rev-parse",
50792
51874
  "--abbrev-ref",
50793
51875
  "HEAD"
@@ -50797,13 +51879,13 @@ const openWorkflowPullRequest = async (params) => {
50797
51879
  reason: "detached-head"
50798
51880
  };
50799
51881
  const previousBranch = previousBranchProbe.stdout;
50800
- await runCommand("git", [
51882
+ await run("git", [
50801
51883
  "fetch",
50802
51884
  "origin",
50803
51885
  defaultBranch
50804
51886
  ], cwd);
50805
- const newBranch = await findUniqueBranchName(cwd);
50806
- if (!(await runCommand("git", [
51887
+ const newBranch = await findUniqueBranchName(cwd, run);
51888
+ if (!(await run("git", [
50807
51889
  "checkout",
50808
51890
  "-b",
50809
51891
  newBranch,
@@ -50813,17 +51895,17 @@ const openWorkflowPullRequest = async (params) => {
50813
51895
  reason: "checkout-failed"
50814
51896
  };
50815
51897
  const restoreToPreviousBranch = async (deleteNewBranch) => {
50816
- await runCommand("git", ["checkout", previousBranch], cwd);
50817
- if (deleteNewBranch) await runCommand("git", [
51898
+ await run("git", ["checkout", previousBranch], cwd);
51899
+ if (deleteNewBranch) await run("git", [
50818
51900
  "branch",
50819
51901
  "-D",
50820
51902
  newBranch
50821
51903
  ], cwd);
50822
51904
  };
50823
- if (!(await runCommand("git", [
51905
+ if (!(await run("git", [
50824
51906
  "add",
50825
51907
  "--",
50826
- Path.relative(cwd, workflowPath)
51908
+ workflowRelative
50827
51909
  ], cwd)).success) {
50828
51910
  await restoreToPreviousBranch(true);
50829
51911
  return {
@@ -50831,7 +51913,7 @@ const openWorkflowPullRequest = async (params) => {
50831
51913
  reason: "git-add-failed"
50832
51914
  };
50833
51915
  }
50834
- if (!(await runCommand("git", [
51916
+ if (!(await run("git", [
50835
51917
  "commit",
50836
51918
  "-m",
50837
51919
  commitMessage
@@ -50842,7 +51924,7 @@ const openWorkflowPullRequest = async (params) => {
50842
51924
  reason: "git-commit-failed"
50843
51925
  };
50844
51926
  }
50845
- if (!(await runCommand("git", [
51927
+ if (!(await run("git", [
50846
51928
  "push",
50847
51929
  "-u",
50848
51930
  "origin",
@@ -50854,7 +51936,7 @@ const openWorkflowPullRequest = async (params) => {
50854
51936
  reason: "git-push-failed"
50855
51937
  };
50856
51938
  }
50857
- const prCreate = await runCommand("gh", [
51939
+ const prCreate = await run("gh", [
50858
51940
  "pr",
50859
51941
  "create",
50860
51942
  "--title",
@@ -50878,12 +51960,13 @@ const openWorkflowPullRequest = async (params) => {
50878
51960
  };
50879
51961
  const stageWorkflowFile = async (params) => {
50880
51962
  const workflowPath = Path.resolve(params.workflowPath);
50881
- const repoRootProbe = await runCommand("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
51963
+ const run = params.run ?? runCommand;
51964
+ const repoRootProbe = await run("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
50882
51965
  if (!repoRootProbe.success) return false;
50883
- return (await runCommand("git", [
51966
+ return (await run("git", [
50884
51967
  "add",
50885
51968
  "--",
50886
- Path.relative(repoRootProbe.stdout, workflowPath)
51969
+ toForwardSlashes(Path.relative(repoRootProbe.stdout, workflowPath))
50887
51970
  ], repoRootProbe.stdout)).success;
50888
51971
  };
50889
51972
  //#endregion
@@ -50924,6 +52007,7 @@ const setUpGitHubActions = async (options) => {
50924
52007
  baseBranch: defaultBranch
50925
52008
  });
50926
52009
  if (pullRequestResult.status === "pr-opened") pullRequestSpinner.succeed(`Opened pull request for review: ${highlighter.info(pullRequestResult.url)}`);
52010
+ else if (pullRequestResult.status === "pr-exists") pullRequestSpinner.succeed(`A React Doctor setup pull request is already open: ${highlighter.info(pullRequestResult.url)}`);
50927
52011
  else if (pullRequestResult.status === "branch-pushed") pullRequestSpinner.warn(`Pushed branch ${highlighter.bold(pullRequestResult.branch)} but couldn't open a PR. Open one with: gh pr create --head ${pullRequestResult.branch}`);
50928
52012
  else {
50929
52013
  pullRequestSpinner.stop();
@@ -50935,6 +52019,19 @@ const setUpGitHubActions = async (options) => {
50935
52019
  return didCreateWorkflow;
50936
52020
  };
50937
52021
  //#endregion
52022
+ //#region src/cli/utils/install-agents-preference.ts
52023
+ const INSTALL_AGENTS_PREFERENCE = {
52024
+ id: INSTALL_AGENTS_PREFERENCE_ID,
52025
+ scope: "global"
52026
+ };
52027
+ const PREFERENCE_SEPARATOR = ",";
52028
+ const readInstallAgents = (options = {}) => {
52029
+ const stored = readPreference(INSTALL_AGENTS_PREFERENCE, {}, options);
52030
+ if (stored === null) return [];
52031
+ return stored.split(PREFERENCE_SEPARATOR).map((entry) => entry.trim()).filter((entry) => isSkillAgentType(entry));
52032
+ };
52033
+ const rememberInstallAgents = (agents, options = {}) => writePreference(INSTALL_AGENTS_PREFERENCE, agents.join(PREFERENCE_SEPARATOR), {}, options);
52034
+ //#endregion
50938
52035
  //#region src/cli/utils/install-agent-hooks.ts
50939
52036
  const CLAUDE_AGENT = "claude-code";
50940
52037
  const CURSOR_AGENT = "cursor";
@@ -50945,20 +52042,34 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
50945
52042
  const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
50946
52043
  const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
50947
52044
  const CURSOR_HOOKS_SCHEMA_VERSION = 1;
50948
- const JSON_INDENT_SPACES$1 = 2;
50949
52045
  const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
50950
52046
  const readJsonFile = (filePath, fallback) => {
50951
52047
  if (!NFS.existsSync(filePath)) return fallback;
50952
52048
  const content = NFS.readFileSync(filePath, "utf8").trim();
50953
52049
  if (content.length === 0) return fallback;
50954
- return JSON.parse(content);
52050
+ try {
52051
+ return JSON.parse(content);
52052
+ } catch (error) {
52053
+ if (error instanceof SyntaxError) throw new CliInputError(`Could not parse ${filePath}: the file contains invalid JSON. Fix the syntax errors in this file and re-run the install command.`);
52054
+ throw error;
52055
+ }
50955
52056
  };
50956
- const writeJsonFile = (filePath, value) => {
50957
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
50958
- NFS.writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
52057
+ const ensureDirectoryExists = (directoryPath) => {
52058
+ try {
52059
+ NFS.mkdirSync(directoryPath, { recursive: true });
52060
+ } catch (error) {
52061
+ const code = error.code;
52062
+ if (code === "EACCES" || code === "EPERM") throw new CliInputError(`Could not create directory ${directoryPath}: permission denied. Ensure you have write permissions for this location and re-run the install command.`);
52063
+ if (code === "ENOTDIR" || code === "EEXIST") throw new CliInputError(`Could not create directory ${directoryPath}: a file exists at this path or one of its parent paths. Remove the conflicting file and re-run the install command.`);
52064
+ throw error;
52065
+ }
52066
+ };
52067
+ const writeJsonFileWithDirectoryCheck = (filePath, value) => {
52068
+ ensureDirectoryExists(Path.dirname(filePath));
52069
+ writeJsonFile(filePath, value);
50959
52070
  };
50960
52071
  const writeHookScript = (filePath) => {
50961
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
52072
+ ensureDirectoryExists(Path.dirname(filePath));
50962
52073
  NFS.writeFileSync(filePath, buildAgentHookScript());
50963
52074
  NFS.chmodSync(filePath, 493);
50964
52075
  };
@@ -50974,7 +52085,7 @@ const installClaudeHook = (projectRoot) => {
50974
52085
  command: CLAUDE_HOOK_COMMAND
50975
52086
  }] });
50976
52087
  hooks.PostToolBatch = postToolBatchHooks;
50977
- writeJsonFile(settingsPath, {
52088
+ writeJsonFileWithDirectoryCheck(settingsPath, {
50978
52089
  ...settings,
50979
52090
  hooks
50980
52091
  });
@@ -50994,7 +52105,7 @@ const installCursorHook = (projectRoot) => {
50994
52105
  timeout: 120
50995
52106
  });
50996
52107
  hooks.postToolUse = postToolUseHooks;
50997
- writeJsonFile(configPath, {
52108
+ writeJsonFileWithDirectoryCheck(configPath, {
50998
52109
  ...config,
50999
52110
  version: config.version ?? CURSOR_HOOKS_SCHEMA_VERSION,
51000
52111
  hooks
@@ -51230,7 +52341,7 @@ const installPackageJsonHook = (options, strategy) => {
51230
52341
  parent = cloned;
51231
52342
  }
51232
52343
  parent[leafKey] = strategy.leafShape === "array" ? appendArrayCommand(parent[leafKey]) : appendStringCommand(parent[leafKey]);
51233
- writeJsonFile$1(packageJsonPath, nextPackageJson);
52344
+ writeJsonFile(packageJsonPath, nextPackageJson);
51234
52345
  removeLegacyManagedRunner(options.projectRoot);
51235
52346
  return {
51236
52347
  hookPath: packageJsonPath,
@@ -51813,6 +52924,9 @@ const runInstallReactDoctor = async (options = {}) => {
51813
52924
  const shouldUpgradeWorkflow = canUpgradeWorkflow && (Boolean(options.yes) || upgradePromptOutcome === "yes");
51814
52925
  if (upgradePromptOutcome === "no" && !options.dryRun) recordActionUpgradeDecision(projectRoot, "declined");
51815
52926
  if ((ciPromptOutcome === "yes" || ciPromptOutcome === "no") && !options.dryRun) recordCiPromptDecision(projectRoot, ciPromptOutcome === "yes" ? "accepted" : "declined");
52927
+ const rememberedAgents = options.lastSelectedAgents ?? readInstallAgents();
52928
+ const defaultSelectedAgents = computeDefaultSelectedAgents(detectedAgents, rememberedAgents);
52929
+ const usedRememberedAgents = rememberedAgents.some((agent) => detectedAgents.includes(agent));
51816
52930
  const selectedAgents = skipPrompts ? detectedAgents : (await prompt({
51817
52931
  type: "multiselect",
51818
52932
  name: "agents",
@@ -51820,12 +52934,13 @@ const runInstallReactDoctor = async (options = {}) => {
51820
52934
  choices: detectedAgents.map((agent) => ({
51821
52935
  title: getSkillAgentConfig(agent).displayName,
51822
52936
  value: agent,
51823
- selected: true
52937
+ selected: defaultSelectedAgents.includes(agent)
51824
52938
  })),
51825
52939
  instructions: false,
51826
52940
  min: 1
51827
52941
  }, promptOptions)).agents ?? [];
51828
52942
  if (selectedAgents.length === 0) return;
52943
+ if (!skipPrompts && !options.dryRun) rememberInstallAgents(selectedAgents);
51829
52944
  let dependencyResult;
51830
52945
  if (!options.dryRun) {
51831
52946
  await installReactDoctorSkillStep(sourceDir, selectedAgents, projectRoot);
@@ -51884,6 +52999,8 @@ const runInstallReactDoctor = async (options = {}) => {
51884
52999
  }
51885
53000
  recordCount(METRIC.installCompleted, 1, {
51886
53001
  agentsCount: selectedAgents.length,
53002
+ agentsDetected: detectedAgents.length,
53003
+ usedRememberedAgents,
51887
53004
  gitHook: shouldInstallGitHook,
51888
53005
  agentHooks: shouldInstallAgentHooks,
51889
53006
  workflow: didInstallWorkflow,
@@ -52024,7 +53141,12 @@ const upgradeGitHubActionsWorkflow = async (workflow) => {
52024
53141
  prBody: UPGRADE_PR_BODY
52025
53142
  });
52026
53143
  if (pullRequestResult.status === "pr-opened") upgradeSpinner.succeed(`Opened pull request for review: ${highlighter.info(pullRequestResult.url)}`);
52027
- else if (pullRequestResult.status === "branch-pushed") upgradeSpinner.warn(`Pushed branch ${highlighter.bold(pullRequestResult.branch)} but couldn't open a PR. Open one with: gh pr create --head ${pullRequestResult.branch}`);
53144
+ else if (pullRequestResult.status === "pr-exists") {
53145
+ try {
53146
+ NFS.writeFileSync(workflow.workflowPath, workflow.content);
53147
+ } catch {}
53148
+ upgradeSpinner.succeed(`A React Doctor pull request is already open: ${highlighter.info(pullRequestResult.url)}`);
53149
+ } else if (pullRequestResult.status === "branch-pushed") upgradeSpinner.warn(`Pushed branch ${highlighter.bold(pullRequestResult.branch)} but couldn't open a PR. Open one with: gh pr create --head ${pullRequestResult.branch}`);
52028
53150
  else {
52029
53151
  upgradeSpinner.stop();
52030
53152
  try {
@@ -52077,29 +53199,34 @@ const handoffToAgent = async (input) => {
52077
53199
  outcome: "ci-suppressed",
52078
53200
  diagnosticsCount: input.diagnostics.length
52079
53201
  });
53202
+ const choices = [
53203
+ ...(await detectLaunchableAgents()).map((agentId) => ({
53204
+ title: getSkillAgentConfig(agentId).displayName,
53205
+ description: `Open ${CLI_AGENT_BINARIES[agentId]} here with the top issues as a prompt`,
53206
+ value: agentId
53207
+ })),
53208
+ {
53209
+ title: "Copy prompt to clipboard",
53210
+ description: "Paste into any agent or chat",
53211
+ value: CLIPBOARD_CHOICE
53212
+ },
53213
+ {
53214
+ title: "Skip",
53215
+ description: "Don't hand off",
53216
+ value: SKIP_CHOICE
53217
+ }
53218
+ ];
53219
+ const rememberedTarget = readHandoffTarget();
53220
+ const rememberedChoiceIndex = choices.findIndex((choice) => choice.value === rememberedTarget);
53221
+ const initial = rememberedChoiceIndex >= 0 ? rememberedChoiceIndex : 0;
52080
53222
  const { handoffTarget } = await prompts({
52081
53223
  type: "select",
52082
53224
  name: "handoffTarget",
52083
53225
  message: "What would you like to do next?",
52084
- choices: [
52085
- ...(await detectLaunchableAgents()).map((agentId) => ({
52086
- title: getSkillAgentConfig(agentId).displayName,
52087
- description: `Open ${CLI_AGENT_BINARIES[agentId]} here with the top issues as a prompt`,
52088
- value: agentId
52089
- })),
52090
- {
52091
- title: "Copy prompt to clipboard",
52092
- description: "Paste into any agent or chat",
52093
- value: CLIPBOARD_CHOICE
52094
- },
52095
- {
52096
- title: "Skip",
52097
- description: "Don't hand off",
52098
- value: SKIP_CHOICE
52099
- }
52100
- ],
52101
- initial: 0
53226
+ choices,
53227
+ initial
52102
53228
  }, { onCancel: () => true });
53229
+ if (handoffTarget !== void 0) rememberHandoffTarget(handoffTarget);
52103
53230
  let handoffOutcome = "launch";
52104
53231
  if (handoffTarget === void 0) handoffOutcome = "cancel";
52105
53232
  else if (handoffTarget === SKIP_CHOICE) handoffOutcome = "skip";
@@ -52107,7 +53234,9 @@ const handoffToAgent = async (input) => {
52107
53234
  recordCount(METRIC.agentHandoff, 1, {
52108
53235
  outcome: handoffOutcome,
52109
53236
  agent: handoffOutcome === "launch" ? handoffTarget : void 0,
52110
- diagnosticsCount: input.diagnostics.length
53237
+ diagnosticsCount: input.diagnostics.length,
53238
+ defaultRemembered: rememberedChoiceIndex >= 0,
53239
+ keptDefault: handoffTarget === choices[initial].value
52111
53240
  });
52112
53241
  if (handoffTarget === void 0 || handoffTarget === SKIP_CHOICE) return;
52113
53242
  const payload = buildHandoffPayload({
@@ -52138,6 +53267,47 @@ const handoffToAgent = async (input) => {
52138
53267
  }
52139
53268
  };
52140
53269
  //#endregion
53270
+ //#region src/cli/utils/migrate-action-pin.ts
53271
+ const WORKFLOWS_DIRECTORY = Path.join(".github", "workflows");
53272
+ const RECOMMENDED_ACTION_REF = "v2";
53273
+ const MUTABLE_ACTION_REF = /(uses:\s*[\w.-]+\/react-doctor@)(?:main|master)\b/g;
53274
+ const isWorkflowFile = (fileName) => /\.ya?ml$/.test(fileName);
53275
+ /**
53276
+ * Rewrites mutable `@main` / `@master` React Doctor GitHub Action references in
53277
+ * the repo's `.github/workflows/*.yml` to the recommended floating major
53278
+ * (`@v2`) — a supply-chain hardening (#299) that also moves the workflow onto
53279
+ * the current (install- and scan-cached) action release. Pinned tags / SHAs are
53280
+ * deliberate and left untouched. Returns the absolute paths of the workflow
53281
+ * files it rewrote — empty when there's nothing to migrate.
53282
+ */
53283
+ const migrateActionPin = (projectRoot) => {
53284
+ const workflowsDirectory = Path.join(projectRoot, WORKFLOWS_DIRECTORY);
53285
+ let entries;
53286
+ try {
53287
+ entries = NFS.readdirSync(workflowsDirectory, { withFileTypes: true });
53288
+ } catch {
53289
+ return [];
53290
+ }
53291
+ const rewrittenFiles = [];
53292
+ for (const entry of entries) {
53293
+ if (!entry.isFile() || !isWorkflowFile(entry.name)) continue;
53294
+ const workflowPath = Path.join(workflowsDirectory, entry.name);
53295
+ let contents;
53296
+ try {
53297
+ contents = NFS.readFileSync(workflowPath, "utf-8");
53298
+ } catch {
53299
+ continue;
53300
+ }
53301
+ const updated = contents.replace(MUTABLE_ACTION_REF, `$1${RECOMMENDED_ACTION_REF}`);
53302
+ if (updated === contents) continue;
53303
+ try {
53304
+ NFS.writeFileSync(workflowPath, updated);
53305
+ rewrittenFiles.push(workflowPath);
53306
+ } catch {}
53307
+ }
53308
+ return rewrittenFiles;
53309
+ };
53310
+ //#endregion
52141
53311
  //#region src/cli/utils/read-object-file.ts
52142
53312
  /**
52143
53313
  * Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
@@ -52202,6 +53372,35 @@ export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
52202
53372
  NFS.rmSync(legacy.legacyFilePath, { force: true });
52203
53373
  return targetPath;
52204
53374
  };
53375
+ const PROJECT_MIGRATIONS = [{
53376
+ id: "config-json-to-ts",
53377
+ scope: "project",
53378
+ run: ({ projectRoot }) => {
53379
+ if (projectRoot === void 0) return false;
53380
+ const legacyConfig = findLegacyConfig(projectRoot);
53381
+ if (!legacyConfig) return false;
53382
+ const migratedPath = migrateLegacyConfig(legacyConfig);
53383
+ if (!migratedPath) return false;
53384
+ cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
53385
+ cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, projectRoot)} and commit it.`);
53386
+ cliLogger.break();
53387
+ return true;
53388
+ }
53389
+ }, {
53390
+ id: "action-pin-main-to-v2",
53391
+ scope: "project",
53392
+ run: ({ projectRoot }) => {
53393
+ if (projectRoot === void 0) return false;
53394
+ const rewrittenFiles = migrateActionPin(projectRoot);
53395
+ if (rewrittenFiles.length === 0) return false;
53396
+ const relativeFiles = rewrittenFiles.map((file) => toRelativePath(file, projectRoot)).join(", ");
53397
+ cliLogger.success(`Pinned the React Doctor action to @v2 in ${relativeFiles}`);
53398
+ cliLogger.dim(" An unpinned @main reference runs whatever the action's HEAD points to (a supply-chain risk). Review and commit the change — or revert it if you intentionally track main.");
53399
+ cliLogger.break();
53400
+ return true;
53401
+ }
53402
+ }];
53403
+ const runProjectMigrations = (projectRoot, options = {}) => runMigrations(PROJECT_MIGRATIONS, { projectRoot }, options);
52205
53404
  //#endregion
52206
53405
  //#region src/cli/utils/print-branded-header.ts
52207
53406
  /**
@@ -52421,36 +53620,16 @@ const printMultiProjectSummary = (input) => gen(function* () {
52421
53620
  });
52422
53621
  //#endregion
52423
53622
  //#region src/cli/utils/prompt-install-setup.ts
52424
- const getSetupPromptStore = (options = {}) => new Conf({
52425
- projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
52426
- cwd: options.cwd
52427
- });
52428
- const getSetupPromptProjectKey = (projectRoot) => hashProjectRoot(projectRoot);
52429
- const hasDisabledSetupPrompt = (projectRoot, storeOptions = {}) => {
52430
- try {
52431
- return getSetupPromptStore(storeOptions).get("projects", {})[getSetupPromptProjectKey(projectRoot)]?.setupPrompt === false;
52432
- } catch {
52433
- return false;
52434
- }
52435
- };
52436
- const disableSetupPrompt = (projectRoot, storeOptions = {}) => {
52437
- try {
52438
- const store = getSetupPromptStore(storeOptions);
52439
- const projects = store.get("projects", {});
52440
- const projectKey = getSetupPromptProjectKey(projectRoot);
52441
- store.set("projects", {
52442
- ...projects,
52443
- [projectKey]: {
52444
- ...projects[projectKey] ?? {},
52445
- rootDirectory: Path.resolve(projectRoot),
52446
- setupPrompt: false
52447
- }
52448
- });
52449
- return true;
52450
- } catch {
52451
- return false;
52452
- }
52453
- };
53623
+ const SETUP_HINT_GATE = {
53624
+ id: SETUP_HINT_EVENT,
53625
+ scope: "project",
53626
+ fireWhenUnknown: true
53627
+ };
53628
+ const hasDisabledSetupPrompt = (projectRoot, options = {}) => !isGatePending(SETUP_HINT_GATE, { projectRoot }, options);
53629
+ const disableSetupPrompt = (projectRoot, options = {}) => recordGate(SETUP_HINT_GATE, {
53630
+ projectRoot,
53631
+ outcome: "declined"
53632
+ }, options);
52454
53633
  const resolveInstallSetupProjectRoot = (options) => {
52455
53634
  if (options.scanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
52456
53635
  const packageDirectories = /* @__PURE__ */ new Set();
@@ -52601,7 +53780,7 @@ const warnDeprecatedDiff = (flags, userConfig) => {
52601
53780
  };
52602
53781
  const warnDiffUnavailable = (requested, isQuiet) => {
52603
53782
  if (isQuiet) return;
52604
- if (typeof requested.base === "string") cliLogger.warn(`Could not compute diff against "${requested.base}" (merge-base failed or HEAD has no history). Running full scan.`);
53783
+ if (typeof requested.base === "string") cliLogger.warn(`Could not compute diff against "${requested.base}" (git unavailable, ref not found, or merge-base failed). Running full scan.`);
52605
53784
  else cliLogger.warn("No feature branch or uncommitted changes detected. Running full scan.");
52606
53785
  cliLogger.break();
52607
53786
  };
@@ -52978,15 +54157,9 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
52978
54157
  * are left untouched — the loader still reads the legacy file as a deprecated
52979
54158
  * fallback and warns — so a scan never mutates the repo unattended.
52980
54159
  */
52981
- const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
54160
+ const maybeMigrateLegacyConfig = async (requestedDirectory, { isQuiet, isStaged }) => {
52982
54161
  if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
52983
- const legacyConfig = findLegacyConfig(requestedDirectory);
52984
- if (!legacyConfig) return;
52985
- const migratedPath = migrateLegacyConfig(legacyConfig);
52986
- if (!migratedPath) return;
52987
- cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
52988
- cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
52989
- cliLogger.break();
54162
+ await runProjectMigrations(requestedDirectory);
52990
54163
  };
52991
54164
  const inspectAction = async (directory, flags) => {
52992
54165
  const isScoreOnly = Boolean(flags.score);
@@ -53001,7 +54174,7 @@ const inspectAction = async (directory, flags) => {
53001
54174
  recordCount(METRIC.cliInvoked, 1, { command: "inspect" });
53002
54175
  try {
53003
54176
  validateModeFlags(flags);
53004
- maybeMigrateLegacyConfig(requestedDirectory, {
54177
+ await maybeMigrateLegacyConfig(requestedDirectory, {
53005
54178
  isQuiet,
53006
54179
  isStaged: Boolean(flags.staged)
53007
54180
  });
@@ -53298,6 +54471,10 @@ const installAction = async (options, command) => {
53298
54471
  projectRoot: options.cwd ?? process.cwd()
53299
54472
  });
53300
54473
  } catch (error) {
54474
+ if (isExpectedUserError(error)) {
54475
+ handleUserError(error);
54476
+ return;
54477
+ }
53301
54478
  handleError(error, { sentryEventId: await reportErrorToSentry(error) });
53302
54479
  }
53303
54480
  };
@@ -54305,4 +55482,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
54305
55482
  export {};
54306
55483
 
54307
55484
  //# sourceMappingURL=cli.js.map
54308
- //# debugId=80f0c020-6340-5bba-86a5-7cdbaecdf52d
55485
+ //# debugId=6c903b87-8faf-5702-b078-03465e326385