react-doctor 0.5.7-dev.9f733f7 → 0.5.8-dev.0c19858

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]="87ed2e7a-69a3-5a4d-a104-8ba680ac1aa2")}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]="1bdeba5a-5ce9-5f4f-b9b0-df7abd4c9c0b")}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
@@ -9041,7 +9041,7 @@ const composePassthrough = /* @__PURE__ */ dual(2, (left, right) => (input) => {
9041
9041
  * @since 2.0.0
9042
9042
  */
9043
9043
  const Scheduler = /* @__PURE__ */ Reference("effect/Scheduler", { defaultValue: () => new MixedScheduler() });
9044
- const setImmediate = "setImmediate" in globalThis ? (f) => {
9044
+ const setImmediate$1 = "setImmediate" in globalThis ? (f) => {
9045
9045
  const timer = globalThis.setImmediate(f);
9046
9046
  return () => globalThis.clearImmediate(timer);
9047
9047
  } : (f) => {
@@ -9085,7 +9085,7 @@ var PriorityBuckets = class {
9085
9085
  var MixedScheduler = class {
9086
9086
  executionMode;
9087
9087
  setImmediate;
9088
- constructor(executionMode = "async", setImmediateFn = setImmediate) {
9088
+ constructor(executionMode = "async", setImmediateFn = setImmediate$1) {
9089
9089
  this.executionMode = executionMode;
9090
9090
  this.setImmediate = setImmediateFn;
9091
9091
  }
@@ -9110,7 +9110,7 @@ var MixedSchedulerDispatcher = class {
9110
9110
  tasks = /* @__PURE__ */ new PriorityBuckets();
9111
9111
  running = void 0;
9112
9112
  setImmediate;
9113
- constructor(setImmediateFn = setImmediate) {
9113
+ constructor(setImmediateFn = setImmediate$1) {
9114
9114
  this.setImmediate = setImmediateFn;
9115
9115
  }
9116
9116
  /**
@@ -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,17 @@ 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_WORKER_MEM_BUDGET_BYTES = 2 * 1024 * 1024 * 1024;
36951
+ const DEAD_CODE_TIMEOUT_CEILING_MS = 6e5;
36952
+ const DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS = 3e4;
36953
+ const DEAD_CODE_OVERLAP_PARSE_SHARE = .4;
36842
36954
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
36843
36955
  const REACT_SERVER_DOM_PACKAGES = [
36844
36956
  "react-server-dom-webpack",
@@ -36873,9 +36985,13 @@ const CONFIG_CACHE_TTL_MS = 300 * 1e3;
36873
36985
  const SOCKET_FREE_PURL_API_BASE = "https://firewall-api.socket.dev/purl";
36874
36986
  const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
36875
36987
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
36988
+ const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
36989
+ const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
36876
36990
  const SUPPLY_CHAIN_PLUGIN = "socket";
36877
36991
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
36878
36992
  const SUPPLY_CHAIN_CATEGORY = "Security";
36993
+ const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
36994
+ const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
36879
36995
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
36880
36996
  const TSCONFIG_FILENAME = "tsconfig.json";
36881
36997
  const isRelativeExtendsValue = (extendsValue) => extendsValue.startsWith("./") || extendsValue.startsWith("../") || Path.isAbsolute(extendsValue);
@@ -37565,7 +37681,10 @@ for (const [legacyRuleKey, nativeRuleKey] of Object.entries(LEGACY_RULE_KEY_TO_N
37565
37681
  NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.set(nativeRuleKey, aliases);
37566
37682
  }
37567
37683
  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;
37684
+ const canonicalizeRuleKey = (ruleKey) => {
37685
+ const nativeRuleKey = LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey];
37686
+ return typeof nativeRuleKey === "string" ? nativeRuleKey : ruleKey;
37687
+ };
37569
37688
  const isReactDoctorShortIdOf = (bareRuleKey, qualifiedRuleKey) => !bareRuleKey.includes("/") && qualifiedRuleKey === `react-doctor/${bareRuleKey}`;
37570
37689
  const isSameRuleKey = (candidateRuleKey, targetRuleKey) => {
37571
37690
  const canonicalCandidate = canonicalizeRuleKey(candidateRuleKey);
@@ -38227,6 +38346,11 @@ var OxlintBatchExceeded = class extends TaggedErrorClass()("OxlintBatchExceeded"
38227
38346
  }
38228
38347
  }
38229
38348
  };
38349
+ var ScanDeadlineExceeded = class extends TaggedErrorClass()("ScanDeadlineExceeded", { detail: String$1 }) {
38350
+ get message() {
38351
+ return `Scan exceeded its overall time budget: ${this.detail}`;
38352
+ }
38353
+ };
38230
38354
  var OxlintSpawnFailed = class extends TaggedErrorClass()("OxlintSpawnFailed", { cause: Unknown }) {
38231
38355
  get message() {
38232
38356
  return `Failed to run oxlint: ${pretty(fail$6(this.cause))}`;
@@ -38290,6 +38414,7 @@ var GitBaseBranchInvalid = class extends TaggedErrorClass()("GitBaseBranchInvali
38290
38414
  const ReactDoctorErrorReason = Union([
38291
38415
  OxlintUnavailable,
38292
38416
  OxlintBatchExceeded,
38417
+ ScanDeadlineExceeded,
38293
38418
  OxlintSpawnFailed,
38294
38419
  OxlintOutputUnparseable,
38295
38420
  ConfigParseFailed,
@@ -38362,15 +38487,105 @@ const layerOtlp = unwrap$3(gen(function* () {
38362
38487
  }).pipe(provide$2(layer$9));
38363
38488
  }).pipe(orDie));
38364
38489
  /**
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
38490
+ * Read a positive-millisecond timeout from an env var, falling back to
38491
+ * `defaultMs` when the var is unset, non-finite, or not strictly positive.
38492
+ */
38493
+ const readPositiveEnvMs = (envVarName, defaultMs) => {
38494
+ const rawValue = process.env[envVarName];
38495
+ if (rawValue === void 0) return defaultMs;
38496
+ const parsedValue = Number(rawValue);
38497
+ if (!Number.isFinite(parsedValue) || parsedValue <= 0) return defaultMs;
38498
+ return parsedValue;
38499
+ };
38500
+ const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
38501
+ const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
38502
+ const CGROUP_UNLIMITED_SENTINEL_BYTES = Number.MAX_SAFE_INTEGER;
38503
+ /**
38504
+ * Parses one raw cgroup memory-limit file value into a positive byte count, or
38505
+ * `undefined` when it represents "no limit" (the v2 `"max"` literal, an empty
38506
+ * read, a non-positive / non-finite value, or v1's near-2^63 unlimited
38507
+ * sentinel). Pure and exported so the classification is unit-testable without
38508
+ * touching the filesystem.
38509
+ */
38510
+ const parseCgroupMemoryLimitBytes = (raw) => {
38511
+ if (raw === void 0) return void 0;
38512
+ const trimmed = raw.trim();
38513
+ if (trimmed === "" || trimmed === "max") return void 0;
38514
+ const parsed = Number(trimmed);
38515
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= CGROUP_UNLIMITED_SENTINEL_BYTES) return;
38516
+ return parsed;
38517
+ };
38518
+ const CGROUP_MEMORY_LIMIT_PATHS = [CGROUP_V2_MEMORY_MAX_PATH, CGROUP_V1_MEMORY_LIMIT_PATH];
38519
+ /**
38520
+ * Reads this process's cgroup memory limit in bytes from the first candidate
38521
+ * path that yields a real limit, or `undefined` when none does — no cgroup, no
38522
+ * limit, or the files are unreadable (e.g. macOS / Windows dev machines).
38523
+ * `os.totalmem()` reports the HOST total and ignores cgroup memory limits, so a
38524
+ * memory-constrained container over-reports total memory; `resolveAutoScan-
38525
+ * Concurrency` takes `min(totalmem, this)` to honor the limit.
38526
+ *
38527
+ * The cgroup v2 read is the mount-root `memory.max`, which IS the container's
38528
+ * limit under the standard cgroup-namespace setup CI runners use (the
38529
+ * container's own cgroup is the root of its namespaced view). A process in a
38530
+ * non-namespaced nested/delegated cgroup whose root reads `"max"` is not
38531
+ * detected here and falls back to the host total; the EAGAIN/ENOMEM serial
38532
+ * replay in `spawnLintBatches` remains the runtime backstop for that case.
38533
+ *
38534
+ * `candidatePaths` is injectable so tests exercise the v2-wins-over-v1
38535
+ * precedence, the skip-unreadable fallback, and the all-missing case without a
38536
+ * real `/sys/fs/cgroup`.
38537
+ */
38538
+ const readCgroupMemoryLimitBytes = (candidatePaths = CGROUP_MEMORY_LIMIT_PATHS) => {
38539
+ for (const limitPath of candidatePaths) {
38540
+ let raw;
38541
+ try {
38542
+ raw = fs.readFileSync(limitPath, "utf8");
38543
+ } catch {
38544
+ continue;
38545
+ }
38546
+ const limitBytes = parseCgroupMemoryLimitBytes(raw);
38547
+ if (limitBytes !== void 0) return limitBytes;
38548
+ }
38549
+ };
38550
+ /**
38551
+ * Clamps a requested lint worker count to `[MIN_SCAN_CONCURRENCY,
38552
+ * HARD_MAX_SCAN_CONCURRENCY]` as a finite integer. This is the explicit-pin and
38553
+ * spawn-boundary clamp — the memory-and-core-budgeted auto count comes from
38554
+ * `resolveAutoScanConcurrency`. Out-of-range or non-finite requests degrade to
38368
38555
  * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
38369
38556
  */
38370
38557
  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));
38558
+ if (!Number.isFinite(requested) || requested < 1) return 1;
38559
+ return Math.min(Math.floor(requested), 32);
38560
+ };
38561
+ const readSystemFacts$1 = () => ({
38562
+ availableCores: os.availableParallelism(),
38563
+ totalMemoryBytes: os.totalmem(),
38564
+ cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
38565
+ });
38566
+ /**
38567
+ * Auto lint-worker count: the smaller of the (cgroup-CPU-aware) core count and
38568
+ * the number of `PER_WORKER_MEM_BUDGET_BYTES` workers that fit in available
38569
+ * memory, then clamped to `[MIN, HARD_MAX]` by `resolveScanConcurrency`.
38570
+ *
38571
+ * `os.availableParallelism()` already respects cgroup CPU quotas, so the core
38572
+ * term needs no help. Available memory is `os.totalmem()` floored by the cgroup
38573
+ * memory limit — `os.freemem()` is deliberately NOT used: it excludes
38574
+ * reclaimable page cache and reads near-zero on macOS / cache-heavy Linux, which
38575
+ * would collapse the auto path to a single worker. `os.totalmem()` reports the
38576
+ * host total even inside a container, so the cgroup limit (read directly,
38577
+ * because Node doesn't fold it into `totalmem()`) is the real ceiling there.
38578
+ *
38579
+ * `facts` is injectable so tests exercise core-bound, memory-bound, cgroup-
38580
+ * limited, and ceiling cases without mocking `os` or the filesystem.
38581
+ */
38582
+ const resolveAutoScanConcurrency = (facts = readSystemFacts$1()) => {
38583
+ const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
38584
+ const memoryBoundedWorkers = Math.floor(availableMemoryBytes / PER_WORKER_MEM_BUDGET_BYTES);
38585
+ return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
38586
+ };
38587
+ const resolveLintBatchOrdering = () => {
38588
+ return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "cost" ? "cost" : "arrival";
38374
38589
  };
38375
38590
  /**
38376
38591
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -38378,11 +38593,38 @@ const resolveScanConcurrency = (requested) => {
38378
38593
  * microVMs without recompiling react-doctor. Tests override via
38379
38594
  * `Layer.succeed(OxlintSpawnTimeoutMs, ...)`.
38380
38595
  */
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;
38596
+ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS", OXLINT_SPAWN_TIMEOUT_MS) }) {};
38597
+ /**
38598
+ * Effect-side cap on the lint phase. The env var lets CI / eval runners
38599
+ * raise the phase budget for slow large repos without recompiling.
38600
+ * Tests override via `Layer.succeed(LintPhaseTimeoutMs, ...)`.
38601
+ */
38602
+ var LintPhaseTimeoutMs = class extends Reference("react-doctor/LintPhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_LINT_PHASE_TIMEOUT_MS", LINT_PHASE_TIMEOUT_MS) }) {};
38603
+ /**
38604
+ * Effect-side cap on the dead-code phase, sitting above the in-worker
38605
+ * timeout as a runtime-independent backstop. The env var raises it for
38606
+ * type-heavy projects; tests override via
38607
+ * `Layer.succeed(DeadCodePhaseTimeoutMs, ...)`.
38608
+ */
38609
+ var DeadCodePhaseTimeoutMs = class extends Reference("react-doctor/DeadCodePhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_DEAD_CODE_PHASE_TIMEOUT_MS", DEAD_CODE_PHASE_TIMEOUT_MS) }) {};
38610
+ /**
38611
+ * Overall scan deadline backstop, bounding everything the per-phase
38612
+ * timeouts don't (wedged git / IO). The env var raises it for very
38613
+ * large repos; tests override via `Layer.succeed(ScanDeadlineMs, ...)`.
38614
+ */
38615
+ var ScanDeadlineMs = class extends Reference("react-doctor/ScanDeadlineMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_SCAN_DEADLINE_MS", SCAN_TOTAL_DEADLINE_MS) }) {};
38616
+ /**
38617
+ * Wall-clock budget for the supply-chain check when it runs on a background
38618
+ * fiber overlapping the lint pass. Reads from the env var on startup so the
38619
+ * eval harness can raise the budget under sandbox microVMs (slower network)
38620
+ * without recompiling react-doctor. Tests override via
38621
+ * `Layer.succeed(SupplyChainOverlapTimeoutMs, ...)`.
38622
+ */
38623
+ var SupplyChainOverlapTimeoutMs = class extends Reference("react-doctor/SupplyChainOverlapTimeoutMs", { defaultValue: () => {
38624
+ const raw = process.env["REACT_DOCTOR_SUPPLY_CHAIN_TIMEOUT_MS"];
38625
+ if (raw === void 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
38384
38626
  const parsed = Number(raw);
38385
- if (!Number.isFinite(parsed) || parsed <= 0) return OXLINT_SPAWN_TIMEOUT_MS;
38627
+ if (!Number.isFinite(parsed) || parsed <= 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
38386
38628
  return parsed;
38387
38629
  } }) {};
38388
38630
  /**
@@ -38393,31 +38635,93 @@ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTime
38393
38635
  */
38394
38636
  var OxlintOutputMaxBytes = class extends Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
38395
38637
  /**
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)
38638
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults to a
38639
+ * memory-and-core-budgeted auto count (`resolveAutoScanConcurrency`) so large
38640
+ * repos scan fast out of the box without OOMing the native binding on a
38641
+ * high-core / low-memory box; `spawnLintBatches` transparently falls back to a
38642
+ * single worker if a parallel run still exhausts system resources. The CLI's
38643
+ * `--no-parallel` flag forces serial via `Layer.succeed`; the
38644
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic / CI
38645
+ * callers that never touch the flag — parallelism is opt-OUT, so only the
38646
+ * explicit serial values pin one worker:
38647
+ *
38648
+ * - unset / `auto` / `true` / `on` → memory-and-core-budgeted auto count
38406
38649
  * - `0` / `false` / `off` → `1` (serial)
38407
38650
  * - a positive integer → that many workers (clamped)
38408
- * - any other value → available CPU cores (clamped)
38651
+ * - any other value → memory-and-core-budgeted auto count
38409
38652
  *
38410
38653
  * The resolved value is always within
38411
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
38654
+ * `[MIN_SCAN_CONCURRENCY, HARD_MAX_SCAN_CONCURRENCY]`.
38412
38655
  */
38413
38656
  var OxlintConcurrency = class extends Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
38414
38657
  const raw = process.env["REACT_DOCTOR_PARALLEL"];
38415
- if (raw === void 0) return resolveScanConcurrency("auto");
38658
+ if (raw === void 0) return resolveAutoScanConcurrency();
38416
38659
  const normalized = raw.trim().toLowerCase();
38417
38660
  if (normalized === "0" || normalized === "false" || normalized === "off") return 1;
38418
38661
  const parsed = Number.parseInt(normalized, 10);
38419
38662
  if (Number.isInteger(parsed) && parsed > 0) return resolveScanConcurrency(parsed);
38420
- return resolveScanConcurrency("auto");
38663
+ return resolveAutoScanConcurrency();
38664
+ } }) {};
38665
+ /**
38666
+ * Three-state control for overlapping the dead-code pass with the lint pass —
38667
+ * forking dead-code as a child fiber that runs DURING lint instead of strictly
38668
+ * after it.
38669
+ *
38670
+ * - `"auto"` (default) / `"off"` → strictly SEQUENTIAL: dead-code runs after
38671
+ * lint with the full core budget. Both deslop's parse pool and the oxlint
38672
+ * pool are CPU-bound and each size themselves to all cores, so overlapping
38673
+ * them only oversubscribes (~2x the cores) and starves the parse pass past
38674
+ * its timeout — for no wall-clock win, since there are no spare cores to
38675
+ * absorb the second pass. Sequential is both faster per-phase and safe.
38676
+ * - `"on"` → force the overlap anyway. The orchestrator then SPLITS the core
38677
+ * budget (`DEAD_CODE_OVERLAP_PARSE_SHARE`): deslop's parse pool is capped
38678
+ * and lint shrinks to the remainder, so the two sum to the cores instead of
38679
+ * doubling them, and the dead-code timeout scales up for the reduced share.
38680
+ *
38681
+ * Seeded from `REACT_DOCTOR_DEAD_CODE_OVERLAP` so operators get a redeploy-free
38682
+ * switch; tests pin it via `Layer.succeed(DeadCodeOverlap, ...)`.
38683
+ */
38684
+ var DeadCodeOverlap = class extends Reference("react-doctor/DeadCodeOverlap", { defaultValue: () => {
38685
+ const raw = process.env["REACT_DOCTOR_DEAD_CODE_OVERLAP"]?.trim().toLowerCase();
38686
+ if (raw === "on" || raw === "true" || raw === "1") return "on";
38687
+ if (raw === "off" || raw === "false" || raw === "0") return "off";
38688
+ return "auto";
38689
+ } }) {};
38690
+ /**
38691
+ * How the full-scan lint pass orders its file batches. `"arrival"` (the
38692
+ * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
38693
+ * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
38694
+ * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
38695
+ * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
38696
+ * batch is a straggler (and can trip the per-batch timeout + split), measurably
38697
+ * regressing the common full-scan case. LPT needs the heavy files SPREAD across
38698
+ * batches before `cost` earns the default. Tests override via
38699
+ * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
38700
+ * — they pass user-scoped `includePaths` that skip discovery and stay in
38701
+ * arrival order; only the full-scan branch reads it.
38702
+ */
38703
+ var LintBatchOrdering = class extends Reference("react-doctor/LintBatchOrdering", { defaultValue: resolveLintBatchOrdering }) {};
38704
+ const CACHE_DISABLED_VALUES$1 = new Set(["1", "true"]);
38705
+ /**
38706
+ * Whether the per-file lint cache (`runners/oxlint/file-lint-cache.ts`) is
38707
+ * active. Defaults ON — repeat scans re-lint only the files whose content
38708
+ * changed, and correctness is guaranteed byte-identical to a cold scan by the
38709
+ * always-fresh cross-file sidecar. Opt-OUT, two knobs (matching the whole-repo
38710
+ * scan cache's `REACT_DOCTOR_NO_CACHE`):
38711
+ *
38712
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch; disables BOTH the
38713
+ * whole-repo scan cache and this per-file cache.
38714
+ * - `REACT_DOCTOR_NO_FILE_CACHE` — granular: bust only the per-file cache
38715
+ * while keeping the whole-repo short-circuit.
38716
+ *
38717
+ * Tests override via `Layer.succeed(PerFileLintCacheEnabled, false)`.
38718
+ */
38719
+ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintCacheEnabled", { defaultValue: () => {
38720
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
38721
+ const noFileCache = process.env["REACT_DOCTOR_NO_FILE_CACHE"]?.toLowerCase() ?? "";
38722
+ if (CACHE_DISABLED_VALUES$1.has(noCache)) return false;
38723
+ if (CACHE_DISABLED_VALUES$1.has(noFileCache)) return false;
38724
+ return true;
38421
38725
  } }) {};
38422
38726
  const DIAGNOSTIC_SURFACES = [
38423
38727
  "cli",
@@ -38466,6 +38770,12 @@ const BOOLEAN_FIELD_NAMES = [
38466
38770
  "adoptExistingLintConfig"
38467
38771
  ];
38468
38772
  const STRING_FIELD_NAMES = ["rootDir"];
38773
+ const STRING_ARRAY_FIELD_NAMES = [
38774
+ "projects",
38775
+ "textComponents",
38776
+ "rawTextWrapperComponents",
38777
+ "serverAuthFunctionNames"
38778
+ ];
38469
38779
  const SURFACE_CONTROL_FIELD_NAMES = [
38470
38780
  "includeTags",
38471
38781
  "excludeTags",
@@ -38567,6 +38877,7 @@ const validateConfigTypes = (config) => {
38567
38877
  const validated = { ...config };
38568
38878
  for (const fieldName of BOOLEAN_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => coerceMaybeBooleanString(fieldName, value));
38569
38879
  for (const fieldName of STRING_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateString(fieldName, value));
38880
+ for (const fieldName of STRING_ARRAY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateStringArrayField(fieldName, value));
38570
38881
  applyFieldValidator(config, validated, "surfaces", validateSurfacesField);
38571
38882
  for (const fieldName of SEVERITY_FIELD_NAMES) applyFieldValidator(config, validated, fieldName, (value) => validateSeverityMap(fieldName, value, fieldName === "categories"));
38572
38883
  applyFieldValidator(config, validated, "plugins", (value) => validateStringArrayField("plugins", value));
@@ -38851,6 +39162,8 @@ const assignFixGroups = (diagnostics) => {
38851
39162
  };
38852
39163
  });
38853
39164
  };
39165
+ const compareStrings = (left, right) => left < right ? -1 : left > right ? 1 : 0;
39166
+ 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
39167
  const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
38855
39168
  const buildExpoCheckContext = (rootDirectory, expoVersion) => {
38856
39169
  const packageJson = readPackageJson$1(Path.join(rootDirectory, "package.json"));
@@ -39827,7 +40140,10 @@ const shouldEnableRule = (requires, tags, capabilities, ignoredTags, disabledBy)
39827
40140
  }
39828
40141
  return true;
39829
40142
  };
39830
- const checkSecurityScan = (rootDirectory, options = {}) => {
40143
+ const yieldToEventLoop = () => new Promise((resolve) => {
40144
+ setImmediate(resolve);
40145
+ });
40146
+ const createSecurityScanSession = (rootDirectory, options) => {
39831
40147
  const capabilities = options.project ? buildCapabilities(options.project) : /* @__PURE__ */ new Set();
39832
40148
  const ignoredTags = options.ignoredTags ?? /* @__PURE__ */ new Set();
39833
40149
  const enabledScanRules = REACT_DOCTOR_RULES.flatMap((entry) => {
@@ -39842,7 +40158,7 @@ const checkSecurityScan = (rootDirectory, options = {}) => {
39842
40158
  committedFilesOnly: rule.committedFilesOnly === true
39843
40159
  }];
39844
40160
  });
39845
- if (enabledScanRules.length === 0) return [];
40161
+ if (enabledScanRules.length === 0) return null;
39846
40162
  const diagnostics = [];
39847
40163
  const seen = /* @__PURE__ */ new Set();
39848
40164
  const gitIgnoredCache = /* @__PURE__ */ new Map();
@@ -39854,15 +40170,34 @@ const checkSecurityScan = (rootDirectory, options = {}) => {
39854
40170
  }
39855
40171
  return status === true;
39856
40172
  };
39857
- for (const file of collectSecurityScanFiles(rootDirectory)) for (const { entry, scan, committedFilesOnly } of enabledScanRules) for (const finding of scan(file)) {
39858
- if (committedFilesOnly && isFileGitIgnored(file)) continue;
39859
- const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
39860
- const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
39861
- if (seen.has(key)) continue;
39862
- seen.add(key);
39863
- diagnostics.push(diagnostic);
40173
+ const scanFile = (file) => {
40174
+ for (const { entry, scan, committedFilesOnly } of enabledScanRules) for (const finding of scan(file)) {
40175
+ if (committedFilesOnly && isFileGitIgnored(file)) continue;
40176
+ const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
40177
+ const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
40178
+ if (seen.has(key)) continue;
40179
+ seen.add(key);
40180
+ diagnostics.push(diagnostic);
40181
+ }
40182
+ };
40183
+ return {
40184
+ scanFile,
40185
+ diagnostics
40186
+ };
40187
+ };
40188
+ const checkSecurityScanCooperative = async (rootDirectory, options = {}) => {
40189
+ const session = createSecurityScanSession(rootDirectory, options);
40190
+ if (session === null) return [];
40191
+ let filesSinceYield = 0;
40192
+ for (const file of collectSecurityScanFiles(rootDirectory)) {
40193
+ session.scanFile(file);
40194
+ filesSinceYield += 1;
40195
+ if (filesSinceYield >= 16) {
40196
+ filesSinceYield = 0;
40197
+ await yieldToEventLoop();
40198
+ }
39864
40199
  }
39865
- return diagnostics;
40200
+ return session.diagnostics;
39866
40201
  };
39867
40202
  var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
39868
40203
  let p = process || {}, argv = p.argv || [], env = p.env || {};
@@ -40095,6 +40430,74 @@ const collectDeadCodeIgnorePatterns = (rootDirectory) => {
40095
40430
  return [...seen].filter((pattern) => pattern.length > 0);
40096
40431
  };
40097
40432
  const collectDeadCodeEntryPatterns = (rootDirectory) => [...new Set(collectKnipPatterns(rootDirectory, "entry"))].filter((pattern) => pattern.length > 0);
40433
+ const readSystemFacts = () => ({
40434
+ availableCores: os.availableParallelism(),
40435
+ totalMemoryBytes: os.totalmem(),
40436
+ cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
40437
+ });
40438
+ /**
40439
+ * How many real deslop dead-code child processes may run at once, across the
40440
+ * concurrent per-project `runInspect` fibers of one CLI run. The cap is the
40441
+ * smaller of the core count and the number of `DEAD_CODE_WORKER_MEM_BUDGET_BYTES`
40442
+ * workers that fit in available memory, floored at 1.
40443
+ *
40444
+ * On a roomy dev box / CI runner this resolves high enough that every
40445
+ * concurrently-scanned project still spawns its own worker (no serialization vs
40446
+ * the prior uncapped behavior); on a memory-constrained runner it collapses
40447
+ * toward 1, so the `withDeadCodeWorkerSlot` semaphore serializes the spawns
40448
+ * instead of oversubscribing memory with N simultaneous children — the global
40449
+ * cap the per-project spawn path lacked.
40450
+ *
40451
+ * Mirrors `resolveAutoScanConcurrency` (lint), but budgets memory per the
40452
+ * heavier dead-code worker. `facts` is injectable for tests.
40453
+ */
40454
+ const resolveDeadCodeConcurrency = (facts = readSystemFacts()) => {
40455
+ const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
40456
+ const memoryBoundedWorkers = Math.floor(availableMemoryBytes / DEAD_CODE_WORKER_MEM_BUDGET_BYTES);
40457
+ return Math.max(1, Math.min(facts.availableCores, memoryBoundedWorkers));
40458
+ };
40459
+ let availableSlots = -1;
40460
+ const waiters = [];
40461
+ const releaseSlot = () => {
40462
+ const nextWaiter = waiters.shift();
40463
+ if (nextWaiter !== void 0) nextWaiter();
40464
+ else availableSlots += 1;
40465
+ };
40466
+ /**
40467
+ * Runs `task` once a dead-code worker slot is free, releasing the slot when the
40468
+ * task settles (success or failure). With a high cap (roomy machine) every
40469
+ * caller proceeds immediately; with a low cap (constrained runner) callers
40470
+ * queue and run as slots free.
40471
+ *
40472
+ * `abortSignal` short-circuits the WAIT: if it's already aborted, or fires while
40473
+ * this caller is queued, the call rejects without acquiring a slot or running
40474
+ * `task` — so a cancelled scan (e.g. lint failed) doesn't sit in the queue and
40475
+ * then spawn a child only to tear it down. A queued caller that aborts removes
40476
+ * its own waiter so a later release never hands a slot to a dead request.
40477
+ */
40478
+ const withDeadCodeWorkerSlot = async (task, abortSignal) => {
40479
+ if (abortSignal?.aborted) throw new Error("Dead-code worker aborted.");
40480
+ if (availableSlots < 0) availableSlots = resolveDeadCodeConcurrency();
40481
+ if (availableSlots > 0) availableSlots -= 1;
40482
+ else await new Promise((resolve, reject) => {
40483
+ const waiter = () => {
40484
+ abortSignal?.removeEventListener("abort", onAbort);
40485
+ resolve();
40486
+ };
40487
+ const onAbort = () => {
40488
+ const queuedIndex = waiters.indexOf(waiter);
40489
+ if (queuedIndex !== -1) waiters.splice(queuedIndex, 1);
40490
+ reject(/* @__PURE__ */ new Error("Dead-code worker aborted."));
40491
+ };
40492
+ waiters.push(waiter);
40493
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
40494
+ });
40495
+ try {
40496
+ return await task();
40497
+ } finally {
40498
+ releaseSlot();
40499
+ }
40500
+ };
40098
40501
  /**
40099
40502
  * Resolves a path to its canonical, symlink-free form, falling back to
40100
40503
  * the input when it cannot be realpath'd (broken symlink, permission
@@ -40166,6 +40569,22 @@ process.stdin.on("end", () => {
40166
40569
  ...(workerInput.ignorePatterns.length > 0
40167
40570
  ? { ignorePatterns: workerInput.ignorePatterns }
40168
40571
  : {}),
40572
+ // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
40573
+ // unusedDependencies, circularDependencies). Everything else deslop can compute
40574
+ // is pure wasted work for us, and it's the bulk of the runtime:
40575
+ // - semantic: a full TS Program for unusedTypes/enum/class-members/
40576
+ // misclassifiedDependencies (~37-45% of the phase).
40577
+ // - reportCodeQuality: the duplicate-block, complexity, feature-flag,
40578
+ // TypeScript-smell, private-type-leak and re-export-cycle detectors. These
40579
+ // are the single most expensive pass — duplicate-block detection alone was
40580
+ // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
40581
+ // speedup on a large repo.
40582
+ // Both are provably safe: the consumed graph findings are computed by their own
40583
+ // detectors, independent of these passes (confirmed byte-identical on
40584
+ // excalidraw + mui-material + sentry). tsConfigPath stays — the module resolver
40585
+ // needs it for path-alias resolution in the import graph.
40586
+ semantic: { enabled: false },
40587
+ reportCodeQuality: false,
40169
40588
  };
40170
40589
  const result = await analyze(defineConfig(config));
40171
40590
  emit({ ok: true, result: normalizeResult(result) });
@@ -40295,7 +40714,11 @@ const createDeadCodeWorker = (input) => {
40295
40714
  "pipe",
40296
40715
  "pipe"
40297
40716
  ],
40298
- windowsHide: true
40717
+ windowsHide: true,
40718
+ env: input.parseConcurrency === void 0 ? process.env : {
40719
+ ...process.env,
40720
+ DESLOP_PARSE_CONCURRENCY: String(input.parseConcurrency)
40721
+ }
40299
40722
  });
40300
40723
  const stdoutChunks = [];
40301
40724
  const stderrChunks = [];
@@ -40340,41 +40763,42 @@ const createDeadCodeWorker = (input) => {
40340
40763
  }
40341
40764
  };
40342
40765
  };
40343
- const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve, reject) => {
40766
+ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs, abortSignal) => new Promise((resolve, reject) => {
40344
40767
  let didSettle = false;
40345
- const timeoutHandle = setTimeout(() => {
40346
- if (didSettle) return;
40347
- didSettle = true;
40348
- handle.terminate?.();
40349
- reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`));
40350
- }, timeoutMs);
40351
- timeoutHandle.unref?.();
40352
- handle.result.then((value) => {
40768
+ const settle = (finish) => {
40353
40769
  if (didSettle) return;
40354
40770
  didSettle = true;
40355
40771
  clearTimeout(timeoutHandle);
40772
+ abortSignal?.removeEventListener("abort", onAbort);
40356
40773
  handle.terminate?.();
40357
- resolve(value);
40358
- }, (error) => {
40359
- if (didSettle) return;
40360
- didSettle = true;
40361
- clearTimeout(timeoutHandle);
40362
- handle.terminate?.();
40363
- reject(error);
40364
- });
40774
+ finish();
40775
+ };
40776
+ const onAbort = () => settle(() => reject(/* @__PURE__ */ new Error("Dead-code worker aborted.")));
40777
+ const timeoutHandle = setTimeout(() => settle(() => reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`))), timeoutMs);
40778
+ timeoutHandle.unref?.();
40779
+ if (abortSignal?.aborted) {
40780
+ onAbort();
40781
+ return;
40782
+ }
40783
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
40784
+ handle.result.then((value) => settle(() => resolve(value)), (error) => settle(() => reject(error)));
40365
40785
  });
40366
40786
  const checkDeadCode = async (options) => {
40367
40787
  const rootDirectory = toCanonicalPath(options.rootDirectory);
40368
40788
  if (!NFS.existsSync(Path.join(rootDirectory, "package.json"))) return [];
40369
40789
  const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
40370
40790
  const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory);
40371
- const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
40372
- rootDirectory,
40373
- entryPatterns,
40374
- tsConfigPath: resolveTsConfigPath(rootDirectory),
40375
- ignorePatterns,
40376
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js")
40377
- }), options.workerTimeoutMs ?? 12e4));
40791
+ const spawnAndRun = () => {
40792
+ return runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
40793
+ rootDirectory,
40794
+ entryPatterns,
40795
+ tsConfigPath: resolveTsConfigPath(rootDirectory),
40796
+ ignorePatterns,
40797
+ deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
40798
+ parseConcurrency: options.parseConcurrency
40799
+ }), options.workerTimeoutMs ?? 12e4, options.abortSignal);
40800
+ };
40801
+ const result = parseDeadCodeWorkerResult(options.createWorker === void 0 ? await withDeadCodeWorkerSlot(spawnAndRun, options.abortSignal) : await spawnAndRun());
40378
40802
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
40379
40803
  const diagnostics = [];
40380
40804
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -40472,7 +40896,37 @@ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
40472
40896
  return true;
40473
40897
  };
40474
40898
  const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
40475
- const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(Path.resolve(rootDirectory, relativePath)));
40899
+ /**
40900
+ * Budget for the dead-code phase, scaled to the work. deslop's graph build is
40901
+ * CPU-bound and roughly linear in file count, so a fixed 120s cap is too tight
40902
+ * for a large repo (where the pass legitimately runs that long) and is then
40903
+ * tipped over by any concurrent load — silently dropping every dead-code
40904
+ * finding. Scaling the budget with file count (and inversely with the core
40905
+ * share when overlapped) lets the pass complete, while the ceiling still
40906
+ * reclaims a genuinely wedged worker. Returns the in-worker SIGKILL deadline
40907
+ * and the Effect-side phase backstop that sits a margin above it.
40908
+ */
40909
+ const resolveDeadCodeTimeout = (input) => {
40910
+ const coreShareFactor = Math.max(1, input.fullConcurrency / Math.max(1, input.deadCodeConcurrency));
40911
+ const workerTimeoutMs = Math.min(DEAD_CODE_TIMEOUT_CEILING_MS, Math.max(DEAD_CODE_WORKER_TIMEOUT_MS, Math.ceil(input.sourceFileCount * 30 * coreShareFactor)));
40912
+ return {
40913
+ workerTimeoutMs,
40914
+ phaseTimeoutMs: workerTimeoutMs + DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS
40915
+ };
40916
+ };
40917
+ const collectSizedSourceFiles = (rootDirectory, relativePaths) => {
40918
+ const entries = [];
40919
+ for (const relativePath of relativePaths) {
40920
+ const absolutePath = Path.resolve(rootDirectory, relativePath);
40921
+ const sizeBytes = statSourceFileSize(absolutePath);
40922
+ if (isLargeMinifiedFile(absolutePath, sizeBytes)) continue;
40923
+ entries.push({
40924
+ path: relativePath,
40925
+ sizeBytes: sizeBytes ?? 0
40926
+ });
40927
+ }
40928
+ return entries;
40929
+ };
40476
40930
  const listSourceFilesViaGit = (rootDirectory) => {
40477
40931
  const result = spawnSync("git", [
40478
40932
  "ls-files",
@@ -40505,7 +40959,8 @@ const listSourceFilesViaFilesystem = (rootDirectory) => {
40505
40959
  }
40506
40960
  return filePaths;
40507
40961
  };
40508
- const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
40962
+ const listSourceFilesWithSize = (rootDirectory) => collectSizedSourceFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
40963
+ const listSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).map((entry) => entry.path);
40509
40964
  const resolveLintIncludePaths = (rootDirectory, userConfig, project) => {
40510
40965
  if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
40511
40966
  const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
@@ -40548,9 +41003,12 @@ var Config = class Config extends Service()("react-doctor/Config") {
40548
41003
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
40549
41004
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
40550
41005
  return yield* tryPromise({
40551
- try: () => checkDeadCode({
41006
+ try: (signal) => checkDeadCode({
40552
41007
  rootDirectory: input.rootDirectory,
40553
- userConfig: input.userConfig
41008
+ userConfig: input.userConfig,
41009
+ parseConcurrency: input.parseConcurrency,
41010
+ workerTimeoutMs: input.workerTimeoutMs,
41011
+ abortSignal: signal
40554
41012
  }),
40555
41013
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
40556
41014
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -40741,43 +41199,46 @@ var Git = class Git extends Service()("react-doctor/Git") {
40741
41199
  * reason: GitInvocationFailed })` so the rest of the codebase
40742
41200
  * sees a single failure channel.
40743
41201
  */
40744
- const runCommand = (input) => scoped(gen(function* () {
40745
- const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
40746
- cwd: input.directory,
40747
- env: input.env,
40748
- extendEnv: true
40749
- }));
40750
- const maxStdoutBytes = input.maxStdoutBytes;
40751
- const stdoutByteCount = yield* make$13(0);
40752
- const [stdout, stderr, status] = yield* all([
40753
- 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({
40754
- args: [...input.args],
40755
- directory: input.directory,
40756
- cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
40757
- }) })) : void_)))))),
40758
- mkString(decodeText(handle.stderr)),
40759
- handle.exitCode
40760
- ], { concurrency: 3 });
40761
- return {
40762
- status,
40763
- stdout,
40764
- stderr
40765
- };
40766
- })).pipe(catchTag$1("PlatformError", (cause) => {
40767
- if (input.command !== "git") return succeed$2({
41202
+ const runCommand = (input) => {
41203
+ const foldSpawnFailure = (cause) => input.command !== "git" ? succeed$2({
40768
41204
  status: 127,
40769
41205
  stdout: "",
40770
41206
  stderr: String(cause)
40771
- });
40772
- return new ReactDoctorError({ reason: new GitInvocationFailed({
41207
+ }) : fail$4(new ReactDoctorError({ reason: new GitInvocationFailed({
40773
41208
  args: [...input.args],
40774
41209
  directory: input.directory,
40775
41210
  cause
40776
- }) });
40777
- }), withSpan("git.exec", { attributes: {
40778
- "git.command": input.command,
40779
- "git.subcommand": input.args[0] ?? ""
40780
- } }));
41211
+ }) }));
41212
+ return scoped(gen(function* () {
41213
+ if (!isDirectory(input.directory)) return yield* foldSpawnFailure(`spawn ENOTDIR (cwd is not a directory: ${input.directory})`);
41214
+ const argvLengthChars = input.command.length + 1 + input.args.reduce((total, arg) => total + arg.length + 1, 0);
41215
+ if (argvLengthChars > 24e3) return yield* foldSpawnFailure(`spawn ENAMETOOLONG (${argvLengthChars} argv chars exceed ${SPAWN_ARGS_MAX_LENGTH_CHARS})`);
41216
+ const handle = yield* spawner.spawn(make$1(input.command, [...input.args], {
41217
+ cwd: input.directory,
41218
+ env: input.env,
41219
+ extendEnv: true
41220
+ }));
41221
+ const maxStdoutBytes = input.maxStdoutBytes;
41222
+ const stdoutByteCount = yield* make$13(0);
41223
+ const [stdout, stderr, status] = yield* all([
41224
+ 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({
41225
+ args: [...input.args],
41226
+ directory: input.directory,
41227
+ cause: /* @__PURE__ */ new Error(`git stdout exceeded ${maxStdoutBytes} bytes`)
41228
+ }) })) : void_)))))),
41229
+ mkString(decodeText(handle.stderr)),
41230
+ handle.exitCode
41231
+ ], { concurrency: 3 });
41232
+ return {
41233
+ status,
41234
+ stdout,
41235
+ stderr
41236
+ };
41237
+ })).pipe(catchTag$1("PlatformError", foldSpawnFailure), withSpan("git.exec", { attributes: {
41238
+ "git.command": input.command,
41239
+ "git.subcommand": input.args[0] ?? ""
41240
+ } }));
41241
+ };
40781
41242
  const runGit = (directory, args) => runCommand({
40782
41243
  command: "git",
40783
41244
  args,
@@ -40810,7 +41271,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40810
41271
  "rev-parse",
40811
41272
  "--verify",
40812
41273
  branch
40813
- ]).pipe(map$3((result) => result.status === 0));
41274
+ ]).pipe(map$3((result) => result.status === 0), catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(false) : fail$4(error)));
40814
41275
  const headSha = (directory) => runGit(directory, ["rev-parse", "HEAD"]).pipe(map$3((result) => result.status === 0 ? trimOrNull(result.stdout) : null));
40815
41276
  const mergeBase = (input) => isSafeGitRevision(input.ref) ? runGit(input.directory, [
40816
41277
  "merge-base",
@@ -41024,7 +41485,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
41024
41485
  ]);
41025
41486
  if (result.status !== 0) return null;
41026
41487
  return parseChangedLineRanges(result.stdout);
41027
- }).pipe(withSpan("Git.changedLineRanges"))
41488
+ }).pipe(catch_$1((error) => error.reason._tag === "GitInvocationFailed" ? succeed$2(null) : fail$4(error)), withSpan("Git.changedLineRanges"))
41028
41489
  });
41029
41490
  })).pipe(provide$2(layer$3.pipe(provide$2(mergeAll$1(layer$2, layer$1)))));
41030
41491
  /**
@@ -41263,6 +41724,14 @@ const neutralizeDisableDirectives = async (rootDirectory, includePaths) => {
41263
41724
  process.removeListener("exit", onExit);
41264
41725
  };
41265
41726
  };
41727
+ const ROOT_DIRECTORY_PLACEHOLDER = "<root>";
41728
+ const normalizeConfigForHash = (config) => {
41729
+ const clone = JSON.parse(JSON.stringify(config));
41730
+ if (clone?.settings?.["react-doctor"]) clone.settings["react-doctor"].rootDirectory = ROOT_DIRECTORY_PLACEHOLDER;
41731
+ if (Array.isArray(clone?.jsPlugins)) clone.jsPlugins = clone.jsPlugins.map((_, index) => `<plugin:${index}>`);
41732
+ return clone;
41733
+ };
41734
+ 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");
41266
41735
  /**
41267
41736
  * Loads a plugin module via the local require resolver and extracts
41268
41737
  * `(name, ruleNames)` from either `module.exports.meta + rules` or
@@ -41417,8 +41886,8 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
41417
41886
  }
41418
41887
  return enabled;
41419
41888
  };
41420
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false }) => {
41421
- const reactHooksJsPlugin = disableReactHooksJsPlugin ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
41889
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection }) => {
41890
+ const reactHooksJsPlugin = disableReactHooksJsPlugin || ruleSelection === "sidecar" ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
41422
41891
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
41423
41892
  const jsPlugins = [];
41424
41893
  if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
@@ -41427,6 +41896,8 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41427
41896
  for (const registryEntry of REACT_DOCTOR_RULES) {
41428
41897
  const rule = reactDoctorPlugin.rules[registryEntry.id];
41429
41898
  if (!rule) continue;
41899
+ if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
41900
+ if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
41430
41901
  if (rule.scan !== void 0) continue;
41431
41902
  if (customRulesOnly && registryEntry.originallyExternal) continue;
41432
41903
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -41441,7 +41912,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41441
41912
  enabledReactDoctorRules[registryEntry.key] = severity;
41442
41913
  }
41443
41914
  const userPluginRules = {};
41444
- for (const userPlugin of userPlugins) {
41915
+ if (ruleSelection !== "sidecar") for (const userPlugin of userPlugins) {
41445
41916
  Object.assign(userPluginRules, buildUserPluginRules(userPlugin, severityControls));
41446
41917
  jsPlugins.push(userPlugin.entry);
41447
41918
  }
@@ -41471,6 +41942,100 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41471
41942
  }
41472
41943
  };
41473
41944
  };
41945
+ const atomicWriteJson = (filePath, value) => {
41946
+ try {
41947
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
41948
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
41949
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
41950
+ NFS.renameSync(temporaryPath, filePath);
41951
+ } catch {
41952
+ return;
41953
+ }
41954
+ };
41955
+ const failOpenReadJson = (filePath, fallback) => {
41956
+ try {
41957
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
41958
+ } catch {
41959
+ return fallback;
41960
+ }
41961
+ };
41962
+ const validateDiagnostic = decodeUnknownSync(Diagnostic);
41963
+ const decodeFileDiagnostics = (raw) => {
41964
+ if (!Array.isArray(raw)) return null;
41965
+ try {
41966
+ for (const entry of raw) validateDiagnostic(entry);
41967
+ return raw;
41968
+ } catch {
41969
+ return null;
41970
+ }
41971
+ };
41972
+ const emptyCache = () => ({
41973
+ version: 1,
41974
+ rulesets: {}
41975
+ });
41976
+ const loadRulesetEntries = (cacheFilePath, rulesetHash) => {
41977
+ const entries = /* @__PURE__ */ new Map();
41978
+ const persisted = failOpenReadJson(cacheFilePath, emptyCache());
41979
+ if (persisted.version !== 1 || !isRecord$2(persisted.rulesets)) return entries;
41980
+ const bucket = persisted.rulesets[rulesetHash];
41981
+ if (!isRecord$2(bucket) || !isRecord$2(bucket.files)) return entries;
41982
+ for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
41983
+ const decoded = decodeFileDiagnostics(rawDiagnostics);
41984
+ if (decoded !== null) entries.set(fileKey, decoded);
41985
+ }
41986
+ return entries;
41987
+ };
41988
+ const createFileLintCache = (cacheDirectory, rulesetHash) => {
41989
+ const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
41990
+ const entries = loadRulesetEntries(cacheFilePath, rulesetHash);
41991
+ return {
41992
+ lookup: (fileKey) => entries.get(fileKey) ?? null,
41993
+ store: (fileKey, diagnostics) => {
41994
+ entries.delete(fileKey);
41995
+ entries.set(fileKey, diagnostics);
41996
+ },
41997
+ persist: () => {
41998
+ const onDisk = failOpenReadJson(cacheFilePath, emptyCache());
41999
+ const rulesets = onDisk.version === 1 && isRecord$2(onDisk.rulesets) ? { ...onDisk.rulesets } : {};
42000
+ const existingBucket = rulesets[rulesetHash];
42001
+ const existingFiles = isRecord$2(existingBucket) && isRecord$2(existingBucket.files) ? existingBucket.files : {};
42002
+ const ourFiles = {};
42003
+ for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
42004
+ const cappedEntries = Object.entries({
42005
+ ...existingFiles,
42006
+ ...ourFiles
42007
+ }).slice(-FILE_LINT_CACHE_MAX_FILE_COUNT);
42008
+ rulesets[rulesetHash] = {
42009
+ updatedAtMs: Date.now(),
42010
+ files: Object.fromEntries(cappedEntries)
42011
+ };
42012
+ const keptHashes = Object.entries(rulesets).sort(([, first], [, second]) => second.updatedAtMs - first.updatedAtMs).slice(0, 8).map(([hash]) => hash);
42013
+ const prunedRulesets = {};
42014
+ for (const hash of keptHashes) prunedRulesets[hash] = rulesets[hash];
42015
+ atomicWriteJson(cacheFilePath, {
42016
+ version: 1,
42017
+ rulesets: prunedRulesets
42018
+ });
42019
+ }
42020
+ };
42021
+ };
42022
+ const bundledRequire$2 = createRequire(import.meta.url);
42023
+ const TOOLCHAIN_PACKAGE_SPECIFIERS$1 = [
42024
+ "oxlint/package.json",
42025
+ "oxlint-plugin-react-doctor/package.json",
42026
+ "eslint-plugin-react-hooks/package.json"
42027
+ ];
42028
+ const resolveOxlintToolchainVersions = () => {
42029
+ const versions = [`node=${process.version}`];
42030
+ for (const specifier of TOOLCHAIN_PACKAGE_SPECIFIERS$1) try {
42031
+ const packageJson = bundledRequire$2(specifier);
42032
+ const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
42033
+ versions.push(`${specifier}=${version}`);
42034
+ } catch {
42035
+ versions.push(`${specifier}=missing`);
42036
+ }
42037
+ return versions;
42038
+ };
41474
42039
  const esmRequire = createRequire(import.meta.url);
41475
42040
  const resolveOxlintBinary = () => {
41476
42041
  const oxlintMainPath = esmRequire.resolve("oxlint");
@@ -42152,15 +42717,19 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
42152
42717
  };
42153
42718
  });
42154
42719
  };
42155
- const SANITIZED_ENV = (() => {
42156
- const sanitized = {};
42157
- for (const [name, value] of Object.entries(process.env)) {
42720
+ const buildOxlintChildEnv = (sourceEnv) => {
42721
+ const childEnv = {};
42722
+ for (const [name, value] of Object.entries(sourceEnv)) {
42158
42723
  if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
42159
42724
  if (name.startsWith("npm_config_")) continue;
42160
- sanitized[name] = value;
42725
+ childEnv[name] = value;
42161
42726
  }
42162
- return sanitized;
42163
- })();
42727
+ const isCompileCacheDisabled = Boolean(sourceEnv.NODE_DISABLE_COMPILE_CACHE);
42728
+ const isCompileCacheAlreadySet = childEnv.NODE_COMPILE_CACHE !== void 0;
42729
+ if (!isCompileCacheDisabled && !isCompileCacheAlreadySet) childEnv.NODE_COMPILE_CACHE = Path.join(os.tmpdir(), NODE_COMPILE_CACHE_DIR_NAME);
42730
+ return childEnv;
42731
+ };
42732
+ const SANITIZED_ENV = buildOxlintChildEnv(process.env);
42164
42733
  /**
42165
42734
  * Spawn one oxlint subprocess with hard ceilings on wall time and
42166
42735
  * output size. Returns stdout on success; raises a tagged
@@ -42177,7 +42746,11 @@ const SANITIZED_ENV = (() => {
42177
42746
  * The first three are splittable (the caller's binary-split retry
42178
42747
  * shrinks the batch and re-spawns); the fourth isn't.
42179
42748
  */
42180
- const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES) => new Promise((resolve, reject) => {
42749
+ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES, abortSignal) => new Promise((resolve, reject) => {
42750
+ if (abortSignal?.aborted) {
42751
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
42752
+ return;
42753
+ }
42181
42754
  const child = spawn(nodeBinaryPath, args, {
42182
42755
  cwd: rootDirectory,
42183
42756
  env: SANITIZED_ENV,
@@ -42187,7 +42760,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42187
42760
  "pipe"
42188
42761
  ]
42189
42762
  });
42763
+ const onAbort = () => {
42764
+ child.kill("SIGKILL");
42765
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
42766
+ };
42767
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
42768
+ const clearAbortListener = () => abortSignal?.removeEventListener("abort", onAbort);
42190
42769
  const timeoutHandle = setTimeout(() => {
42770
+ clearAbortListener();
42191
42771
  child.kill("SIGKILL");
42192
42772
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42193
42773
  kind: "timeout",
@@ -42222,10 +42802,12 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42222
42802
  });
42223
42803
  child.on("error", (error) => {
42224
42804
  clearTimeout(timeoutHandle);
42805
+ clearAbortListener();
42225
42806
  reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: error }) }));
42226
42807
  });
42227
42808
  child.on("close", (_code, signal) => {
42228
42809
  clearTimeout(timeoutHandle);
42810
+ clearAbortListener();
42229
42811
  if (didKillForSize) {
42230
42812
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42231
42813
  kind: "output-too-large",
@@ -42292,26 +42874,28 @@ const isParallelismRelatedSpawnError = (error) => {
42292
42874
  * loop with a slimmer config in that case.
42293
42875
  */
42294
42876
  const spawnLintBatches = async (input) => {
42295
- const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
42877
+ const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes, splitTotalBudgetMs = OXLINT_SPLIT_TOTAL_BUDGET_MS, splitMaxDepth = 8, signal } = input;
42296
42878
  const requestedConcurrency = resolveScanConcurrency(input.concurrency ?? 1);
42297
42879
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
42298
42880
  const runBatchPass = async (concurrency) => {
42299
42881
  const allDiagnostics = [];
42300
42882
  const droppedFiles = [];
42301
42883
  let firstDropReason = null;
42302
- const spawnLintBatch = async (batch) => {
42884
+ const splitDeadlineMs = Date.now() + splitTotalBudgetMs;
42885
+ const spawnLintBatch = async (batch, depth) => {
42303
42886
  const batchArgs = [...baseArgs, ...batch];
42304
42887
  try {
42305
- return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes), project, rootDirectory);
42888
+ return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes, signal), project, rootDirectory);
42306
42889
  } catch (error) {
42307
42890
  if (!isSplittableReactDoctorError(error)) throw error;
42308
- if (batch.length <= 1) {
42891
+ const splitBudgetExhausted = Date.now() >= splitDeadlineMs || depth >= splitMaxDepth;
42892
+ if (batch.length <= 1 || splitBudgetExhausted) {
42309
42893
  droppedFiles.push(...batch);
42310
- if (firstDropReason === null) firstDropReason = error.message;
42894
+ if (firstDropReason === null) firstDropReason = splitBudgetExhausted && batch.length > 1 ? `${error.message} (split budget exhausted after ${splitMaxDepth} levels / ${splitTotalBudgetMs / MILLISECONDS_PER_SECOND}s)` : error.message;
42311
42895
  return [];
42312
42896
  }
42313
42897
  const splitIndex = Math.ceil(batch.length / 2);
42314
- return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
42898
+ return [...await spawnLintBatch(batch.slice(0, splitIndex), depth + 1), ...await spawnLintBatch(batch.slice(splitIndex), depth + 1)];
42315
42899
  }
42316
42900
  };
42317
42901
  let startedFileCount = 0;
@@ -42328,7 +42912,7 @@ const spawnLintBatches = async (input) => {
42328
42912
  try {
42329
42913
  const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
42330
42914
  startedFileCount += batch.length;
42331
- const batchDiagnostics = await spawnLintBatch(batch);
42915
+ const batchDiagnostics = await spawnLintBatch(batch, 0);
42332
42916
  scannedFileCount += batch.length;
42333
42917
  if (onFileProgress) {
42334
42918
  displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
@@ -42389,6 +42973,22 @@ const validateRuleRegistration = () => {
42389
42973
  ].filter((entry) => entry !== null).join("; ");
42390
42974
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
42391
42975
  };
42976
+ const hashFileContents = (filePath) => {
42977
+ try {
42978
+ return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
42979
+ } catch {
42980
+ return null;
42981
+ }
42982
+ };
42983
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
42984
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
42985
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
42986
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
42987
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
42988
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
42989
+ return Path.join(os.tmpdir(), "react-doctor-cache", projectCacheSubdir(projectDirectory));
42990
+ };
42991
+ const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
42392
42992
  /**
42393
42993
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
42394
42994
  * the runner: once for the primary scan, once for the
@@ -42447,7 +43047,7 @@ const reactHooksJsPluginDropNote = (error) => {
42447
43047
  * 6. always restore disable directives + clean up the temp dir
42448
43048
  */
42449
43049
  const runOxlint = async (options) => {
42450
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, spawnTimeoutMs, outputMaxBytes } = options;
43050
+ 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;
42451
43051
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
42452
43052
  const severityControls = buildRuleSeverityControls(userConfig);
42453
43053
  validateRuleRegistration();
@@ -42464,30 +43064,156 @@ const runOxlint = async (options) => {
42464
43064
  serverAuthFunctionNames,
42465
43065
  severityControls,
42466
43066
  userPlugins,
42467
- disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
43067
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
43068
+ ruleSelection: overrides.ruleSelection
42468
43069
  });
42469
43070
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
42470
43071
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
42471
43072
  const configPath = Path.join(configDirectory, "oxlintrc.json");
42472
43073
  try {
42473
- const baseArgs = [
42474
- resolveOxlintBinary(),
42475
- "-c",
42476
- configPath,
42477
- "--format",
42478
- "json"
42479
- ];
43074
+ const oxlintBinary = resolveOxlintBinary();
43075
+ const sharedArgs = [];
43076
+ let tsconfigContent = null;
42480
43077
  if (project.hasTypeScript) {
42481
43078
  const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
42482
- if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
43079
+ if (tsconfigRelativePath) {
43080
+ sharedArgs.push("--tsconfig", tsconfigRelativePath);
43081
+ try {
43082
+ tsconfigContent = NFS.readFileSync(Path.resolve(rootDirectory, tsconfigRelativePath), "utf8");
43083
+ } catch {
43084
+ tsconfigContent = null;
43085
+ }
43086
+ }
42483
43087
  }
42484
43088
  const combinedPatterns = collectIgnorePatterns(rootDirectory);
42485
43089
  if (combinedPatterns.length > 0) {
42486
43090
  const combinedIgnorePath = Path.join(configDirectory, "combined.ignore");
42487
43091
  NFS.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
42488
- baseArgs.push("--ignore-path", combinedIgnorePath);
43092
+ sharedArgs.push("--ignore-path", combinedIgnorePath);
42489
43093
  }
42490
- const fileBatches = batchIncludePaths(baseArgs, includePaths !== void 0 ? includePaths : listSourceFiles(rootDirectory));
43094
+ const makeBaseArgs = (oxlintConfigPath) => [
43095
+ oxlintBinary,
43096
+ "-c",
43097
+ oxlintConfigPath,
43098
+ "--format",
43099
+ "json",
43100
+ ...sharedArgs
43101
+ ];
43102
+ const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
43103
+ const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
43104
+ const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
43105
+ if (files.length === 0) return {
43106
+ diagnostics: [],
43107
+ didDropReactHooksJsPlugin: false,
43108
+ hadPartialFailure: false
43109
+ };
43110
+ let hadPartialFailure = false;
43111
+ const reportPartialFailure = (reason) => {
43112
+ hadPartialFailure = true;
43113
+ onPartialFailure?.(reason);
43114
+ };
43115
+ const passConfigPath = Path.join(configDirectory, configFileName);
43116
+ const passBaseArgs = makeBaseArgs(passConfigPath);
43117
+ const passFileBatches = batchIncludePaths(passBaseArgs, files);
43118
+ const spawnPass = () => spawnLintBatches({
43119
+ baseArgs: passBaseArgs,
43120
+ fileBatches: passFileBatches,
43121
+ rootDirectory,
43122
+ nodeBinaryPath,
43123
+ project,
43124
+ onPartialFailure: reportPartialFailure,
43125
+ onFileProgress: fileProgress,
43126
+ spawnTimeoutMs,
43127
+ outputMaxBytes,
43128
+ concurrency: options.concurrency,
43129
+ signal: options.signal
43130
+ });
43131
+ writeOxlintConfig(passConfigPath, buildConfigForPass({}));
43132
+ try {
43133
+ return {
43134
+ diagnostics: await spawnPass(),
43135
+ didDropReactHooksJsPlugin: false,
43136
+ hadPartialFailure
43137
+ };
43138
+ } catch (error) {
43139
+ const reactHooksJsDropNote = reactHooksJsPluginDropNote(error);
43140
+ if (reactHooksJsDropNote === null) throw error;
43141
+ writeOxlintConfig(passConfigPath, buildConfigForPass({ disableReactHooksJsPlugin: true }));
43142
+ const diagnostics = await spawnPass();
43143
+ reportPartialFailure(reactHooksJsDropNote);
43144
+ return {
43145
+ diagnostics,
43146
+ didDropReactHooksJsPlugin: true,
43147
+ hadPartialFailure
43148
+ };
43149
+ }
43150
+ };
43151
+ if (perFileLintCacheEnabled && respectInlineDisables && !project.hasReactCompiler && extendsPaths.length === 0 && userPlugins.length === 0) {
43152
+ const rulesetHash = computeRulesetHash({
43153
+ config: buildConfig({
43154
+ extendsPaths: [],
43155
+ ruleSelection: "cacheable"
43156
+ }),
43157
+ toolchainVersions: resolveOxlintToolchainVersions(),
43158
+ ignorePatterns: combinedPatterns,
43159
+ tsconfigContent
43160
+ });
43161
+ const cache = createFileLintCache(resolveReactDoctorCacheDir(rootDirectory), rulesetHash);
43162
+ const cacheKeyByFile = /* @__PURE__ */ new Map();
43163
+ const missFiles = [];
43164
+ const replayedDiagnostics = [];
43165
+ for (const candidateFile of candidateFiles) {
43166
+ const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
43167
+ if (contentHash === null) {
43168
+ missFiles.push(candidateFile);
43169
+ continue;
43170
+ }
43171
+ const cacheKey = `${candidateFile.replaceAll("\\", "/")}${contentHash}`;
43172
+ cacheKeyByFile.set(candidateFile, cacheKey);
43173
+ const cachedDiagnostics = cache.lookup(cacheKey);
43174
+ if (cachedDiagnostics === null) missFiles.push(candidateFile);
43175
+ else replayedDiagnostics.push(...cachedDiagnostics);
43176
+ }
43177
+ const cacheHitFileCount = candidateFiles.length - missFiles.length;
43178
+ const cacheableResult = await runConfigOverFiles((overrides) => buildConfig({
43179
+ extendsPaths: [],
43180
+ ruleSelection: "cacheable",
43181
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
43182
+ }), "oxlintrc.cacheable.json", missFiles, void 0);
43183
+ const sidecarResult = await runConfigOverFiles(() => buildConfig({
43184
+ extendsPaths: [],
43185
+ ruleSelection: "sidecar"
43186
+ }), "oxlintrc.sidecar.json", candidateFiles, options.onFileProgress);
43187
+ onCacheStats?.(cacheHitFileCount, candidateFiles.length);
43188
+ const missFileByNormalizedPath = /* @__PURE__ */ new Map();
43189
+ for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
43190
+ const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
43191
+ let isAttributionSound = true;
43192
+ for (const diagnostic of cacheableResult.diagnostics) {
43193
+ const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
43194
+ if (missFile === void 0) {
43195
+ isAttributionSound = false;
43196
+ break;
43197
+ }
43198
+ const fileDiagnostics = freshDiagnosticsByFile.get(missFile) ?? [];
43199
+ fileDiagnostics.push(diagnostic);
43200
+ freshDiagnosticsByFile.set(missFile, fileDiagnostics);
43201
+ }
43202
+ if (!cacheableResult.didDropReactHooksJsPlugin && !cacheableResult.hadPartialFailure && isAttributionSound) {
43203
+ for (const missFile of missFiles) {
43204
+ const cacheKey = cacheKeyByFile.get(missFile);
43205
+ if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
43206
+ }
43207
+ cache.persist();
43208
+ }
43209
+ return dedupeDiagnostics([
43210
+ ...replayedDiagnostics,
43211
+ ...cacheableResult.diagnostics,
43212
+ ...sidecarResult.diagnostics
43213
+ ]);
43214
+ }
43215
+ const baseArgs = makeBaseArgs(configPath);
43216
+ const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
42491
43217
  const runBatches = () => spawnLintBatches({
42492
43218
  baseArgs,
42493
43219
  fileBatches,
@@ -42498,7 +43224,8 @@ const runOxlint = async (options) => {
42498
43224
  onFileProgress: options.onFileProgress,
42499
43225
  spawnTimeoutMs,
42500
43226
  outputMaxBytes,
42501
- concurrency: options.concurrency
43227
+ concurrency: options.concurrency,
43228
+ signal: options.signal
42502
43229
  });
42503
43230
  writeOxlintConfig(configPath, buildConfig({ extendsPaths }));
42504
43231
  try {
@@ -42577,9 +43304,11 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
42577
43304
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
42578
43305
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
42579
43306
  const concurrency = yield* OxlintConcurrency;
43307
+ const lintBatchOrdering = yield* LintBatchOrdering;
43308
+ const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
42580
43309
  const collectedFailures = [];
42581
43310
  const diagnostics = yield* tryPromise({
42582
- try: () => runOxlint({
43311
+ try: (signal) => runOxlint({
42583
43312
  rootDirectory: input.rootDirectory,
42584
43313
  project: input.project,
42585
43314
  includePaths: input.includePaths ? [...input.includePaths] : void 0,
@@ -42594,9 +43323,13 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
42594
43323
  collectedFailures.push(reason);
42595
43324
  },
42596
43325
  onFileProgress: input.onFileProgress,
43326
+ perFileLintCacheEnabled,
43327
+ onCacheStats: input.onCacheStats,
42597
43328
  spawnTimeoutMs,
42598
43329
  outputMaxBytes,
42599
- concurrency
43330
+ concurrency,
43331
+ signal,
43332
+ lintBatchOrdering
42600
43333
  }),
42601
43334
  catch: ensureReactDoctorError
42602
43335
  });
@@ -42988,14 +43721,49 @@ const parseArtifactFromBody = (body) => {
42988
43721
  }
42989
43722
  return null;
42990
43723
  };
42991
- const fetchSocketArtifact = (dependency) => tryPromise(async (signal) => {
43724
+ const isSupplyChainCacheDisabled = () => {
43725
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
43726
+ return noCache === "1" || noCache === "true";
43727
+ };
43728
+ const supplyChainCacheFile = (cacheDirectory, dependency) => {
43729
+ const purlHash = crypto.createHash("sha256").update(toPurl(dependency)).digest("hex").slice(0, 16);
43730
+ return Path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR, `${purlHash}.json`);
43731
+ };
43732
+ const readCachedSocketBody = (cacheFile) => {
43733
+ try {
43734
+ const entry = JSON.parse(NFS.readFileSync(cacheFile, "utf-8"));
43735
+ 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;
43736
+ } catch {}
43737
+ return null;
43738
+ };
43739
+ const writeCachedSocketBody = (cacheFile, body) => {
43740
+ try {
43741
+ NFS.mkdirSync(Path.dirname(cacheFile), { recursive: true });
43742
+ NFS.writeFileSync(cacheFile, JSON.stringify({
43743
+ fetchedAtMs: Date.now(),
43744
+ body
43745
+ }));
43746
+ } catch {}
43747
+ };
43748
+ const fetchSocketArtifact = (dependency, cacheDirectory) => tryPromise(async (signal) => {
43749
+ const cacheFile = cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency);
43750
+ if (cacheFile !== null) {
43751
+ const cachedBody = readCachedSocketBody(cacheFile);
43752
+ if (cachedBody !== null) {
43753
+ const cachedArtifact = parseArtifactFromBody(cachedBody);
43754
+ if (cachedArtifact !== null) return cachedArtifact;
43755
+ }
43756
+ }
42992
43757
  const requestUrl = `${SOCKET_FREE_PURL_API_BASE}/${encodeURIComponent(toPurl(dependency))}`;
42993
43758
  const response = await fetch(requestUrl, {
42994
43759
  headers: { "User-Agent": SOCKET_FREE_USER_AGENT },
42995
43760
  signal
42996
43761
  });
42997
43762
  if (!response.ok) return null;
42998
- return parseArtifactFromBody(await response.text());
43763
+ const body = await response.text();
43764
+ const artifact = parseArtifactFromBody(body);
43765
+ if (artifact !== null && cacheFile !== null) writeCachedSocketBody(cacheFile, body);
43766
+ return artifact;
42999
43767
  }).pipe(timeout(FETCH_TIMEOUT_MS), orElseSucceed(() => null), tap$1((artifact) => {
43000
43768
  const scoreAttributes = {};
43001
43769
  if (artifact !== null) {
@@ -43100,7 +43868,8 @@ const checkSupplyChain = (input) => gen(function* () {
43100
43868
  const packageJsonPath = Path.join(input.rootDirectory, "package.json");
43101
43869
  const dependencies = collectDependenciesToScore(readPackageJson$1(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
43102
43870
  if (dependencies.length === 0) return [];
43103
- const artifacts = yield* forEach$1(dependencies, fetchSocketArtifact, { concurrency: 8 });
43871
+ const cacheDirectory = isSupplyChainCacheDisabled() ? null : resolveReactDoctorCacheDir(input.rootDirectory);
43872
+ const artifacts = yield* forEach$1(dependencies, (dependency) => fetchSocketArtifact(dependency, cacheDirectory), { concurrency: 8 }).pipe(timeoutOption(input.totalTimeoutMs ?? 9e4), map$3((maybeArtifacts) => getOrElse$1(maybeArtifacts, () => [])));
43104
43873
  const diagnostics = [];
43105
43874
  for (let index = 0; index < dependencies.length; index += 1) {
43106
43875
  const artifact = artifacts[index];
@@ -43125,6 +43894,10 @@ const checkSupplyChain = (input) => gen(function* () {
43125
43894
  * The underlying `checkSupplyChain` Effect is total/fail-open — per-package
43126
43895
  * timeouts and network failures recover to "skip" — so the stream never
43127
43896
  * fails, mirroring `DeadCode`'s stream shape so the two compose the same way.
43897
+ * The orchestrator (`run-inspect.ts`) consumes this stream on a background
43898
+ * fiber whose network time overlaps the lint pass, joined under a generous
43899
+ * wall-clock budget; a budget expiry is the same fail-open outcome as a Socket
43900
+ * outage.
43128
43901
  */
43129
43902
  var SupplyChain = class SupplyChain extends Service()("react-doctor/SupplyChain") {
43130
43903
  static layerNode = succeed$3(SupplyChain, SupplyChain.of({ run: (input) => unwrap(checkSupplyChain(input).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)), withSpan("SupplyChain.run"))) }));
@@ -43183,18 +43956,45 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
43183
43956
  *
43184
43957
  * Phases:
43185
43958
  *
43186
- * 1. Config.resolve(directory) → Project.discover → Git metadata
43959
+ * 1. Config.resolve(directory) → Project.discover → Git metadata.
43960
+ * The GitHub viewer-permission lookup is forked onto a background
43961
+ * fiber here and joined late (it feeds score metadata, not
43962
+ * diagnostics).
43187
43963
  * 2. beforeLint hook (e.g. CLI renders the project-detection block)
43188
43964
  * 3. environment checks (reduced-motion + pnpm hardening +
43189
- * expo/react-native + security scan)
43190
- * 4. Linter.run + DeadCode.run forked as concurrent fibers so
43191
- * their wall-clock times overlap. Progress spinners stay
43192
- * sequential (lint first, then dead-code) for clean terminal
43193
- * output. GitHub viewer permission also runs as a background
43194
- * fiber during this phase.
43195
- * 5. afterLint hook
43196
- * 6. Reporter.finalize
43197
- * 7. Score.compute against the surface-filtered diagnostic set
43965
+ * expo/react-native), collected synchronously. The heavier
43966
+ * content-regex security scan is forked instead (like supply-chain
43967
+ * below) and joined before the concat, so its CPU overlaps lint
43968
+ * rather than blocking the event loop before it.
43969
+ * 4. The supply-chain check (Socket.dev) is forked onto a background
43970
+ * fiber so its ~100% network-bound time overlaps the ~100%
43971
+ * CPU/subprocess-bound lint pass below, collapsing two serial
43972
+ * phases into roughly `max(supplyChain, lint)`. It is capped by
43973
+ * `SupplyChainOverlapTimeoutMs` (measured from fork) so a hung
43974
+ * socket can't drag out its join; on timeout it fails open to no
43975
+ * diagnostics — the same outcome class as a Socket outage.
43976
+ * 5. Linter.run runs; DeadCode.run runs concurrently (forked child
43977
+ * fiber) ONLY when the memory gate has headroom to run the 8 GB
43978
+ * dead-code child alongside the oxlint workers — or when overlap is
43979
+ * forced via REACT_DOCTOR_DEAD_CODE_OVERLAP. Otherwise dead-code
43980
+ * runs sequentially after lint, exactly as it did pre-overlap. The
43981
+ * fiber is joined (or interrupted, SIGKILLing its worker, on lint
43982
+ * failure) before diagnostics are concatenated. The afterLint hook
43983
+ * fires between lint and dead-code. Progress spinner labels AND the
43984
+ * final diagnostic / score order stay independent of execution
43985
+ * order, so terminal output is identical either way; supply-chain
43986
+ * rides alongside without a spinner.
43987
+ * 6. Join the supply-chain fiber, then assemble the diagnostics in a
43988
+ * FIXED order (env, security-scan, supply-chain, lint, dead-code) so the output is
43989
+ * byte-identical regardless of which fiber settled first. The
43990
+ * viewer-permission fiber is joined later, during score-metadata
43991
+ * assembly (it feeds score metadata, not diagnostics). The per-element
43992
+ * `Reporter.emit` side-channel now interleaves supply-chain with lint
43993
+ * emits, so capture-order assertions must target the deterministic
43994
+ * concat below, not emit order (production `Reporter.layerNoop` makes
43995
+ * emit a no-op).
43996
+ * 7. Reporter.finalize
43997
+ * 8. Score.compute against the surface-filtered diagnostic set
43198
43998
  *
43199
43999
  * The orchestrator owns spinner lifecycle via `Progress`; callers
43200
44000
  * choose `Progress.layerOra(...)` for CLI feedback or
@@ -43246,16 +44046,27 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43246
44046
  ...checkPnpmHardening(scanDirectory),
43247
44047
  ...checkReactServerComponentsAdvisory(scanDirectory, project),
43248
44048
  ...checkExpoProject(scanDirectory, project),
43249
- ...checkReactNativeProject(scanDirectory, project),
43250
- ...checkSecurityScan(scanDirectory, {
43251
- project,
43252
- ignoredTags: input.ignoredTags
43253
- })
44049
+ ...checkReactNativeProject(scanDirectory, project)
43254
44050
  ])));
43255
- const supplyChainCollected = !isDiffMode || (input.supplyChainManifestChanged ?? false) ? yield* runCollect(applyPerElementPipeline(supplyChainService.run({
44051
+ const securityScanFiber = yield* forkChild(runCollect(applyPerElementPipeline(isDiffMode ? empty$4 : unwrap(promise(() => checkSecurityScanCooperative(scanDirectory, {
44052
+ project,
44053
+ ignoredTags: input.ignoredTags
44054
+ })).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)))))).pipe(withSpan("SecurityScan.run")));
44055
+ const shouldRunSupplyChain = !isDiffMode || (input.supplyChainManifestChanged ?? false);
44056
+ const supplyChainOverlapTimeout = yield* SupplyChainOverlapTimeoutMs;
44057
+ const supplyChainFiber = yield* forkChild(shouldRunSupplyChain ? runCollect(applyPerElementPipeline(supplyChainService.run({
43256
44058
  rootDirectory: scanDirectory,
43257
44059
  userConfig: resolvedConfig.config
43258
- }))) : [];
44060
+ }))).pipe(map$3((diagnostics) => ({
44061
+ diagnostics,
44062
+ timedOut: false
44063
+ })), timeout(supplyChainOverlapTimeout), orElseSucceed(() => ({
44064
+ diagnostics: [],
44065
+ timedOut: true
44066
+ }))) : succeed$2({
44067
+ diagnostics: [],
44068
+ timedOut: false
44069
+ }));
43259
44070
  const lintFailure = yield* make$13({
43260
44071
  didFail: false,
43261
44072
  reason: null,
@@ -43266,12 +44077,49 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43266
44077
  didFail: false,
43267
44078
  reason: null
43268
44079
  });
43269
- const scanConcurrency = yield* OxlintConcurrency;
44080
+ const scanConcurrency = resolveScanConcurrency(yield* OxlintConcurrency);
44081
+ const lintPhaseTimeoutMs = yield* LintPhaseTimeoutMs;
44082
+ const deadCodePhaseTimeoutMs = yield* DeadCodePhaseTimeoutMs;
44083
+ const resolveDeadCodePhaseTimeoutMs = (scaledPhaseTimeoutMs) => deadCodePhaseTimeoutMs === 15e4 ? scaledPhaseTimeoutMs : deadCodePhaseTimeoutMs;
43270
44084
  const workerCountSuffix = scanConcurrency > 1 ? ` ${highlighter.dim(`[~${scanConcurrency} workers]`)}` : "";
44085
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
44086
+ const deadCodeOverlapMode = yield* DeadCodeOverlap;
44087
+ const shouldOverlapDeadCode = shouldRunDeadCode && deadCodeOverlapMode === "on";
44088
+ const deadCodeParseConcurrency = shouldOverlapDeadCode ? Math.max(1, Math.floor(scanConcurrency * DEAD_CODE_OVERLAP_PARSE_SHARE)) : void 0;
44089
+ const lintConcurrency = deadCodeParseConcurrency === void 0 ? scanConcurrency : Math.max(1, scanConcurrency - deadCodeParseConcurrency);
44090
+ const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
44091
+ rootDirectory: scanDirectory,
44092
+ userConfig: resolvedConfig.config,
44093
+ parseConcurrency: deadCodeParseConcurrency,
44094
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
44095
+ }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
44096
+ yield* set(deadCodeFailure, {
44097
+ didFail: true,
44098
+ reason: error.message
44099
+ });
44100
+ return empty$4;
44101
+ })))))).pipe(timeoutOption(deadCodeTimeout.phaseTimeoutMs), flatMap$2(match$3({
44102
+ onNone: () => set(deadCodeFailure, {
44103
+ didFail: true,
44104
+ reason: `Dead-code analysis exceeded ${Math.round(deadCodeTimeout.phaseTimeoutMs / MILLISECONDS_PER_SECOND)}s and was skipped.`
44105
+ }).pipe(as([])),
44106
+ onSome: succeed$2
44107
+ })));
44108
+ const overlapDeadCodeTimeout = resolveDeadCodeTimeout({
44109
+ sourceFileCount: project.sourceFileCount,
44110
+ deadCodeConcurrency: deadCodeParseConcurrency ?? scanConcurrency,
44111
+ fullConcurrency: scanConcurrency
44112
+ });
44113
+ const deadCodeFiber = shouldOverlapDeadCode ? yield* forkChild(buildCollectDeadCode({
44114
+ workerTimeoutMs: overlapDeadCodeTimeout.workerTimeoutMs,
44115
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(overlapDeadCodeTimeout.phaseTimeoutMs)
44116
+ })) : null;
43271
44117
  const scanProgress = yield* progressService.start("Scanning...");
43272
44118
  const scanStartTime = Date.now();
43273
44119
  let lastReportedTotalFileCount = 0;
43274
- const lintCollected = yield* runCollect(applyPerElementPipeline(linterService.run({
44120
+ let lintCacheHitFileCount = null;
44121
+ let lintCacheTotalFileCount = null;
44122
+ const baseLintStream = linterService.run({
43275
44123
  rootDirectory: scanDirectory,
43276
44124
  project,
43277
44125
  includePaths: lintIncludePaths ?? void 0,
@@ -43285,6 +44133,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43285
44133
  onFileProgress: (scannedFileCount, totalFileCount) => {
43286
44134
  lastReportedTotalFileCount = totalFileCount;
43287
44135
  runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
44136
+ },
44137
+ onCacheStats: (cacheHitFileCount, totalConsideredFileCount) => {
44138
+ lintCacheHitFileCount = cacheHitFileCount;
44139
+ lintCacheTotalFileCount = totalConsideredFileCount;
43288
44140
  }
43289
44141
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43290
44142
  yield* set(lintFailure, {
@@ -43294,36 +44146,56 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43294
44146
  reasonKind: error.reason._tag === "OxlintUnavailable" ? error.reason.kind : null
43295
44147
  });
43296
44148
  return empty$4;
43297
- }))))));
44149
+ }))));
44150
+ const lintCollected = yield* runCollect(applyPerElementPipeline(shouldOverlapDeadCode ? baseLintStream.pipe(provideService(OxlintConcurrency, lintConcurrency)) : baseLintStream)).pipe(timeoutOption(lintPhaseTimeoutMs), flatMap$2(match$3({
44151
+ onNone: () => set(lintFailure, {
44152
+ didFail: true,
44153
+ reason: `Lint analysis exceeded ${lintPhaseTimeoutMs / MILLISECONDS_PER_SECOND}s and was skipped.`,
44154
+ reasonTag: "OxlintBatchExceeded",
44155
+ reasonKind: null
44156
+ }).pipe(as([])),
44157
+ onSome: succeed$2
44158
+ })));
43298
44159
  const lintFailureState = yield* get$2(lintFailure);
43299
44160
  yield* afterLint(lintFailureState.didFail);
43300
44161
  if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
43301
44162
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
43302
44163
  const scannedFilesLabel = `${totalFileCount} ${totalFileCount === 1 ? "file" : "files"}`;
43303
- const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
43304
- const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`).pipe(andThen(runCollect(applyPerElementPipeline(deadCodeService.run({
43305
- rootDirectory: scanDirectory,
43306
- userConfig: resolvedConfig.config
43307
- }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43308
- yield* set(deadCodeFailure, {
43309
- didFail: true,
43310
- reason: error.message
44164
+ let deadCodeCollected = [];
44165
+ if (lintFailureState.didFail) {
44166
+ if (deadCodeFiber !== null) yield* interrupt(deadCodeFiber);
44167
+ } else if (shouldRunDeadCode) {
44168
+ yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`);
44169
+ const sequentialDeadCodeTimeout = resolveDeadCodeTimeout({
44170
+ sourceFileCount: totalFileCount,
44171
+ deadCodeConcurrency: scanConcurrency,
44172
+ fullConcurrency: scanConcurrency
43311
44173
  });
43312
- return empty$4;
43313
- }))))))));
43314
- const deadCodeFailureState = yield* get$2(deadCodeFailure);
44174
+ deadCodeCollected = deadCodeFiber !== null ? yield* join(deadCodeFiber) : yield* buildCollectDeadCode({
44175
+ workerTimeoutMs: sequentialDeadCodeTimeout.workerTimeoutMs,
44176
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(sequentialDeadCodeTimeout.phaseTimeoutMs)
44177
+ });
44178
+ }
44179
+ const deadCodeFailureState = lintFailureState.didFail ? {
44180
+ didFail: false,
44181
+ reason: null
44182
+ } : yield* get$2(deadCodeFailure);
43315
44183
  const scanElapsedMilliseconds = Date.now() - scanStartTime;
43316
44184
  const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1);
43317
44185
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
43318
44186
  else if (input.suppressScanSummary) yield* scanProgress.stop();
43319
44187
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
44188
+ const supplyChainResult = yield* join(supplyChainFiber);
44189
+ const supplyChainCollected = supplyChainResult.diagnostics;
44190
+ const securityScanCollected = yield* join(securityScanFiber);
43320
44191
  yield* reporterService.finalize;
43321
- const finalDiagnostics = assignFixGroups([
44192
+ const finalDiagnostics = sortDiagnosticsStable(assignFixGroups([
43322
44193
  ...envCollected,
44194
+ ...securityScanCollected,
43323
44195
  ...supplyChainCollected,
43324
44196
  ...lintCollected,
43325
44197
  ...deadCodeCollected
43326
- ]);
44198
+ ]));
43327
44199
  const githubViewerPermission = yield* join(githubViewerPermissionFiber);
43328
44200
  const scoreMetadata = {
43329
44201
  ...repo !== null ? { repo } : {},
@@ -43359,9 +44231,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43359
44231
  lintPartialFailures,
43360
44232
  didDeadCodeFail: deadCodeFailureState.didFail,
43361
44233
  deadCodeFailureReason: deadCodeFailureState.reason,
44234
+ deadCodeOverlapped: shouldOverlapDeadCode,
43362
44235
  scannedFileCount: totalFileCount,
43363
44236
  scannedFilePaths,
43364
- scanElapsedMilliseconds
44237
+ scanElapsedMilliseconds,
44238
+ scanConcurrency,
44239
+ supplyChainOverlapTimedOut: supplyChainResult.timedOut,
44240
+ lintCacheHitFileCount,
44241
+ lintCacheTotalFileCount
43365
44242
  };
43366
44243
  }).pipe(withSpan("runInspect", { attributes: {
43367
44244
  "inspect.directory": input.directory,
@@ -43369,7 +44246,7 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43369
44246
  "inspect.runDeadCode": input.runDeadCode,
43370
44247
  "inspect.isCi": input.isCi,
43371
44248
  "inspect.scoreSurface": input.scoreSurface ?? "score"
43372
- } }));
44249
+ } }), (scanProgram) => flatMap$2(ScanDeadlineMs, (scanDeadlineMs) => scanProgram.pipe(timeout(scanDeadlineMs), catchTag$1("TimeoutError", () => new ReactDoctorError({ reason: new ScanDeadlineExceeded({ detail: `${scanDeadlineMs / MILLISECONDS_PER_SECOND}s elapsed` }) })))));
43373
44250
  const parseNodeVersion = (versionString) => {
43374
44251
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
43375
44252
  return {
@@ -44075,6 +44952,7 @@ const NANOSECONDS_PER_SECOND = 1000000000n;
44075
44952
  const METRIC = {
44076
44953
  cliInvoked: "cli.invoked",
44077
44954
  cliError: "cli.error",
44955
+ cliEnvironmentError: "cli.env_error",
44078
44956
  projectDetected: "project.detected",
44079
44957
  projectPathSelected: "project.path_selected",
44080
44958
  projectConfigSelected: "project.config_selected",
@@ -44147,7 +45025,7 @@ const makeNoopConsole = () => ({
44147
45025
  });
44148
45026
  //#endregion
44149
45027
  //#region src/cli/utils/version.ts
44150
- const VERSION = "0.5.7-dev.9f733f7";
45028
+ const VERSION = "0.5.8-dev.0c19858";
44151
45029
  //#endregion
44152
45030
  //#region src/cli/utils/json-mode.ts
44153
45031
  let context = null;
@@ -44300,7 +45178,8 @@ const buildRunContext = () => {
44300
45178
  terminalKind: detectTerminalKind(),
44301
45179
  jsonMode: isJsonModeActive(),
44302
45180
  debug: isDebugFlagEnabled(),
44303
- invokedVia: detectInvokedVia()
45181
+ invokedVia: detectInvokedVia(),
45182
+ lintBatchOrdering: resolveLintBatchOrdering()
44304
45183
  };
44305
45184
  };
44306
45185
  //#endregion
@@ -44373,7 +45252,8 @@ const buildSentryScope = (runContext = buildRunContext()) => {
44373
45252
  jsonMode: runContext.jsonMode,
44374
45253
  debug: runContext.debug,
44375
45254
  invokedVia: runContext.invokedVia,
44376
- nodeMajor: runContext.nodeMajor
45255
+ nodeMajor: runContext.nodeMajor,
45256
+ lintBatchOrdering: runContext.lintBatchOrdering
44377
45257
  };
44378
45258
  const contexts = { run: { ...runContext } };
44379
45259
  const projectInfo = getSentryProjectInfo();
@@ -44509,13 +45389,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
44509
45389
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
44510
45390
  * standard `SENTRY_RELEASE` override.
44511
45391
  */
44512
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.7-dev.9f733f7`;
45392
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.8-dev.0c19858`;
44513
45393
  /**
44514
45394
  * Deployment environment shown in Sentry's environment filter. Defaults to
44515
45395
  * `production` for tagged releases and `development` for dev/unbuilt versions,
44516
45396
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
44517
45397
  */
44518
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.7-dev.9f733f7") ? "development" : "production");
45398
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.8-dev.0c19858") ? "development" : "production");
44519
45399
  /**
44520
45400
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
44521
45401
  * (set to `0` to disable tracing) and falls back to
@@ -44718,7 +45598,7 @@ const externalSpanFrom = (sentrySpan) => {
44718
45598
  * in-memory tracer — identical to the prior default behavior.
44719
45599
  */
44720
45600
  const applyObservability = (program, rootSentrySpan) => {
44721
- if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(provideService(ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(provide(layerOtlp));
45601
+ if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(provideService$2(ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(provide(layerOtlp));
44722
45602
  if (rootSentrySpan) return program.pipe(withTracer(makeSentryTracer(rootSentrySpan)));
44723
45603
  return program.pipe(provide(layerOtlp));
44724
45604
  };
@@ -48111,6 +48991,25 @@ const shouldBlockCi = (diagnostics, blockingLevel) => {
48111
48991
  */
48112
48992
  const toCategoryKey = (category) => category.toLowerCase().replace(/[^a-z0-9]+/g, "_");
48113
48993
  //#endregion
48994
+ //#region src/cli/utils/with-namespace.ts
48995
+ /**
48996
+ * Prefixes every key in a flat attribute group with `namespace.`, so a logical
48997
+ * group (scan config, diagnostics rollup, score, …) carries one dotted
48998
+ * namespace applied in a single place rather than hand-spelled into each key —
48999
+ * which is how the wide event drifted into a flat, half-namespaced soup. The
49000
+ * dotted prefix is what makes the attributes tree up in Sentry's attribute
49001
+ * browser and stay group-/filter-/aggregate-able in the Spans dataset.
49002
+ *
49003
+ * Value types are preserved verbatim (numbers stay numbers so Sentry infers a
49004
+ * numeric attribute and `p75(...)`/`avg(...)` work; `null` is kept so
49005
+ * `toSpanAttributes` can drop absent signals rather than coerce them).
49006
+ */
49007
+ const withNamespace = (namespace, attributes) => {
49008
+ const namespaced = {};
49009
+ for (const [key, value] of Object.entries(attributes)) namespaced[`${namespace}.${key}`] = value;
49010
+ return namespaced;
49011
+ };
49012
+ //#endregion
48114
49013
  //#region src/cli/utils/build-run-event.ts
48115
49014
  const readEnvBoolean = (name) => {
48116
49015
  const value = process.env[name];
@@ -48132,12 +49031,12 @@ const buildOutcomeAttributes = (input) => {
48132
49031
  if (input.result === void 0) {
48133
49032
  const error = input.error;
48134
49033
  const known = isReactDoctorError(error);
48135
- return {
48136
- outcome: "error",
49034
+ return withNamespace("outcome", {
49035
+ status: "error",
48137
49036
  exitCode: 1,
48138
49037
  knownError: known,
48139
49038
  errorTag: known ? error.reason._tag : error instanceof Error ? error.name : null
48140
- };
49039
+ });
48141
49040
  }
48142
49041
  const result = input.result;
48143
49042
  const summary = summarizeDiagnostics(result.diagnostics);
@@ -48171,60 +49070,82 @@ const buildOutcomeAttributes = (input) => {
48171
49070
  }
48172
49071
  let fixGroupedFindings = 0;
48173
49072
  for (const count of findingsPerFixGroup.values()) fixGroupedFindings += count;
49073
+ const categoryRollup = {};
49074
+ for (const [category, count] of countByCategory) categoryRollup[`category.${toCategoryKey(category)}`] = count;
48174
49075
  const attributes = {
48175
- outcome,
48176
- exitCode: wouldBlock ? 1 : 0,
48177
- wouldBlock,
48178
- blocking: blockingLevel,
48179
- scanClean: isClean,
48180
- totalDiagnostics: summary.totalDiagnosticCount,
48181
- errorCount: summary.errorCount,
48182
- warningCount: summary.warningCount,
48183
- affectedFiles: summary.affectedFileCount,
48184
- diagnosticsInTestFiles,
48185
- diagnosticsInStoryFiles,
48186
- distinctRulesFired: countByRule.size,
48187
- "diag.fixGroups": findingsPerFixGroup.size,
48188
- "diag.fixGroupedFindings": fixGroupedFindings,
48189
- topRule,
48190
- "migration.largestRuleBucketFiles": largestRuleBucket ? largestRuleBucket.fileCount : null,
48191
- "migration.largestRuleBucketSites": largestRuleBucket ? largestRuleBucket.siteCount : null,
48192
- "migration.largestRuleBucketRule": largestRuleBucket ? largestRuleBucket.ruleKey : null,
48193
- scannedFileCount: result.scannedFileCount ?? null,
48194
- elapsedMs: result.elapsedMilliseconds,
48195
- scanPhaseMs: result.scanElapsedMilliseconds ?? null,
48196
- score: result.score ? result.score.score : null,
48197
- scoreLabel: result.score ? result.score.label : null,
48198
- scoreAvailable: result.score !== null,
48199
- skippedCheckCount: result.skippedChecks.length,
48200
- didLintFail: input.didLintFail ?? null,
48201
- lintFailureReasonKind: input.lintFailureReasonKind ?? null,
48202
- lintPartialFailureCount: input.lintPartialFailureCount ?? null,
48203
- didDeadCodeFail: input.didDeadCodeFail ?? null
48204
- };
48205
- for (const [category, count] of countByCategory) attributes[`diag.category.${toCategoryKey(category)}`] = count;
48206
- if (result.baselineDelta) {
48207
- attributes["baseline.new"] = summary.totalDiagnosticCount;
48208
- attributes["baseline.fixed"] = result.baselineDelta.fixedCount;
48209
- attributes["baseline.baseTotal"] = result.baselineDelta.baseTotalCount;
48210
- attributes["baseline.degraded"] = false;
48211
- } else if (input.gateExempt) attributes["baseline.degraded"] = true;
49076
+ ...withNamespace("outcome", {
49077
+ status: outcome,
49078
+ exitCode: wouldBlock ? 1 : 0,
49079
+ wouldBlock,
49080
+ blocking: blockingLevel,
49081
+ clean: isClean,
49082
+ skippedChecks: result.skippedChecks.length
49083
+ }),
49084
+ ...withNamespace("diag", {
49085
+ total: summary.totalDiagnosticCount,
49086
+ errors: summary.errorCount,
49087
+ warnings: summary.warningCount,
49088
+ affectedFiles: summary.affectedFileCount,
49089
+ inTestFiles: diagnosticsInTestFiles,
49090
+ inStoryFiles: diagnosticsInStoryFiles,
49091
+ distinctRules: countByRule.size,
49092
+ topRule,
49093
+ fixGroups: findingsPerFixGroup.size,
49094
+ fixGroupedFindings,
49095
+ ...categoryRollup
49096
+ }),
49097
+ ...withNamespace("score", {
49098
+ value: result.score ? result.score.score : null,
49099
+ label: result.score ? result.score.label : null,
49100
+ available: result.score !== null
49101
+ }),
49102
+ ...withNamespace("lint", {
49103
+ failed: input.didLintFail ?? null,
49104
+ failureReasonKind: input.lintFailureReasonKind ?? null,
49105
+ partialFailureCount: input.lintPartialFailureCount ?? null,
49106
+ droppedFileCount: input.lintDroppedFileCount ?? null,
49107
+ cacheHitFiles: result.lintCacheHitFileCount ?? null,
49108
+ cacheTotalFiles: result.lintCacheTotalFileCount ?? null,
49109
+ cacheHitRatio: result.lintCacheTotalFileCount != null && result.lintCacheTotalFileCount > 0 ? (result.lintCacheHitFileCount ?? 0) / result.lintCacheTotalFileCount : null
49110
+ }),
49111
+ ...withNamespace("deadCode", {
49112
+ failed: input.didDeadCodeFail ?? null,
49113
+ overlapped: input.deadCodeOverlapped ?? null
49114
+ }),
49115
+ ...withNamespace("supplyChain", { overlapTimedOut: input.supplyChainOverlapTimedOut ?? null }),
49116
+ ...withNamespace("timing", {
49117
+ elapsedMs: result.elapsedMilliseconds,
49118
+ scanMs: result.scanElapsedMilliseconds ?? null
49119
+ }),
49120
+ ...withNamespace("migration", {
49121
+ largestRuleBucketFiles: largestRuleBucket ? largestRuleBucket.fileCount : null,
49122
+ largestRuleBucketSites: largestRuleBucket ? largestRuleBucket.siteCount : null,
49123
+ largestRuleBucketRule: largestRuleBucket ? largestRuleBucket.ruleKey : null
49124
+ })
49125
+ };
49126
+ if (result.baselineDelta) Object.assign(attributes, withNamespace("baseline", {
49127
+ new: summary.totalDiagnosticCount,
49128
+ fixed: result.baselineDelta.fixedCount,
49129
+ baseTotal: result.baselineDelta.baseTotalCount,
49130
+ degraded: false
49131
+ }));
49132
+ else if (input.gateExempt) attributes["baseline.degraded"] = true;
48212
49133
  return attributes;
48213
49134
  };
48214
- const buildCiAttributes = () => {
49135
+ const buildActionAttributes = () => {
48215
49136
  const { githubActorAssociation } = resolveGithubActionsScoreMetadata();
48216
- return {
49137
+ return withNamespace("action", {
48217
49138
  actorAssociation: githubActorAssociation ?? null,
48218
49139
  runnerOs: detectRunnerOs(),
48219
49140
  comment: readEnvBoolean(ACTION_INPUT_ENVIRONMENT_VARIABLES.comment),
48220
49141
  reviewComments: readEnvBoolean(ACTION_INPUT_ENVIRONMENT_VARIABLES.reviewComments),
48221
49142
  versionPin: resolveVersionPin(process.env[ACTION_INPUT_ENVIRONMENT_VARIABLES.version])
48222
- };
49143
+ });
48223
49144
  };
48224
- const buildConfigAttributes = (input) => {
49145
+ const buildScanAttributes = (input) => {
48225
49146
  const ruleOverrides = input.userConfig?.rules ?? {};
48226
49147
  const ruleKeys = Object.keys(ruleOverrides);
48227
- return {
49148
+ return withNamespace("scan", {
48228
49149
  mode: input.mode,
48229
49150
  scope: input.scope,
48230
49151
  parallel: input.parallel,
@@ -48239,12 +49160,18 @@ const buildConfigAttributes = (input) => {
48239
49160
  ignoredTagCount: input.ignoredTagCount,
48240
49161
  hasCustomConfig: input.hasCustomConfig,
48241
49162
  rulesConfigured: ruleKeys.length,
48242
- rulesDisabled: ruleKeys.filter((key) => ruleOverrides[key] === "off").length
48243
- };
49163
+ rulesDisabled: ruleKeys.filter((key) => ruleOverrides[key] === "off").length,
49164
+ fileCount: input.result?.scannedFileCount ?? null
49165
+ });
48244
49166
  };
48245
49167
  /**
48246
- * Projects a scan into the flat attribute set for its root span — the canonical
48247
- * per-scan "wide event". Pure and exported so the projection (outcome
49168
+ * Projects a scan into the namespaced attribute set for its root span — the
49169
+ * canonical per-scan "wide event". Every attribute carries a dotted namespace
49170
+ * that groups it by concept (`scan.*` config, `action.*` CI knobs, `outcome.*`
49171
+ * verdict, `diag.*` findings, `score.*`, `lint.*`, `deadCode.*`,
49172
+ * `supplyChain.*`, `timing.*`, `migration.*`, `baseline.*`) so the attributes
49173
+ * tree up in Sentry's attribute browser and stay filter-/group-/aggregate-able
49174
+ * in the Spans dataset. Pure and exported so the projection (outcome
48248
49175
  * precedence, rule/category rollups, CI knobs, config shape) is unit-testable
48249
49176
  * without a live Sentry client. `null` values are dropped so absent signals
48250
49177
  * never become misleading `"null"` attributes. The run + project base context
@@ -48253,8 +49180,8 @@ const buildConfigAttributes = (input) => {
48253
49180
  * those don't carry.
48254
49181
  */
48255
49182
  const buildRunEventAttributes = (input) => toSpanAttributes({
48256
- ...buildConfigAttributes(input),
48257
- ...buildCiAttributes(),
49183
+ ...buildScanAttributes(input),
49184
+ ...buildActionAttributes(),
48258
49185
  ...buildOutcomeAttributes(input)
48259
49186
  });
48260
49187
  /**
@@ -48269,6 +49196,32 @@ const recordRunEvent = (rootSpan, input) => {
48269
49196
  } catch {}
48270
49197
  };
48271
49198
  //#endregion
49199
+ //#region src/cli/utils/resolve-worker-telemetry.ts
49200
+ /**
49201
+ * Projects the resolved lint worker count into the `(workerCount, parallel)`
49202
+ * telemetry pair. `resolvedWorkerCount` is the count the scan actually fanned
49203
+ * out to (`InspectOutput.scanConcurrency`); `pinnedConcurrency` is the caller's
49204
+ * `inspect({ concurrency })` pin, used as the fallback when no scan resolved a
49205
+ * count (the pre-scan failure path, or a cache entry persisted before the
49206
+ * resolved count was tracked). `parallel` is derived from the count — NOT from
49207
+ * whether a count was pinned — so the common auto path (no pin) still reports
49208
+ * parallelism correctly instead of always reading `false`.
49209
+ */
49210
+ const resolveWorkerTelemetry = (resolvedWorkerCount, pinnedConcurrency) => {
49211
+ const workerCount = resolvedWorkerCount ?? pinnedConcurrency;
49212
+ return {
49213
+ workerCount,
49214
+ parallel: workerCount !== void 0 && workerCount > 1
49215
+ };
49216
+ };
49217
+ //#endregion
49218
+ //#region src/cli/utils/count-dropped-lint-files.ts
49219
+ const DROPPED_FILES_MESSAGE_PATTERN = /^(\d+) file\(s\) failed to lint and were skipped/;
49220
+ const countDroppedLintFiles = (lintPartialFailures) => lintPartialFailures.reduce((total, message) => {
49221
+ const match = DROPPED_FILES_MESSAGE_PATTERN.exec(message);
49222
+ return match ? total + Number(match[1]) : total;
49223
+ }, 0);
49224
+ //#endregion
48272
49225
  //#region src/cli/utils/path-format.ts
48273
49226
  const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
48274
49227
  //#endregion
@@ -48885,25 +49838,192 @@ const canAnimateOnboarding = (stream = process.stdout) => {
48885
49838
  return !isGitHookEnvironment() && !isCiEnvironment();
48886
49839
  };
48887
49840
  //#endregion
48888
- //#region src/cli/utils/onboarding-state.ts
48889
- const ONBOARDED_AT_KEY = "onboardedAt";
48890
- const getOnboardingStore = (options = {}) => new Conf({
49841
+ //#region src/cli/utils/now-iso.ts
49842
+ const nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
49843
+ const ONBOARDING_EVENT = "onboarding";
49844
+ const CI_PITCH_EVENT = "ci-pitch";
49845
+ const ACTION_UPGRADE_EVENT = "action-upgrade-v2";
49846
+ const SETUP_HINT_EVENT = "setup-hint";
49847
+ const HANDOFF_TARGET_PREFERENCE_ID = "handoff-target";
49848
+ const INSTALL_AGENTS_PREFERENCE_ID = "install-agents";
49849
+ const foldLegacyDecisions = (projects, legacy, eventId) => {
49850
+ for (const [hash, record] of Object.entries(legacy ?? {})) {
49851
+ const existing = projects[hash] ?? { rootDirectory: record.rootDirectory ?? "" };
49852
+ projects[hash] = {
49853
+ ...existing,
49854
+ events: {
49855
+ ...existing.events,
49856
+ [eventId]: {
49857
+ firedAt: record.at ?? nowIso(),
49858
+ version: 1,
49859
+ ...record.outcome ? { outcome: record.outcome } : {}
49860
+ }
49861
+ }
49862
+ };
49863
+ }
49864
+ };
49865
+ const migrateCliState = (state) => {
49866
+ if (state.schemaVersion === 2) return state;
49867
+ const projects = {};
49868
+ for (const [hash, record] of Object.entries(state.projects ?? {})) {
49869
+ const carried = {
49870
+ rootDirectory: record.rootDirectory,
49871
+ ...record.events ? { events: record.events } : {},
49872
+ ...record.migrations ? { migrations: record.migrations } : {}
49873
+ };
49874
+ projects[hash] = record.setupPrompt === false ? {
49875
+ ...carried,
49876
+ events: {
49877
+ ...carried.events,
49878
+ [SETUP_HINT_EVENT]: {
49879
+ firedAt: nowIso(),
49880
+ version: 1,
49881
+ outcome: "declined"
49882
+ }
49883
+ }
49884
+ } : carried;
49885
+ }
49886
+ foldLegacyDecisions(projects, state.ciPrompts, CI_PITCH_EVENT);
49887
+ foldLegacyDecisions(projects, state.actionUpgrades, ACTION_UPGRADE_EVENT);
49888
+ return {
49889
+ schemaVersion: 2,
49890
+ global: typeof state.onboardedAt === "string" ? { events: { [ONBOARDING_EVENT]: {
49891
+ firedAt: state.onboardedAt,
49892
+ version: 1
49893
+ } } } : {},
49894
+ projects
49895
+ };
49896
+ };
49897
+ const resolveConfigDir = (options) => options.cwd ?? (process.env["REACT_DOCTOR_CONFIG_DIR"] || void 0);
49898
+ const openStore = (options = {}) => new Conf({
48891
49899
  projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
48892
- cwd: options.cwd
49900
+ cwd: resolveConfigDir(options)
48893
49901
  });
48894
- const hasCompletedOnboarding = (options = {}) => {
49902
+ const openMigratedStore = (options) => {
49903
+ const store = openStore(options);
49904
+ if (store.store.schemaVersion !== 2) store.store = migrateCliState(store.store);
49905
+ return store;
49906
+ };
49907
+ const readCliState = (select, fallback, options = {}) => {
48895
49908
  try {
48896
- return typeof getOnboardingStore(options).get(ONBOARDED_AT_KEY) === "string";
49909
+ return select(openMigratedStore(options).store);
48897
49910
  } catch {
49911
+ return fallback;
49912
+ }
49913
+ };
49914
+ const updateCliState = (update, options = {}) => {
49915
+ try {
49916
+ const store = openMigratedStore(options);
49917
+ store.store = update(store.store);
48898
49918
  return true;
49919
+ } catch {
49920
+ return false;
48899
49921
  }
48900
49922
  };
49923
+ //#endregion
49924
+ //#region src/cli/utils/hash-project-root.ts
49925
+ const hashProjectRoot = (projectRoot) => createHash("sha256").update(Path.resolve(projectRoot)).digest("hex");
49926
+ //#endregion
49927
+ //#region src/cli/utils/cli-lifecycle.ts
49928
+ const versionOf = (item) => item.version ?? 1;
49929
+ const selectScope = (state, scoped, projectRoot) => scoped.scope === "global" ? state.global : projectRoot === void 0 ? void 0 : state.projects?.[hashProjectRoot(projectRoot)];
49930
+ const updateScope = (state, scoped, projectRoot, updateScopeState) => {
49931
+ if (scoped.scope === "global") return {
49932
+ ...state,
49933
+ global: updateScopeState(state.global ?? {})
49934
+ };
49935
+ if (projectRoot === void 0) return state;
49936
+ const projectKey = hashProjectRoot(projectRoot);
49937
+ const base = state.projects?.[projectKey] ?? { rootDirectory: Path.resolve(projectRoot) };
49938
+ return {
49939
+ ...state,
49940
+ projects: {
49941
+ ...state.projects,
49942
+ [projectKey]: {
49943
+ ...base,
49944
+ ...updateScopeState(base)
49945
+ }
49946
+ }
49947
+ };
49948
+ };
49949
+ const isGatePending = (gate, target = {}, options = {}) => {
49950
+ if (gate.scope === "project" && target.projectRoot === void 0) return false;
49951
+ return readCliState((state) => {
49952
+ const record = selectScope(state, gate, target.projectRoot)?.events?.[gate.id];
49953
+ return !record || record.version < versionOf(gate);
49954
+ }, gate.fireWhenUnknown ?? false, options);
49955
+ };
49956
+ const recordGate = (gate, target = {}, options = {}) => updateCliState((state) => updateScope(state, gate, target.projectRoot, (scope) => ({
49957
+ ...scope,
49958
+ events: {
49959
+ ...scope.events,
49960
+ [gate.id]: {
49961
+ firedAt: nowIso(),
49962
+ version: versionOf(gate),
49963
+ ...target.outcome ? { outcome: target.outcome } : {}
49964
+ }
49965
+ }
49966
+ })), options);
49967
+ const readPreference = (preference, target = {}, options = {}) => readCliState((state) => selectScope(state, preference, target.projectRoot)?.preferences?.[preference.id] ?? null, null, options);
49968
+ const writePreference = (preference, value, target = {}, options = {}) => updateCliState((state) => updateScope(state, preference, target.projectRoot, (scope) => ({
49969
+ ...scope,
49970
+ preferences: {
49971
+ ...scope.preferences,
49972
+ [preference.id]: value
49973
+ }
49974
+ })), options);
49975
+ const isMigrationPending = (migration, target = {}, options = {}) => {
49976
+ if (migration.scope === "project" && target.projectRoot === void 0) return false;
49977
+ return readCliState((state) => {
49978
+ const record = selectScope(state, migration, target.projectRoot)?.migrations?.[migration.id];
49979
+ return !record || record.version < versionOf(migration);
49980
+ }, false, options);
49981
+ };
49982
+ const recordMigration = (migration, projectRoot, record, options) => updateCliState((state) => updateScope(state, migration, projectRoot, (scope) => ({
49983
+ ...scope,
49984
+ migrations: {
49985
+ ...scope.migrations,
49986
+ [migration.id]: record
49987
+ }
49988
+ })), options);
49989
+ const runMigrations = async (migrations, target = {}, options = {}) => {
49990
+ const results = [];
49991
+ for (const migration of migrations) {
49992
+ if (!isMigrationPending(migration, target, options)) {
49993
+ results.push({
49994
+ id: migration.id,
49995
+ ran: false,
49996
+ applied: true
49997
+ });
49998
+ continue;
49999
+ }
50000
+ let applied = false;
50001
+ try {
50002
+ applied = await migration.run({ projectRoot: target.projectRoot });
50003
+ } catch {
50004
+ applied = false;
50005
+ }
50006
+ if (applied) recordMigration(migration, target.projectRoot, {
50007
+ ranAt: nowIso(),
50008
+ version: versionOf(migration)
50009
+ }, options);
50010
+ results.push({
50011
+ id: migration.id,
50012
+ ran: true,
50013
+ applied
50014
+ });
50015
+ }
50016
+ return results;
50017
+ };
50018
+ //#endregion
50019
+ //#region src/cli/utils/onboarding-state.ts
50020
+ const ONBOARDING_GATE = {
50021
+ id: ONBOARDING_EVENT,
50022
+ scope: "global"
50023
+ };
50024
+ const hasCompletedOnboarding = (options = {}) => !isGatePending(ONBOARDING_GATE, {}, options);
48901
50025
  const markOnboardingComplete = (options = {}) => {
48902
- try {
48903
- const store = getOnboardingStore(options);
48904
- if (typeof store.get(ONBOARDED_AT_KEY) === "string") return;
48905
- store.set(ONBOARDED_AT_KEY, (/* @__PURE__ */ new Date()).toISOString());
48906
- } catch {}
50026
+ if (isGatePending(ONBOARDING_GATE, {}, options)) recordGate(ONBOARDING_GATE, {}, options);
48907
50027
  };
48908
50028
  //#endregion
48909
50029
  //#region src/cli/utils/render-project-detection.ts
@@ -49389,7 +50509,7 @@ const readPackageJson = (projectRoot) => {
49389
50509
  return null;
49390
50510
  }
49391
50511
  };
49392
- const writeJsonFile$1 = (filePath, value) => {
50512
+ const writeJsonFile = (filePath, value) => {
49393
50513
  NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
49394
50514
  };
49395
50515
  const packageHasDependency = (projectRoot, dependencyName) => {
@@ -49478,12 +50598,12 @@ const resolveCacheFilePath = (projectDirectory) => {
49478
50598
  const readPersistedCache = (cacheFilePath) => {
49479
50599
  try {
49480
50600
  const parsed = JSON.parse(fs.readFileSync(cacheFilePath, "utf8"));
49481
- if (!isRecord(parsed) || parsed.version !== 1) return {
49482
- version: 1,
50601
+ if (!isRecord(parsed) || parsed.version !== 2) return {
50602
+ version: 2,
49483
50603
  entries: []
49484
50604
  };
49485
50605
  if (!Array.isArray(parsed.entries)) return {
49486
- version: 1,
50606
+ version: 2,
49487
50607
  entries: []
49488
50608
  };
49489
50609
  const entries = [];
@@ -49493,12 +50613,12 @@ const readPersistedCache = (cacheFilePath) => {
49493
50613
  entries.push(entry);
49494
50614
  }
49495
50615
  return {
49496
- version: 1,
50616
+ version: 2,
49497
50617
  entries
49498
50618
  };
49499
50619
  } catch {
49500
50620
  return {
49501
- version: 1,
50621
+ version: 2,
49502
50622
  entries: []
49503
50623
  };
49504
50624
  }
@@ -49551,7 +50671,7 @@ const buildScanResultCacheKey = (input) => {
49551
50671
  if (headSha === null) return null;
49552
50672
  if (stringifyStableJson(input.userConfig) === null) return null;
49553
50673
  const cacheKeyJson = stringifyStableJson({
49554
- schemaVersion: 1,
50674
+ schemaVersion: 2,
49555
50675
  projectIdentity: resolveProjectIdentity(input.projectDirectory),
49556
50676
  headSha,
49557
50677
  reactDoctorVersion: input.version,
@@ -49571,6 +50691,7 @@ const buildScanResultCacheKey = (input) => {
49571
50691
  adoptExistingLintConfig: input.options.adoptExistingLintConfig,
49572
50692
  ignoredTags: [...input.options.ignoredTags].sort(),
49573
50693
  concurrency: input.options.concurrency,
50694
+ lintBatchOrdering: resolveLintBatchOrdering(),
49574
50695
  baselineRef: input.options.baseline?.ref,
49575
50696
  changedLineRanges: input.options.changedLineRanges ?? void 0,
49576
50697
  noScore: input.options.noScore,
@@ -49588,7 +50709,7 @@ const createScanResultCache = (projectDirectory) => {
49588
50709
  for (const entry of persistedCache.entries) entries.set(entry.key, entry);
49589
50710
  const persist = () => {
49590
50711
  writePersistedCache(cacheFilePath, {
49591
- version: 1,
50712
+ version: 2,
49592
50713
  entries: [...entries.values()].sort((firstEntry, secondEntry) => secondEntry.createdAtMs - firstEntry.createdAtMs).slice(0, 20)
49593
50714
  });
49594
50715
  };
@@ -49604,7 +50725,7 @@ const createScanResultCache = (projectDirectory) => {
49604
50725
  }
49605
50726
  };
49606
50727
  };
49607
- const shouldStoreScanPayload = (payload) => !payload.didLintFail && !payload.didDeadCodeFail && payload.lintPartialFailures.length === 0;
50728
+ const shouldStoreScanPayload = (payload) => !payload.didLintFail && !payload.didDeadCodeFail && payload.lintPartialFailures.length === 0 && !payload.supplyChainOverlapTimedOut;
49608
50729
  //#endregion
49609
50730
  //#region src/inspect.ts
49610
50731
  const silentConsole = makeNoopConsole();
@@ -49668,21 +50789,24 @@ const deriveScope = (options) => {
49668
50789
  if (options.changedLineRanges !== null) return "lines";
49669
50790
  return options.includePaths.length > 0 ? "files" : "full";
49670
50791
  };
49671
- const buildRunEventConfig = (options, userConfig, hasCustomConfig) => ({
49672
- scope: deriveScope(options),
49673
- parallel: options.concurrency !== void 0,
49674
- workerCount: options.concurrency,
49675
- lint: options.lint,
49676
- deadCode: options.deadCode,
49677
- scoreOnly: options.scoreOnly,
49678
- noScore: options.noScore,
49679
- respectInlineDisables: options.respectInlineDisables,
49680
- showWarnings: options.warnings,
49681
- usedOutputDir: options.outputDirectory !== null,
49682
- ignoredTagCount: options.ignoredTags.size,
49683
- hasCustomConfig,
49684
- userConfig
49685
- });
50792
+ const buildRunEventConfig = (options, userConfig, hasCustomConfig, resolvedWorkerCount) => {
50793
+ const { workerCount, parallel } = resolveWorkerTelemetry(resolvedWorkerCount, options.concurrency);
50794
+ return {
50795
+ scope: deriveScope(options),
50796
+ parallel,
50797
+ workerCount,
50798
+ lint: options.lint,
50799
+ deadCode: options.deadCode,
50800
+ scoreOnly: options.scoreOnly,
50801
+ noScore: options.noScore,
50802
+ respectInlineDisables: options.respectInlineDisables,
50803
+ showWarnings: options.warnings,
50804
+ usedOutputDir: options.outputDirectory !== null,
50805
+ ignoredTagCount: options.ignoredTags.size,
50806
+ hasCustomConfig,
50807
+ userConfig
50808
+ };
50809
+ };
49686
50810
  const inspect = async (directory, inputOptions = {}) => {
49687
50811
  const startTime = performance$1.now();
49688
50812
  const isConcurrentScan = inputOptions.concurrentScan === true;
@@ -49774,7 +50898,7 @@ const runBaselineComparison = async (params) => {
49774
50898
  resolveLocalGithubViewerPermission: false,
49775
50899
  suppressScanSummary: true,
49776
50900
  supplyChainManifestChanged: params.options.supplyChainManifestChanged
49777
- }, {}).pipe(provide(baseLayers), provideService(Console, silentConsole))));
50901
+ }, {}).pipe(provide(baseLayers), provideService$2(Console, silentConsole))));
49778
50902
  if (baseOutput.didLintFail) return null;
49779
50903
  const delta = computeDiagnosticDelta({
49780
50904
  headDiagnostics: params.headDiagnostics,
@@ -49859,7 +50983,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49859
50983
  runId: getRunId(),
49860
50984
  resolveLocalGithubViewerPermission: !options.noScore,
49861
50985
  suppressScanSummary: options.suppressRendering,
49862
- supplyChainManifestChanged: options.supplyChainManifestChanged
50986
+ supplyChainManifestChanged: options.supplyChainManifestChanged,
50987
+ concurrentScan: options.concurrentScan
49863
50988
  }, { beforeLint: (projectInfo, lintIncludePaths) => gen(function* () {
49864
50989
  recordSentryProjectContext(projectInfo, rootSentrySpan, { concurrentScan: options.concurrentScan });
49865
50990
  recordCount(METRIC.projectDetected, 1);
@@ -49873,7 +50998,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49873
50998
  lintSourceFileCount
49874
50999
  });
49875
51000
  }) });
49876
- const output = await runPromise(restoreLegacyThrow(applyObservability(options.silent ? program.pipe(provide(layers), provideService(Console, silentConsole)) : program.pipe(provide(layers)), rootSentrySpan)));
51001
+ const output = await runPromise(restoreLegacyThrow(applyObservability(options.silent ? program.pipe(provide(layers), provideService$2(Console, silentConsole)) : program.pipe(provide(layers)), rootSentrySpan)));
49877
51002
  const didLintFail = lintBindingMissing || output.didLintFail;
49878
51003
  const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
49879
51004
  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`)));
@@ -49910,12 +51035,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49910
51035
  lintPartialFailures: output.lintPartialFailures,
49911
51036
  didDeadCodeFail: output.didDeadCodeFail,
49912
51037
  deadCodeFailureReason: output.deadCodeFailureReason,
51038
+ deadCodeOverlapped: output.deadCodeOverlapped,
49913
51039
  directory: output.resolvedDirectory,
49914
51040
  scannedFileCount: output.scannedFileCount,
49915
51041
  scannedFilePaths: output.scannedFilePaths,
49916
51042
  scanElapsedMilliseconds: output.scanElapsedMilliseconds,
51043
+ scanConcurrency: output.scanConcurrency,
49917
51044
  baselineDelta,
49918
- lintFailureReasonKind: lintBindingMissing ? "native-binding-missing" : output.lintFailureReasonKind
51045
+ lintFailureReasonKind: lintBindingMissing ? "native-binding-missing" : output.lintFailureReasonKind,
51046
+ supplyChainOverlapTimedOut: output.supplyChainOverlapTimedOut
49919
51047
  };
49920
51048
  if (cacheKey !== null && scanResultCache !== null && shouldStoreScanPayload(payload)) scanResultCache.store(cacheKey, payload);
49921
51049
  const result = await renderAndRecordScan({
@@ -49926,12 +51054,14 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49926
51054
  startTime,
49927
51055
  rootSentrySpan,
49928
51056
  scanMode: baselineDelta ? "baseline" : isDiffMode ? "diff" : "full",
49929
- baselineDegraded
51057
+ baselineDegraded,
51058
+ lintCacheHitFileCount: output.lintCacheHitFileCount,
51059
+ lintCacheTotalFileCount: output.lintCacheTotalFileCount
49930
51060
  });
49931
51061
  recordOnboardingCompletion(options);
49932
51062
  return result;
49933
51063
  };
49934
- const runMaybeSilent = (effect, silent) => silent ? effect.pipe(provideService(Console, silentConsole)) : effect;
51064
+ const runMaybeSilent = (effect, silent) => silent ? effect.pipe(provideService$2(Console, silentConsole)) : effect;
49935
51065
  const renderCachedProjectDetection = async (input) => {
49936
51066
  if (input.options.scoreOnly || input.options.suppressRendering) return;
49937
51067
  await runPromise(runMaybeSilent(printProjectDetection({
@@ -49959,14 +51089,17 @@ const renderAndRecordScan = async (input) => {
49959
51089
  scannedFileCount: input.payload.scannedFileCount,
49960
51090
  scannedFilePaths: input.payload.scannedFilePaths,
49961
51091
  scanElapsedMilliseconds: input.payload.scanElapsedMilliseconds,
51092
+ lintCacheHitFileCount: input.lintCacheHitFileCount ?? null,
51093
+ lintCacheTotalFileCount: input.lintCacheTotalFileCount ?? null,
49962
51094
  baselineDelta: input.payload.baselineDelta
49963
51095
  }), input.options.silent));
51096
+ const { workerCount: resolvedWorkerCount, parallel } = resolveWorkerTelemetry(input.payload.scanConcurrency, input.options.concurrency);
49964
51097
  recordScanMetrics({
49965
51098
  result,
49966
51099
  mode: input.scanMode,
49967
51100
  baselineDegraded: input.baselineDegraded,
49968
- parallel: input.options.concurrency !== void 0,
49969
- workerCount: input.options.concurrency,
51101
+ parallel,
51102
+ workerCount: resolvedWorkerCount,
49970
51103
  lint: input.options.lint,
49971
51104
  deadCode: input.options.deadCode,
49972
51105
  scoreOnly: input.options.scoreOnly,
@@ -49976,19 +51109,22 @@ const renderAndRecordScan = async (input) => {
49976
51109
  didDeadCodeFail: input.payload.didDeadCodeFail
49977
51110
  });
49978
51111
  recordRunEvent(input.rootSentrySpan, {
49979
- ...buildRunEventConfig(input.options, input.userConfig, input.hasCustomConfig),
51112
+ ...buildRunEventConfig(input.options, input.userConfig, input.hasCustomConfig, resolvedWorkerCount),
49980
51113
  result,
49981
51114
  mode: input.scanMode,
49982
51115
  gateExempt: input.baselineDegraded,
49983
51116
  didLintFail: input.payload.didLintFail,
49984
51117
  lintFailureReasonKind: input.payload.lintFailureReasonKind,
49985
51118
  lintPartialFailureCount: input.payload.lintPartialFailures.length,
49986
- didDeadCodeFail: input.payload.didDeadCodeFail
51119
+ lintDroppedFileCount: countDroppedLintFiles(input.payload.lintPartialFailures),
51120
+ didDeadCodeFail: input.payload.didDeadCodeFail,
51121
+ supplyChainOverlapTimedOut: input.payload.supplyChainOverlapTimedOut,
51122
+ deadCodeOverlapped: input.payload.deadCodeOverlapped
49987
51123
  });
49988
51124
  return result;
49989
51125
  };
49990
51126
  const finalizeAndRender = (input) => gen(function* () {
49991
- const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds, baselineDelta } = input;
51127
+ const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds, lintCacheHitFileCount, lintCacheTotalFileCount, baselineDelta } = input;
49992
51128
  const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
49993
51129
  didLintFail,
49994
51130
  lintFailureReason,
@@ -50008,6 +51144,10 @@ const finalizeAndRender = (input) => gen(function* () {
50008
51144
  scannedFileCount,
50009
51145
  scannedFilePaths,
50010
51146
  scanElapsedMilliseconds,
51147
+ ...lintCacheTotalFileCount !== null ? {
51148
+ lintCacheHitFileCount,
51149
+ lintCacheTotalFileCount
51150
+ } : {},
50011
51151
  ...baselineDelta ? { baselineDelta } : {}
50012
51152
  });
50013
51153
  if (options.suppressRendering) return buildResult();
@@ -50089,7 +51229,8 @@ const getStagedSourceFiles = async (directory) => {
50089
51229
  return [...await runPromise(gen(function* () {
50090
51230
  return yield* (yield* StagedFiles).discoverSourceFiles(directory);
50091
51231
  }).pipe(provide(stagedFilesLayer)))];
50092
- } catch {
51232
+ } catch (error) {
51233
+ cliLogger.warn(`Failed to discover staged files: ${error instanceof Error ? error.message : String(error)}`);
50093
51234
  return [];
50094
51235
  }
50095
51236
  };
@@ -50108,6 +51249,35 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
50108
51249
  };
50109
51250
  };
50110
51251
  //#endregion
51252
+ //#region src/cli/utils/is-environment-error.ts
51253
+ const isNodeSystemError = (error) => error instanceof Error && typeof error.code === "string";
51254
+ const ENVIRONMENT_ERROR_CODES = new Set([
51255
+ "ENOSPC",
51256
+ "EIO",
51257
+ "EROFS",
51258
+ "EACCES",
51259
+ "EPERM",
51260
+ "ENOTDIR"
51261
+ ]);
51262
+ const isEnvironmentError = (error) => {
51263
+ if (!isNodeSystemError(error)) return false;
51264
+ if (error.code === "ENOENT") return error.syscall?.startsWith("spawn") ?? false;
51265
+ return error.code !== void 0 && ENVIRONMENT_ERROR_CODES.has(error.code);
51266
+ };
51267
+ const formatEnvironmentError = (error) => {
51268
+ if (!isNodeSystemError(error)) return error instanceof Error ? error.message : String(error);
51269
+ switch (error.code) {
51270
+ case "ENOSPC": return "No space left on device. Free up disk space and try again.";
51271
+ case "EIO": return "I/O error: the filesystem or disk may be failing. Check your system logs.";
51272
+ case "EROFS": return "Read-only filesystem: cannot write to this location.";
51273
+ case "EACCES":
51274
+ case "EPERM": return error.path ? `Permission denied accessing ${error.path}. Check file permissions and try again.` : "Permission denied. Check file permissions and try again.";
51275
+ 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.";
51276
+ case "ENOENT": return "Required command not found. Ensure the tool (e.g. git) is installed and on your PATH.";
51277
+ default: return error.message;
51278
+ }
51279
+ };
51280
+ //#endregion
50111
51281
  //#region src/cli/utils/handle-error.ts
50112
51282
  const OTLP_ENDPOINT_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_ENDPOINT";
50113
51283
  const OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_AUTH_HEADER";
@@ -50190,15 +51360,19 @@ const handleError = (error, options = {}) => {
50190
51360
  process.exitCode = 1;
50191
51361
  };
50192
51362
  /**
50193
- * Renderer for expected, user-actionable failures — a bad `--diff` value or
50194
- * a base branch that isn't fetched. Prints just the (already human-readable)
50195
- * message no "Something went wrong", prefilled issue, Discord link, or
50196
- * Sentry reference because there is no bug to report.
51363
+ * Renderer for expected, user-actionable failures — a bad `--diff` value,
51364
+ * a base branch that isn't fetched, or environment errors like disk-full or
51365
+ * permission-denied. Prints just the (already human-readable) message no
51366
+ * "Something went wrong", prefilled issue, Discord link, or Sentry reference
51367
+ * — because there is no bug to report.
50197
51368
  */
50198
51369
  const handleUserError = (error, options = {}) => {
51370
+ const isEnvError = isEnvironmentError(error);
51371
+ if (isEnvError) recordCount(METRIC.cliEnvironmentError, 1, { code: error.code ?? "unknown" });
51372
+ const message = isEnvError ? formatEnvironmentError(error) : formatErrorForReport(error);
50199
51373
  runSync(gen(function* () {
50200
51374
  yield* error$1("");
50201
- yield* error$1(highlighter.error(formatErrorForReport(error)));
51375
+ yield* error$1(highlighter.error(message));
50202
51376
  yield* error$1("");
50203
51377
  }));
50204
51378
  if (options.shouldExit !== false) process.exit(1);
@@ -50213,7 +51387,7 @@ const handleUserError = (error, options = {}) => {
50213
51387
  * `handleUserError` (a plain message — no "Something went wrong", prefilled
50214
51388
  * issue, Discord link, or Sentry reference), since there is no bug to report.
50215
51389
  *
50216
- * Three distinct shapes reach the CLI's catch blocks:
51390
+ * Four distinct shapes reach the CLI's catch blocks:
50217
51391
  *
50218
51392
  * - **Project-discovery failures** (`NoReactDependencyError`,
50219
51393
  * `ProjectNotFoundError`, `PackageJsonNotFoundError`, `NotADirectoryError`,
@@ -50226,12 +51400,19 @@ const handleUserError = (error, options = {}) => {
50226
51400
  * `--project` name.
50227
51401
  * - **Bad `--diff` input** (`GitBaseBranchInvalid` / `GitBaseBranchMissing`)
50228
51402
  * stays the tagged `ReactDoctorError`, so dispatch on the reason `_tag`.
51403
+ * - **Environment failures** (`ENOSPC`, `EIO`, `EROFS`, `EACCES`, `EPERM`,
51404
+ * `ENOTDIR`, plus a `spawn`-scoped `ENOENT` for a missing binary) — disk
51405
+ * full / failing / read-only, permission denied, or a path blocked by a
51406
+ * file. React Doctor cannot fix the user's environment; exit cleanly with an
51407
+ * actionable message instead of crashing. See `is-environment-error.ts` for
51408
+ * why the set stays narrow (codes that usually mean our bug keep reaching
51409
+ * Sentry).
50229
51410
  *
50230
51411
  * This composes the existing core narrowers rather than introducing a new
50231
51412
  * error-shape helper (AGENTS.md): it encodes CLI-layer reporting policy, not
50232
51413
  * knowledge of the `ReactDoctorError` shape.
50233
51414
  */
50234
- const isExpectedUserError = (error) => error instanceof CliInputError || isProjectDiscoveryError(error) || isReactDoctorError(error) && (error.reason._tag === "GitBaseBranchInvalid" || error.reason._tag === "GitBaseBranchMissing");
51415
+ const isExpectedUserError = (error) => error instanceof CliInputError || isProjectDiscoveryError(error) || isEnvironmentError(error) || isReactDoctorError(error) && (error.reason._tag === "GitBaseBranchInvalid" || error.reason._tag === "GitBaseBranchMissing");
50235
51416
  //#endregion
50236
51417
  //#region src/cli/utils/build-handoff-payload.ts
50237
51418
  const buildHandoffPayload = (input) => {
@@ -50312,6 +51493,20 @@ const detectAvailableAgents = async () => {
50312
51493
  const detected = new Set([...detectPathAvailableAgents(), ...await detectInstalledSkillAgents()]);
50313
51494
  return getSkillAgentTypes().filter((agent) => agent !== "universal" && detected.has(agent));
50314
51495
  };
51496
+ const DEFAULT_INSTALL_AGENTS = [
51497
+ "claude-code",
51498
+ "cursor",
51499
+ "codex",
51500
+ "opencode"
51501
+ ];
51502
+ const computeDefaultSelectedAgents = (detectedAgents, rememberedAgents) => {
51503
+ const detected = new Set(detectedAgents);
51504
+ const remembered = rememberedAgents.filter((agent) => detected.has(agent));
51505
+ if (remembered.length > 0) return remembered;
51506
+ const defaults = DEFAULT_INSTALL_AGENTS.filter((agent) => detected.has(agent));
51507
+ if (defaults.length > 0) return defaults;
51508
+ return detectedAgents.length === 1 ? [...detectedAgents] : [];
51509
+ };
50315
51510
  //#endregion
50316
51511
  //#region src/cli/utils/install-doctor-script.ts
50317
51512
  const DOCTOR_SCRIPT_NAME = "doctor";
@@ -50393,7 +51588,7 @@ const installDoctorScript = (options) => {
50393
51588
  };
50394
51589
  })();
50395
51590
  const scriptStatus = scriptTarget.status;
50396
- if (scriptStatus === "created") writeJsonFile$1(packageJsonPath, {
51591
+ if (scriptStatus === "created") writeJsonFile(packageJsonPath, {
50397
51592
  ...packageJson,
50398
51593
  scripts: {
50399
51594
  ...isRecord$1(scripts) ? scripts : {},
@@ -50547,54 +51742,35 @@ const upgradeReactDoctorWorkflowInPlace = (projectRoot) => {
50547
51742
  }
50548
51743
  };
50549
51744
  //#endregion
50550
- //#region src/cli/utils/hash-project-root.ts
50551
- const hashProjectRoot = (projectRoot) => createHash("sha256").update(Path.resolve(projectRoot)).digest("hex");
50552
- //#endregion
50553
- //#region src/cli/utils/project-decision-store.ts
50554
- const createProjectDecisionStore = (storeKey) => {
50555
- const getStore = (options = {}) => new Conf({
50556
- projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
50557
- cwd: options.cwd
50558
- });
50559
- return {
50560
- getConfigPath: (options = {}) => getStore(options).path,
50561
- hasHandled: (projectRoot, options = {}) => {
50562
- try {
50563
- return Boolean(getStore(options).get(storeKey, {})[hashProjectRoot(projectRoot)]);
50564
- } catch {
50565
- return true;
50566
- }
50567
- },
50568
- record: (projectRoot, outcome, options = {}) => {
50569
- try {
50570
- const store = getStore(options);
50571
- store.set(storeKey, {
50572
- ...store.get(storeKey, {}),
50573
- [hashProjectRoot(projectRoot)]: {
50574
- rootDirectory: Path.resolve(projectRoot),
50575
- outcome,
50576
- at: (/* @__PURE__ */ new Date()).toISOString()
50577
- }
50578
- });
50579
- return true;
50580
- } catch {
50581
- return false;
50582
- }
50583
- }
50584
- };
50585
- };
50586
- //#endregion
50587
51745
  //#region src/cli/utils/action-upgrade-prompt.ts
50588
- const store$1 = createProjectDecisionStore("actionUpgrades");
50589
- store$1.getConfigPath;
50590
- const hasHandledActionUpgrade = store$1.hasHandled;
50591
- const recordActionUpgradeDecision = store$1.record;
51746
+ const ACTION_UPGRADE_GATE = {
51747
+ id: ACTION_UPGRADE_EVENT,
51748
+ scope: "project"
51749
+ };
51750
+ const hasHandledActionUpgrade = (projectRoot, options = {}) => !isGatePending(ACTION_UPGRADE_GATE, { projectRoot }, options);
51751
+ const recordActionUpgradeDecision = (projectRoot, outcome, options = {}) => recordGate(ACTION_UPGRADE_GATE, {
51752
+ projectRoot,
51753
+ outcome
51754
+ }, options);
50592
51755
  //#endregion
50593
51756
  //#region src/cli/utils/ci-prompt-decision.ts
50594
- const store = createProjectDecisionStore("ciPrompts");
50595
- store.getConfigPath;
50596
- const hasHandledCiPrompt = store.hasHandled;
50597
- const recordCiPromptDecision = store.record;
51757
+ const CI_PITCH_GATE = {
51758
+ id: CI_PITCH_EVENT,
51759
+ scope: "project"
51760
+ };
51761
+ const hasHandledCiPrompt = (projectRoot, options = {}) => !isGatePending(CI_PITCH_GATE, { projectRoot }, options);
51762
+ const recordCiPromptDecision = (projectRoot, outcome, options = {}) => recordGate(CI_PITCH_GATE, {
51763
+ projectRoot,
51764
+ outcome
51765
+ }, options);
51766
+ //#endregion
51767
+ //#region src/cli/utils/handoff-target-preference.ts
51768
+ const HANDOFF_TARGET_PREFERENCE = {
51769
+ id: HANDOFF_TARGET_PREFERENCE_ID,
51770
+ scope: "global"
51771
+ };
51772
+ const readHandoffTarget = (options = {}) => readPreference(HANDOFF_TARGET_PREFERENCE, {}, options);
51773
+ const rememberHandoffTarget = (target, options = {}) => writePreference(HANDOFF_TARGET_PREFERENCE, target, {}, options);
50598
51774
  //#endregion
50599
51775
  //#region src/cli/utils/open-url.ts
50600
51776
  const resolveOpenCommand = (url) => {
@@ -50760,39 +51936,80 @@ const DEFAULT_PR_TITLE = "Add React Doctor to GitHub Actions";
50760
51936
  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.
50761
51937
 
50762
51938
  Docs: https://www.react.doctor/ci`;
50763
- const findUniqueBranchName = async (cwd) => {
50764
- if (!(await runCommand("git", [
51939
+ const findUniqueBranchName = async (cwd, run) => {
51940
+ if (!(await run("git", [
50765
51941
  "rev-parse",
50766
51942
  "--verify",
50767
51943
  NEW_BRANCH_PREFIX
50768
51944
  ], cwd)).success) return NEW_BRANCH_PREFIX;
50769
51945
  return `${NEW_BRANCH_PREFIX}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace(/[-:T]/g, "")}`;
50770
51946
  };
51947
+ const findExistingSetupPullRequest = async (cwd, run) => {
51948
+ const prList = await run("gh", [
51949
+ "pr",
51950
+ "list",
51951
+ "--state",
51952
+ "open",
51953
+ "--json",
51954
+ "headRefName,url",
51955
+ "--limit",
51956
+ String(100)
51957
+ ], cwd);
51958
+ if (!prList.success) return null;
51959
+ try {
51960
+ return JSON.parse(prList.stdout).find((pullRequest) => (pullRequest.headRefName ?? "").startsWith(NEW_BRANCH_PREFIX)) ?? null;
51961
+ } catch {
51962
+ return null;
51963
+ }
51964
+ };
51965
+ const hasUnrelatedTrackedChanges = async (cwd, workflowRelative, run) => {
51966
+ const statusProbe = await run("git", [
51967
+ "status",
51968
+ "--porcelain",
51969
+ "--",
51970
+ ".",
51971
+ `:!${workflowRelative}`
51972
+ ], cwd);
51973
+ if (!statusProbe.success) return true;
51974
+ return statusProbe.stdout.split(/\r?\n/).filter(Boolean).some((statusLine) => !statusLine.startsWith("??"));
51975
+ };
50771
51976
  const openWorkflowPullRequest = async (params) => {
50772
51977
  const workflowPath = Path.resolve(params.workflowPath);
50773
51978
  const commitMessage = params.commitMessage ?? DEFAULT_COMMIT_MESSAGE;
50774
51979
  const prTitle = params.prTitle ?? DEFAULT_PR_TITLE;
50775
51980
  const prBody = params.prBody ?? DEFAULT_PR_BODY;
50776
- const repoRootProbe = await runCommand("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
51981
+ const run = params.run ?? runCommand;
51982
+ const checkCommandAvailable = params.checkCommandAvailable ?? isCommandAvailable;
51983
+ const repoRootProbe = await run("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
50777
51984
  if (!repoRootProbe.success) return {
50778
51985
  status: "not-attempted",
50779
51986
  reason: "not-a-git-repo"
50780
51987
  };
50781
51988
  const cwd = repoRootProbe.stdout;
50782
- if (!isCommandAvailable("gh")) return {
51989
+ const workflowRelative = toForwardSlashes(Path.relative(cwd, workflowPath));
51990
+ if (!checkCommandAvailable("gh")) return {
50783
51991
  status: "not-attempted",
50784
51992
  reason: "gh-not-installed"
50785
51993
  };
50786
- if (!(await runCommand("gh", ["auth", "status"], cwd)).success) return {
51994
+ if (!(await run("gh", ["auth", "status"], cwd)).success) return {
50787
51995
  status: "not-attempted",
50788
51996
  reason: "gh-not-authenticated"
50789
51997
  };
50790
- const defaultBranch = params.baseBranch ?? await detectDefaultBranch(cwd);
51998
+ const existingSetupPullRequest = await findExistingSetupPullRequest(cwd, run);
51999
+ if (existingSetupPullRequest) return {
52000
+ status: "pr-exists",
52001
+ url: existingSetupPullRequest.url ?? ""
52002
+ };
52003
+ if (await hasUnrelatedTrackedChanges(cwd, workflowRelative, run)) return {
52004
+ status: "not-attempted",
52005
+ reason: "working-tree-dirty"
52006
+ };
52007
+ const defaultBranch = params.baseBranch ?? await detectDefaultBranch(cwd, run);
50791
52008
  if (!defaultBranch) return {
50792
52009
  status: "not-attempted",
50793
52010
  reason: "no-default-branch"
50794
52011
  };
50795
- const previousBranchProbe = await runCommand("git", [
52012
+ const previousBranchProbe = await run("git", [
50796
52013
  "rev-parse",
50797
52014
  "--abbrev-ref",
50798
52015
  "HEAD"
@@ -50802,13 +52019,13 @@ const openWorkflowPullRequest = async (params) => {
50802
52019
  reason: "detached-head"
50803
52020
  };
50804
52021
  const previousBranch = previousBranchProbe.stdout;
50805
- await runCommand("git", [
52022
+ await run("git", [
50806
52023
  "fetch",
50807
52024
  "origin",
50808
52025
  defaultBranch
50809
52026
  ], cwd);
50810
- const newBranch = await findUniqueBranchName(cwd);
50811
- if (!(await runCommand("git", [
52027
+ const newBranch = await findUniqueBranchName(cwd, run);
52028
+ if (!(await run("git", [
50812
52029
  "checkout",
50813
52030
  "-b",
50814
52031
  newBranch,
@@ -50818,17 +52035,17 @@ const openWorkflowPullRequest = async (params) => {
50818
52035
  reason: "checkout-failed"
50819
52036
  };
50820
52037
  const restoreToPreviousBranch = async (deleteNewBranch) => {
50821
- await runCommand("git", ["checkout", previousBranch], cwd);
50822
- if (deleteNewBranch) await runCommand("git", [
52038
+ await run("git", ["checkout", previousBranch], cwd);
52039
+ if (deleteNewBranch) await run("git", [
50823
52040
  "branch",
50824
52041
  "-D",
50825
52042
  newBranch
50826
52043
  ], cwd);
50827
52044
  };
50828
- if (!(await runCommand("git", [
52045
+ if (!(await run("git", [
50829
52046
  "add",
50830
52047
  "--",
50831
- Path.relative(cwd, workflowPath)
52048
+ workflowRelative
50832
52049
  ], cwd)).success) {
50833
52050
  await restoreToPreviousBranch(true);
50834
52051
  return {
@@ -50836,7 +52053,7 @@ const openWorkflowPullRequest = async (params) => {
50836
52053
  reason: "git-add-failed"
50837
52054
  };
50838
52055
  }
50839
- if (!(await runCommand("git", [
52056
+ if (!(await run("git", [
50840
52057
  "commit",
50841
52058
  "-m",
50842
52059
  commitMessage
@@ -50847,7 +52064,7 @@ const openWorkflowPullRequest = async (params) => {
50847
52064
  reason: "git-commit-failed"
50848
52065
  };
50849
52066
  }
50850
- if (!(await runCommand("git", [
52067
+ if (!(await run("git", [
50851
52068
  "push",
50852
52069
  "-u",
50853
52070
  "origin",
@@ -50859,7 +52076,7 @@ const openWorkflowPullRequest = async (params) => {
50859
52076
  reason: "git-push-failed"
50860
52077
  };
50861
52078
  }
50862
- const prCreate = await runCommand("gh", [
52079
+ const prCreate = await run("gh", [
50863
52080
  "pr",
50864
52081
  "create",
50865
52082
  "--title",
@@ -50883,12 +52100,13 @@ const openWorkflowPullRequest = async (params) => {
50883
52100
  };
50884
52101
  const stageWorkflowFile = async (params) => {
50885
52102
  const workflowPath = Path.resolve(params.workflowPath);
50886
- const repoRootProbe = await runCommand("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
52103
+ const run = params.run ?? runCommand;
52104
+ const repoRootProbe = await run("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
50887
52105
  if (!repoRootProbe.success) return false;
50888
- return (await runCommand("git", [
52106
+ return (await run("git", [
50889
52107
  "add",
50890
52108
  "--",
50891
- Path.relative(repoRootProbe.stdout, workflowPath)
52109
+ toForwardSlashes(Path.relative(repoRootProbe.stdout, workflowPath))
50892
52110
  ], repoRootProbe.stdout)).success;
50893
52111
  };
50894
52112
  //#endregion
@@ -50929,6 +52147,7 @@ const setUpGitHubActions = async (options) => {
50929
52147
  baseBranch: defaultBranch
50930
52148
  });
50931
52149
  if (pullRequestResult.status === "pr-opened") pullRequestSpinner.succeed(`Opened pull request for review: ${highlighter.info(pullRequestResult.url)}`);
52150
+ else if (pullRequestResult.status === "pr-exists") pullRequestSpinner.succeed(`A React Doctor setup pull request is already open: ${highlighter.info(pullRequestResult.url)}`);
50932
52151
  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}`);
50933
52152
  else {
50934
52153
  pullRequestSpinner.stop();
@@ -50940,6 +52159,19 @@ const setUpGitHubActions = async (options) => {
50940
52159
  return didCreateWorkflow;
50941
52160
  };
50942
52161
  //#endregion
52162
+ //#region src/cli/utils/install-agents-preference.ts
52163
+ const INSTALL_AGENTS_PREFERENCE = {
52164
+ id: INSTALL_AGENTS_PREFERENCE_ID,
52165
+ scope: "global"
52166
+ };
52167
+ const PREFERENCE_SEPARATOR = ",";
52168
+ const readInstallAgents = (options = {}) => {
52169
+ const stored = readPreference(INSTALL_AGENTS_PREFERENCE, {}, options);
52170
+ if (stored === null) return [];
52171
+ return stored.split(PREFERENCE_SEPARATOR).map((entry) => entry.trim()).filter((entry) => isSkillAgentType(entry));
52172
+ };
52173
+ const rememberInstallAgents = (agents, options = {}) => writePreference(INSTALL_AGENTS_PREFERENCE, agents.join(PREFERENCE_SEPARATOR), {}, options);
52174
+ //#endregion
50943
52175
  //#region src/cli/utils/install-agent-hooks.ts
50944
52176
  const CLAUDE_AGENT = "claude-code";
50945
52177
  const CURSOR_AGENT = "cursor";
@@ -50950,20 +52182,34 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
50950
52182
  const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
50951
52183
  const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
50952
52184
  const CURSOR_HOOKS_SCHEMA_VERSION = 1;
50953
- const JSON_INDENT_SPACES$1 = 2;
50954
52185
  const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
50955
52186
  const readJsonFile = (filePath, fallback) => {
50956
52187
  if (!NFS.existsSync(filePath)) return fallback;
50957
52188
  const content = NFS.readFileSync(filePath, "utf8").trim();
50958
52189
  if (content.length === 0) return fallback;
50959
- return JSON.parse(content);
52190
+ try {
52191
+ return JSON.parse(content);
52192
+ } catch (error) {
52193
+ 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.`);
52194
+ throw error;
52195
+ }
50960
52196
  };
50961
- const writeJsonFile = (filePath, value) => {
50962
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
50963
- NFS.writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
52197
+ const ensureDirectoryExists = (directoryPath) => {
52198
+ try {
52199
+ NFS.mkdirSync(directoryPath, { recursive: true });
52200
+ } catch (error) {
52201
+ const code = error.code;
52202
+ 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.`);
52203
+ 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.`);
52204
+ throw error;
52205
+ }
52206
+ };
52207
+ const writeJsonFileWithDirectoryCheck = (filePath, value) => {
52208
+ ensureDirectoryExists(Path.dirname(filePath));
52209
+ writeJsonFile(filePath, value);
50964
52210
  };
50965
52211
  const writeHookScript = (filePath) => {
50966
- NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
52212
+ ensureDirectoryExists(Path.dirname(filePath));
50967
52213
  NFS.writeFileSync(filePath, buildAgentHookScript());
50968
52214
  NFS.chmodSync(filePath, 493);
50969
52215
  };
@@ -50979,7 +52225,7 @@ const installClaudeHook = (projectRoot) => {
50979
52225
  command: CLAUDE_HOOK_COMMAND
50980
52226
  }] });
50981
52227
  hooks.PostToolBatch = postToolBatchHooks;
50982
- writeJsonFile(settingsPath, {
52228
+ writeJsonFileWithDirectoryCheck(settingsPath, {
50983
52229
  ...settings,
50984
52230
  hooks
50985
52231
  });
@@ -50999,7 +52245,7 @@ const installCursorHook = (projectRoot) => {
50999
52245
  timeout: 120
51000
52246
  });
51001
52247
  hooks.postToolUse = postToolUseHooks;
51002
- writeJsonFile(configPath, {
52248
+ writeJsonFileWithDirectoryCheck(configPath, {
51003
52249
  ...config,
51004
52250
  version: config.version ?? CURSOR_HOOKS_SCHEMA_VERSION,
51005
52251
  hooks
@@ -51235,7 +52481,7 @@ const installPackageJsonHook = (options, strategy) => {
51235
52481
  parent = cloned;
51236
52482
  }
51237
52483
  parent[leafKey] = strategy.leafShape === "array" ? appendArrayCommand(parent[leafKey]) : appendStringCommand(parent[leafKey]);
51238
- writeJsonFile$1(packageJsonPath, nextPackageJson);
52484
+ writeJsonFile(packageJsonPath, nextPackageJson);
51239
52485
  removeLegacyManagedRunner(options.projectRoot);
51240
52486
  return {
51241
52487
  hookPath: packageJsonPath,
@@ -51818,6 +53064,9 @@ const runInstallReactDoctor = async (options = {}) => {
51818
53064
  const shouldUpgradeWorkflow = canUpgradeWorkflow && (Boolean(options.yes) || upgradePromptOutcome === "yes");
51819
53065
  if (upgradePromptOutcome === "no" && !options.dryRun) recordActionUpgradeDecision(projectRoot, "declined");
51820
53066
  if ((ciPromptOutcome === "yes" || ciPromptOutcome === "no") && !options.dryRun) recordCiPromptDecision(projectRoot, ciPromptOutcome === "yes" ? "accepted" : "declined");
53067
+ const rememberedAgents = options.lastSelectedAgents ?? readInstallAgents();
53068
+ const defaultSelectedAgents = computeDefaultSelectedAgents(detectedAgents, rememberedAgents);
53069
+ const usedRememberedAgents = rememberedAgents.some((agent) => detectedAgents.includes(agent));
51821
53070
  const selectedAgents = skipPrompts ? detectedAgents : (await prompt({
51822
53071
  type: "multiselect",
51823
53072
  name: "agents",
@@ -51825,12 +53074,13 @@ const runInstallReactDoctor = async (options = {}) => {
51825
53074
  choices: detectedAgents.map((agent) => ({
51826
53075
  title: getSkillAgentConfig(agent).displayName,
51827
53076
  value: agent,
51828
- selected: true
53077
+ selected: defaultSelectedAgents.includes(agent)
51829
53078
  })),
51830
53079
  instructions: false,
51831
53080
  min: 1
51832
53081
  }, promptOptions)).agents ?? [];
51833
53082
  if (selectedAgents.length === 0) return;
53083
+ if (!skipPrompts && !options.dryRun) rememberInstallAgents(selectedAgents);
51834
53084
  let dependencyResult;
51835
53085
  if (!options.dryRun) {
51836
53086
  await installReactDoctorSkillStep(sourceDir, selectedAgents, projectRoot);
@@ -51889,6 +53139,8 @@ const runInstallReactDoctor = async (options = {}) => {
51889
53139
  }
51890
53140
  recordCount(METRIC.installCompleted, 1, {
51891
53141
  agentsCount: selectedAgents.length,
53142
+ agentsDetected: detectedAgents.length,
53143
+ usedRememberedAgents,
51892
53144
  gitHook: shouldInstallGitHook,
51893
53145
  agentHooks: shouldInstallAgentHooks,
51894
53146
  workflow: didInstallWorkflow,
@@ -52029,7 +53281,12 @@ const upgradeGitHubActionsWorkflow = async (workflow) => {
52029
53281
  prBody: UPGRADE_PR_BODY
52030
53282
  });
52031
53283
  if (pullRequestResult.status === "pr-opened") upgradeSpinner.succeed(`Opened pull request for review: ${highlighter.info(pullRequestResult.url)}`);
52032
- 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}`);
53284
+ else if (pullRequestResult.status === "pr-exists") {
53285
+ try {
53286
+ NFS.writeFileSync(workflow.workflowPath, workflow.content);
53287
+ } catch {}
53288
+ upgradeSpinner.succeed(`A React Doctor pull request is already open: ${highlighter.info(pullRequestResult.url)}`);
53289
+ } 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}`);
52033
53290
  else {
52034
53291
  upgradeSpinner.stop();
52035
53292
  try {
@@ -52082,29 +53339,34 @@ const handoffToAgent = async (input) => {
52082
53339
  outcome: "ci-suppressed",
52083
53340
  diagnosticsCount: input.diagnostics.length
52084
53341
  });
53342
+ const choices = [
53343
+ ...(await detectLaunchableAgents()).map((agentId) => ({
53344
+ title: getSkillAgentConfig(agentId).displayName,
53345
+ description: `Open ${CLI_AGENT_BINARIES[agentId]} here with the top issues as a prompt`,
53346
+ value: agentId
53347
+ })),
53348
+ {
53349
+ title: "Copy prompt to clipboard",
53350
+ description: "Paste into any agent or chat",
53351
+ value: CLIPBOARD_CHOICE
53352
+ },
53353
+ {
53354
+ title: "Skip",
53355
+ description: "Don't hand off",
53356
+ value: SKIP_CHOICE
53357
+ }
53358
+ ];
53359
+ const rememberedTarget = readHandoffTarget();
53360
+ const rememberedChoiceIndex = choices.findIndex((choice) => choice.value === rememberedTarget);
53361
+ const initial = rememberedChoiceIndex >= 0 ? rememberedChoiceIndex : 0;
52085
53362
  const { handoffTarget } = await prompts({
52086
53363
  type: "select",
52087
53364
  name: "handoffTarget",
52088
53365
  message: "What would you like to do next?",
52089
- choices: [
52090
- ...(await detectLaunchableAgents()).map((agentId) => ({
52091
- title: getSkillAgentConfig(agentId).displayName,
52092
- description: `Open ${CLI_AGENT_BINARIES[agentId]} here with the top issues as a prompt`,
52093
- value: agentId
52094
- })),
52095
- {
52096
- title: "Copy prompt to clipboard",
52097
- description: "Paste into any agent or chat",
52098
- value: CLIPBOARD_CHOICE
52099
- },
52100
- {
52101
- title: "Skip",
52102
- description: "Don't hand off",
52103
- value: SKIP_CHOICE
52104
- }
52105
- ],
52106
- initial: 0
53366
+ choices,
53367
+ initial
52107
53368
  }, { onCancel: () => true });
53369
+ if (handoffTarget !== void 0) rememberHandoffTarget(handoffTarget);
52108
53370
  let handoffOutcome = "launch";
52109
53371
  if (handoffTarget === void 0) handoffOutcome = "cancel";
52110
53372
  else if (handoffTarget === SKIP_CHOICE) handoffOutcome = "skip";
@@ -52112,7 +53374,9 @@ const handoffToAgent = async (input) => {
52112
53374
  recordCount(METRIC.agentHandoff, 1, {
52113
53375
  outcome: handoffOutcome,
52114
53376
  agent: handoffOutcome === "launch" ? handoffTarget : void 0,
52115
- diagnosticsCount: input.diagnostics.length
53377
+ diagnosticsCount: input.diagnostics.length,
53378
+ defaultRemembered: rememberedChoiceIndex >= 0,
53379
+ keptDefault: handoffTarget === choices[initial].value
52116
53380
  });
52117
53381
  if (handoffTarget === void 0 || handoffTarget === SKIP_CHOICE) return;
52118
53382
  const payload = buildHandoffPayload({
@@ -52143,6 +53407,47 @@ const handoffToAgent = async (input) => {
52143
53407
  }
52144
53408
  };
52145
53409
  //#endregion
53410
+ //#region src/cli/utils/migrate-action-pin.ts
53411
+ const WORKFLOWS_DIRECTORY = Path.join(".github", "workflows");
53412
+ const RECOMMENDED_ACTION_REF = "v2";
53413
+ const MUTABLE_ACTION_REF = /(uses:\s*[\w.-]+\/react-doctor@)(?:main|master)\b/g;
53414
+ const isWorkflowFile = (fileName) => /\.ya?ml$/.test(fileName);
53415
+ /**
53416
+ * Rewrites mutable `@main` / `@master` React Doctor GitHub Action references in
53417
+ * the repo's `.github/workflows/*.yml` to the recommended floating major
53418
+ * (`@v2`) — a supply-chain hardening (#299) that also moves the workflow onto
53419
+ * the current (install- and scan-cached) action release. Pinned tags / SHAs are
53420
+ * deliberate and left untouched. Returns the absolute paths of the workflow
53421
+ * files it rewrote — empty when there's nothing to migrate.
53422
+ */
53423
+ const migrateActionPin = (projectRoot) => {
53424
+ const workflowsDirectory = Path.join(projectRoot, WORKFLOWS_DIRECTORY);
53425
+ let entries;
53426
+ try {
53427
+ entries = NFS.readdirSync(workflowsDirectory, { withFileTypes: true });
53428
+ } catch {
53429
+ return [];
53430
+ }
53431
+ const rewrittenFiles = [];
53432
+ for (const entry of entries) {
53433
+ if (!entry.isFile() || !isWorkflowFile(entry.name)) continue;
53434
+ const workflowPath = Path.join(workflowsDirectory, entry.name);
53435
+ let contents;
53436
+ try {
53437
+ contents = NFS.readFileSync(workflowPath, "utf-8");
53438
+ } catch {
53439
+ continue;
53440
+ }
53441
+ const updated = contents.replace(MUTABLE_ACTION_REF, `$1${RECOMMENDED_ACTION_REF}`);
53442
+ if (updated === contents) continue;
53443
+ try {
53444
+ NFS.writeFileSync(workflowPath, updated);
53445
+ rewrittenFiles.push(workflowPath);
53446
+ } catch {}
53447
+ }
53448
+ return rewrittenFiles;
53449
+ };
53450
+ //#endregion
52146
53451
  //#region src/cli/utils/read-object-file.ts
52147
53452
  /**
52148
53453
  * Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
@@ -52207,6 +53512,35 @@ export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
52207
53512
  NFS.rmSync(legacy.legacyFilePath, { force: true });
52208
53513
  return targetPath;
52209
53514
  };
53515
+ const PROJECT_MIGRATIONS = [{
53516
+ id: "config-json-to-ts",
53517
+ scope: "project",
53518
+ run: ({ projectRoot }) => {
53519
+ if (projectRoot === void 0) return false;
53520
+ const legacyConfig = findLegacyConfig(projectRoot);
53521
+ if (!legacyConfig) return false;
53522
+ const migratedPath = migrateLegacyConfig(legacyConfig);
53523
+ if (!migratedPath) return false;
53524
+ cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
53525
+ cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, projectRoot)} and commit it.`);
53526
+ cliLogger.break();
53527
+ return true;
53528
+ }
53529
+ }, {
53530
+ id: "action-pin-main-to-v2",
53531
+ scope: "project",
53532
+ run: ({ projectRoot }) => {
53533
+ if (projectRoot === void 0) return false;
53534
+ const rewrittenFiles = migrateActionPin(projectRoot);
53535
+ if (rewrittenFiles.length === 0) return false;
53536
+ const relativeFiles = rewrittenFiles.map((file) => toRelativePath(file, projectRoot)).join(", ");
53537
+ cliLogger.success(`Pinned the React Doctor action to @v2 in ${relativeFiles}`);
53538
+ 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.");
53539
+ cliLogger.break();
53540
+ return true;
53541
+ }
53542
+ }];
53543
+ const runProjectMigrations = (projectRoot, options = {}) => runMigrations(PROJECT_MIGRATIONS, { projectRoot }, options);
52210
53544
  //#endregion
52211
53545
  //#region src/cli/utils/print-branded-header.ts
52212
53546
  /**
@@ -52426,36 +53760,16 @@ const printMultiProjectSummary = (input) => gen(function* () {
52426
53760
  });
52427
53761
  //#endregion
52428
53762
  //#region src/cli/utils/prompt-install-setup.ts
52429
- const getSetupPromptStore = (options = {}) => new Conf({
52430
- projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
52431
- cwd: options.cwd
52432
- });
52433
- const getSetupPromptProjectKey = (projectRoot) => hashProjectRoot(projectRoot);
52434
- const hasDisabledSetupPrompt = (projectRoot, storeOptions = {}) => {
52435
- try {
52436
- return getSetupPromptStore(storeOptions).get("projects", {})[getSetupPromptProjectKey(projectRoot)]?.setupPrompt === false;
52437
- } catch {
52438
- return false;
52439
- }
52440
- };
52441
- const disableSetupPrompt = (projectRoot, storeOptions = {}) => {
52442
- try {
52443
- const store = getSetupPromptStore(storeOptions);
52444
- const projects = store.get("projects", {});
52445
- const projectKey = getSetupPromptProjectKey(projectRoot);
52446
- store.set("projects", {
52447
- ...projects,
52448
- [projectKey]: {
52449
- ...projects[projectKey] ?? {},
52450
- rootDirectory: Path.resolve(projectRoot),
52451
- setupPrompt: false
52452
- }
52453
- });
52454
- return true;
52455
- } catch {
52456
- return false;
52457
- }
52458
- };
53763
+ const SETUP_HINT_GATE = {
53764
+ id: SETUP_HINT_EVENT,
53765
+ scope: "project",
53766
+ fireWhenUnknown: true
53767
+ };
53768
+ const hasDisabledSetupPrompt = (projectRoot, options = {}) => !isGatePending(SETUP_HINT_GATE, { projectRoot }, options);
53769
+ const disableSetupPrompt = (projectRoot, options = {}) => recordGate(SETUP_HINT_GATE, {
53770
+ projectRoot,
53771
+ outcome: "declined"
53772
+ }, options);
52459
53773
  const resolveInstallSetupProjectRoot = (options) => {
52460
53774
  if (options.scanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
52461
53775
  const packageDirectories = /* @__PURE__ */ new Set();
@@ -52606,7 +53920,7 @@ const warnDeprecatedDiff = (flags, userConfig) => {
52606
53920
  };
52607
53921
  const warnDiffUnavailable = (requested, isQuiet) => {
52608
53922
  if (isQuiet) return;
52609
- 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.`);
53923
+ 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.`);
52610
53924
  else cliLogger.warn("No feature branch or uncommitted changes detected. Running full scan.");
52611
53925
  cliLogger.break();
52612
53926
  };
@@ -52803,6 +54117,7 @@ const resolveRequestedProjects = (requestedNames, workspacePackages, rootDirecto
52803
54117
  return requestedNames.map((requestedName) => {
52804
54118
  const matched = workspacePackages.find((workspacePackage) => workspacePackage.name === requestedName || Path.basename(workspacePackage.directory) === requestedName);
52805
54119
  if (matched) return matched.directory;
54120
+ if (Path.basename(rootDirectory) === requestedName) return rootDirectory;
52806
54121
  const candidateDirectory = Path.resolve(rootDirectory, requestedName);
52807
54122
  if (isDirectory(candidateDirectory)) {
52808
54123
  recordCount(METRIC.projectPathSelected);
@@ -52983,15 +54298,9 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
52983
54298
  * are left untouched — the loader still reads the legacy file as a deprecated
52984
54299
  * fallback and warns — so a scan never mutates the repo unattended.
52985
54300
  */
52986
- const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
54301
+ const maybeMigrateLegacyConfig = async (requestedDirectory, { isQuiet, isStaged }) => {
52987
54302
  if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
52988
- const legacyConfig = findLegacyConfig(requestedDirectory);
52989
- if (!legacyConfig) return;
52990
- const migratedPath = migrateLegacyConfig(legacyConfig);
52991
- if (!migratedPath) return;
52992
- cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
52993
- cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
52994
- cliLogger.break();
54303
+ await runProjectMigrations(requestedDirectory);
52995
54304
  };
52996
54305
  const inspectAction = async (directory, flags) => {
52997
54306
  const isScoreOnly = Boolean(flags.score);
@@ -53006,7 +54315,7 @@ const inspectAction = async (directory, flags) => {
53006
54315
  recordCount(METRIC.cliInvoked, 1, { command: "inspect" });
53007
54316
  try {
53008
54317
  validateModeFlags(flags);
53009
- maybeMigrateLegacyConfig(requestedDirectory, {
54318
+ await maybeMigrateLegacyConfig(requestedDirectory, {
53010
54319
  isQuiet,
53011
54320
  isStaged: Boolean(flags.staged)
53012
54321
  });
@@ -53303,6 +54612,10 @@ const installAction = async (options, command) => {
53303
54612
  projectRoot: options.cwd ?? process.cwd()
53304
54613
  });
53305
54614
  } catch (error) {
54615
+ if (isExpectedUserError(error)) {
54616
+ handleUserError(error);
54617
+ return;
54618
+ }
53306
54619
  handleError(error, { sentryEventId: await reportErrorToSentry(error) });
53307
54620
  }
53308
54621
  };
@@ -54310,4 +55623,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
54310
55623
  export {};
54311
55624
 
54312
55625
  //# sourceMappingURL=cli.js.map
54313
- //# debugId=87ed2e7a-69a3-5a4d-a104-8ba680ac1aa2
55626
+ //# debugId=1bdeba5a-5ce9-5f4f-b9b0-df7abd4c9c0b