react-doctor 0.5.7-dev.242bf69 → 0.5.7-dev.2cadd3f

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]="4aa7cab0-c8a8-5513-bde6-3f7a0a1bdaa7")}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]="fc85ab90-476c-5c45-bfa1-cfead22c445d")}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";
@@ -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,7 @@ 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 PER_WORKER_MEM_BUDGET_BYTES = 1024 * 1024 * 1024;
36765
36866
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
36766
36867
  const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
36767
36868
  const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
@@ -36838,7 +36939,16 @@ const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
36838
36939
  const SKILL_NAME = "react-doctor";
36839
36940
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
36840
36941
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
36942
+ const NODE_COMPILE_CACHE_DIR_NAME = "node-compile-cache";
36943
+ const DEAD_CODE_WORKER_TIMEOUT_MS = 12e4;
36944
+ const OXLINT_SPLIT_TOTAL_BUDGET_MS = 18e4;
36945
+ const DEAD_CODE_PHASE_TIMEOUT_MS = 15e4;
36946
+ const LINT_PHASE_TIMEOUT_MS = 3e5;
36947
+ const SCAN_TOTAL_DEADLINE_MS = 9e5;
36841
36948
  const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192;
36949
+ const DEAD_CODE_TIMEOUT_CEILING_MS = 6e5;
36950
+ const DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS = 3e4;
36951
+ const DEAD_CODE_OVERLAP_PARSE_SHARE = .4;
36842
36952
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
36843
36953
  const REACT_SERVER_DOM_PACKAGES = [
36844
36954
  "react-server-dom-webpack",
@@ -36873,9 +36983,13 @@ const CONFIG_CACHE_TTL_MS = 300 * 1e3;
36873
36983
  const SOCKET_FREE_PURL_API_BASE = "https://firewall-api.socket.dev/purl";
36874
36984
  const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
36875
36985
  const SOCKET_FREE_USER_AGENT = "react-doctor-supply-chain";
36986
+ const FILE_LINT_CACHE_FILENAME = "file-lint-cache.json";
36987
+ const FILE_LINT_CACHE_MAX_FILE_COUNT = 5e4;
36876
36988
  const SUPPLY_CHAIN_PLUGIN = "socket";
36877
36989
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
36878
36990
  const SUPPLY_CHAIN_CATEGORY = "Security";
36991
+ const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
36992
+ const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
36879
36993
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
36880
36994
  const TSCONFIG_FILENAME = "tsconfig.json";
36881
36995
  const isRelativeExtendsValue = (extendsValue) => extendsValue.startsWith("./") || extendsValue.startsWith("../") || Path.isAbsolute(extendsValue);
@@ -38227,6 +38341,11 @@ var OxlintBatchExceeded = class extends TaggedErrorClass()("OxlintBatchExceeded"
38227
38341
  }
38228
38342
  }
38229
38343
  };
38344
+ var ScanDeadlineExceeded = class extends TaggedErrorClass()("ScanDeadlineExceeded", { detail: String$1 }) {
38345
+ get message() {
38346
+ return `Scan exceeded its overall time budget: ${this.detail}`;
38347
+ }
38348
+ };
38230
38349
  var OxlintSpawnFailed = class extends TaggedErrorClass()("OxlintSpawnFailed", { cause: Unknown }) {
38231
38350
  get message() {
38232
38351
  return `Failed to run oxlint: ${pretty(fail$6(this.cause))}`;
@@ -38290,6 +38409,7 @@ var GitBaseBranchInvalid = class extends TaggedErrorClass()("GitBaseBranchInvali
38290
38409
  const ReactDoctorErrorReason = Union([
38291
38410
  OxlintUnavailable,
38292
38411
  OxlintBatchExceeded,
38412
+ ScanDeadlineExceeded,
38293
38413
  OxlintSpawnFailed,
38294
38414
  OxlintOutputUnparseable,
38295
38415
  ConfigParseFailed,
@@ -38362,15 +38482,105 @@ const layerOtlp = unwrap$3(gen(function* () {
38362
38482
  }).pipe(provide$2(layer$9));
38363
38483
  }).pipe(orDie));
38364
38484
  /**
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
38485
+ * Read a positive-millisecond timeout from an env var, falling back to
38486
+ * `defaultMs` when the var is unset, non-finite, or not strictly positive.
38487
+ */
38488
+ const readPositiveEnvMs = (envVarName, defaultMs) => {
38489
+ const rawValue = process.env[envVarName];
38490
+ if (rawValue === void 0) return defaultMs;
38491
+ const parsedValue = Number(rawValue);
38492
+ if (!Number.isFinite(parsedValue) || parsedValue <= 0) return defaultMs;
38493
+ return parsedValue;
38494
+ };
38495
+ const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
38496
+ const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
38497
+ const CGROUP_UNLIMITED_SENTINEL_BYTES = Number.MAX_SAFE_INTEGER;
38498
+ /**
38499
+ * Parses one raw cgroup memory-limit file value into a positive byte count, or
38500
+ * `undefined` when it represents "no limit" (the v2 `"max"` literal, an empty
38501
+ * read, a non-positive / non-finite value, or v1's near-2^63 unlimited
38502
+ * sentinel). Pure and exported so the classification is unit-testable without
38503
+ * touching the filesystem.
38504
+ */
38505
+ const parseCgroupMemoryLimitBytes = (raw) => {
38506
+ if (raw === void 0) return void 0;
38507
+ const trimmed = raw.trim();
38508
+ if (trimmed === "" || trimmed === "max") return void 0;
38509
+ const parsed = Number(trimmed);
38510
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= CGROUP_UNLIMITED_SENTINEL_BYTES) return;
38511
+ return parsed;
38512
+ };
38513
+ const CGROUP_MEMORY_LIMIT_PATHS = [CGROUP_V2_MEMORY_MAX_PATH, CGROUP_V1_MEMORY_LIMIT_PATH];
38514
+ /**
38515
+ * Reads this process's cgroup memory limit in bytes from the first candidate
38516
+ * path that yields a real limit, or `undefined` when none does — no cgroup, no
38517
+ * limit, or the files are unreadable (e.g. macOS / Windows dev machines).
38518
+ * `os.totalmem()` reports the HOST total and ignores cgroup memory limits, so a
38519
+ * memory-constrained container over-reports total memory; `resolveAutoScan-
38520
+ * Concurrency` takes `min(totalmem, this)` to honor the limit.
38521
+ *
38522
+ * The cgroup v2 read is the mount-root `memory.max`, which IS the container's
38523
+ * limit under the standard cgroup-namespace setup CI runners use (the
38524
+ * container's own cgroup is the root of its namespaced view). A process in a
38525
+ * non-namespaced nested/delegated cgroup whose root reads `"max"` is not
38526
+ * detected here and falls back to the host total; the EAGAIN/ENOMEM serial
38527
+ * replay in `spawnLintBatches` remains the runtime backstop for that case.
38528
+ *
38529
+ * `candidatePaths` is injectable so tests exercise the v2-wins-over-v1
38530
+ * precedence, the skip-unreadable fallback, and the all-missing case without a
38531
+ * real `/sys/fs/cgroup`.
38532
+ */
38533
+ const readCgroupMemoryLimitBytes = (candidatePaths = CGROUP_MEMORY_LIMIT_PATHS) => {
38534
+ for (const limitPath of candidatePaths) {
38535
+ let raw;
38536
+ try {
38537
+ raw = fs.readFileSync(limitPath, "utf8");
38538
+ } catch {
38539
+ continue;
38540
+ }
38541
+ const limitBytes = parseCgroupMemoryLimitBytes(raw);
38542
+ if (limitBytes !== void 0) return limitBytes;
38543
+ }
38544
+ };
38545
+ /**
38546
+ * Clamps a requested lint worker count to `[MIN_SCAN_CONCURRENCY,
38547
+ * HARD_MAX_SCAN_CONCURRENCY]` as a finite integer. This is the explicit-pin and
38548
+ * spawn-boundary clamp — the memory-and-core-budgeted auto count comes from
38549
+ * `resolveAutoScanConcurrency`. Out-of-range or non-finite requests degrade to
38368
38550
  * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
38369
38551
  */
38370
38552
  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));
38553
+ if (!Number.isFinite(requested) || requested < 1) return 1;
38554
+ return Math.min(Math.floor(requested), 32);
38555
+ };
38556
+ const readSystemFacts = () => ({
38557
+ availableCores: os.availableParallelism(),
38558
+ totalMemoryBytes: os.totalmem(),
38559
+ cgroupMemoryLimitBytes: readCgroupMemoryLimitBytes()
38560
+ });
38561
+ /**
38562
+ * Auto lint-worker count: the smaller of the (cgroup-CPU-aware) core count and
38563
+ * the number of `PER_WORKER_MEM_BUDGET_BYTES` workers that fit in available
38564
+ * memory, then clamped to `[MIN, HARD_MAX]` by `resolveScanConcurrency`.
38565
+ *
38566
+ * `os.availableParallelism()` already respects cgroup CPU quotas, so the core
38567
+ * term needs no help. Available memory is `os.totalmem()` floored by the cgroup
38568
+ * memory limit — `os.freemem()` is deliberately NOT used: it excludes
38569
+ * reclaimable page cache and reads near-zero on macOS / cache-heavy Linux, which
38570
+ * would collapse the auto path to a single worker. `os.totalmem()` reports the
38571
+ * host total even inside a container, so the cgroup limit (read directly,
38572
+ * because Node doesn't fold it into `totalmem()`) is the real ceiling there.
38573
+ *
38574
+ * `facts` is injectable so tests exercise core-bound, memory-bound, cgroup-
38575
+ * limited, and ceiling cases without mocking `os` or the filesystem.
38576
+ */
38577
+ const resolveAutoScanConcurrency = (facts = readSystemFacts()) => {
38578
+ const availableMemoryBytes = Math.min(facts.totalMemoryBytes, facts.cgroupMemoryLimitBytes ?? Number.POSITIVE_INFINITY);
38579
+ const memoryBoundedWorkers = Math.floor(availableMemoryBytes / PER_WORKER_MEM_BUDGET_BYTES);
38580
+ return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
38581
+ };
38582
+ const resolveLintBatchOrdering = () => {
38583
+ return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "cost" ? "cost" : "arrival";
38374
38584
  };
38375
38585
  /**
38376
38586
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -38378,11 +38588,38 @@ const resolveScanConcurrency = (requested) => {
38378
38588
  * microVMs without recompiling react-doctor. Tests override via
38379
38589
  * `Layer.succeed(OxlintSpawnTimeoutMs, ...)`.
38380
38590
  */
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;
38591
+ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS", OXLINT_SPAWN_TIMEOUT_MS) }) {};
38592
+ /**
38593
+ * Effect-side cap on the lint phase. The env var lets CI / eval runners
38594
+ * raise the phase budget for slow large repos without recompiling.
38595
+ * Tests override via `Layer.succeed(LintPhaseTimeoutMs, ...)`.
38596
+ */
38597
+ var LintPhaseTimeoutMs = class extends Reference("react-doctor/LintPhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_LINT_PHASE_TIMEOUT_MS", LINT_PHASE_TIMEOUT_MS) }) {};
38598
+ /**
38599
+ * Effect-side cap on the dead-code phase, sitting above the in-worker
38600
+ * timeout as a runtime-independent backstop. The env var raises it for
38601
+ * type-heavy projects; tests override via
38602
+ * `Layer.succeed(DeadCodePhaseTimeoutMs, ...)`.
38603
+ */
38604
+ var DeadCodePhaseTimeoutMs = class extends Reference("react-doctor/DeadCodePhaseTimeoutMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_DEAD_CODE_PHASE_TIMEOUT_MS", DEAD_CODE_PHASE_TIMEOUT_MS) }) {};
38605
+ /**
38606
+ * Overall scan deadline backstop, bounding everything the per-phase
38607
+ * timeouts don't (wedged git / IO). The env var raises it for very
38608
+ * large repos; tests override via `Layer.succeed(ScanDeadlineMs, ...)`.
38609
+ */
38610
+ var ScanDeadlineMs = class extends Reference("react-doctor/ScanDeadlineMs", { defaultValue: () => readPositiveEnvMs("REACT_DOCTOR_SCAN_DEADLINE_MS", SCAN_TOTAL_DEADLINE_MS) }) {};
38611
+ /**
38612
+ * Wall-clock budget for the supply-chain check when it runs on a background
38613
+ * fiber overlapping the lint pass. Reads from the env var on startup so the
38614
+ * eval harness can raise the budget under sandbox microVMs (slower network)
38615
+ * without recompiling react-doctor. Tests override via
38616
+ * `Layer.succeed(SupplyChainOverlapTimeoutMs, ...)`.
38617
+ */
38618
+ var SupplyChainOverlapTimeoutMs = class extends Reference("react-doctor/SupplyChainOverlapTimeoutMs", { defaultValue: () => {
38619
+ const raw = process.env["REACT_DOCTOR_SUPPLY_CHAIN_TIMEOUT_MS"];
38620
+ if (raw === void 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
38384
38621
  const parsed = Number(raw);
38385
- if (!Number.isFinite(parsed) || parsed <= 0) return OXLINT_SPAWN_TIMEOUT_MS;
38622
+ if (!Number.isFinite(parsed) || parsed <= 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
38386
38623
  return parsed;
38387
38624
  } }) {};
38388
38625
  /**
@@ -38393,31 +38630,93 @@ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTime
38393
38630
  */
38394
38631
  var OxlintOutputMaxBytes = class extends Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
38395
38632
  /**
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)
38633
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults to a
38634
+ * memory-and-core-budgeted auto count (`resolveAutoScanConcurrency`) so large
38635
+ * repos scan fast out of the box without OOMing the native binding on a
38636
+ * high-core / low-memory box; `spawnLintBatches` transparently falls back to a
38637
+ * single worker if a parallel run still exhausts system resources. The CLI's
38638
+ * `--no-parallel` flag forces serial via `Layer.succeed`; the
38639
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic / CI
38640
+ * callers that never touch the flag — parallelism is opt-OUT, so only the
38641
+ * explicit serial values pin one worker:
38642
+ *
38643
+ * - unset / `auto` / `true` / `on` → memory-and-core-budgeted auto count
38406
38644
  * - `0` / `false` / `off` → `1` (serial)
38407
38645
  * - a positive integer → that many workers (clamped)
38408
- * - any other value → available CPU cores (clamped)
38646
+ * - any other value → memory-and-core-budgeted auto count
38409
38647
  *
38410
38648
  * The resolved value is always within
38411
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
38649
+ * `[MIN_SCAN_CONCURRENCY, HARD_MAX_SCAN_CONCURRENCY]`.
38412
38650
  */
38413
38651
  var OxlintConcurrency = class extends Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
38414
38652
  const raw = process.env["REACT_DOCTOR_PARALLEL"];
38415
- if (raw === void 0) return resolveScanConcurrency("auto");
38653
+ if (raw === void 0) return resolveAutoScanConcurrency();
38416
38654
  const normalized = raw.trim().toLowerCase();
38417
38655
  if (normalized === "0" || normalized === "false" || normalized === "off") return 1;
38418
38656
  const parsed = Number.parseInt(normalized, 10);
38419
38657
  if (Number.isInteger(parsed) && parsed > 0) return resolveScanConcurrency(parsed);
38420
- return resolveScanConcurrency("auto");
38658
+ return resolveAutoScanConcurrency();
38659
+ } }) {};
38660
+ /**
38661
+ * Three-state control for overlapping the dead-code pass with the lint pass —
38662
+ * forking dead-code as a child fiber that runs DURING lint instead of strictly
38663
+ * after it.
38664
+ *
38665
+ * - `"auto"` (default) / `"off"` → strictly SEQUENTIAL: dead-code runs after
38666
+ * lint with the full core budget. Both deslop's parse pool and the oxlint
38667
+ * pool are CPU-bound and each size themselves to all cores, so overlapping
38668
+ * them only oversubscribes (~2x the cores) and starves the parse pass past
38669
+ * its timeout — for no wall-clock win, since there are no spare cores to
38670
+ * absorb the second pass. Sequential is both faster per-phase and safe.
38671
+ * - `"on"` → force the overlap anyway. The orchestrator then SPLITS the core
38672
+ * budget (`DEAD_CODE_OVERLAP_PARSE_SHARE`): deslop's parse pool is capped
38673
+ * and lint shrinks to the remainder, so the two sum to the cores instead of
38674
+ * doubling them, and the dead-code timeout scales up for the reduced share.
38675
+ *
38676
+ * Seeded from `REACT_DOCTOR_DEAD_CODE_OVERLAP` so operators get a redeploy-free
38677
+ * switch; tests pin it via `Layer.succeed(DeadCodeOverlap, ...)`.
38678
+ */
38679
+ var DeadCodeOverlap = class extends Reference("react-doctor/DeadCodeOverlap", { defaultValue: () => {
38680
+ const raw = process.env["REACT_DOCTOR_DEAD_CODE_OVERLAP"]?.trim().toLowerCase();
38681
+ if (raw === "on" || raw === "true" || raw === "1") return "on";
38682
+ if (raw === "off" || raw === "false" || raw === "0") return "off";
38683
+ return "auto";
38684
+ } }) {};
38685
+ /**
38686
+ * How the full-scan lint pass orders its file batches. `"arrival"` (the
38687
+ * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
38688
+ * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
38689
+ * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
38690
+ * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
38691
+ * batch is a straggler (and can trip the per-batch timeout + split), measurably
38692
+ * regressing the common full-scan case. LPT needs the heavy files SPREAD across
38693
+ * batches before `cost` earns the default. Tests override via
38694
+ * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
38695
+ * — they pass user-scoped `includePaths` that skip discovery and stay in
38696
+ * arrival order; only the full-scan branch reads it.
38697
+ */
38698
+ var LintBatchOrdering = class extends Reference("react-doctor/LintBatchOrdering", { defaultValue: resolveLintBatchOrdering }) {};
38699
+ const CACHE_DISABLED_VALUES$1 = new Set(["1", "true"]);
38700
+ /**
38701
+ * Whether the per-file lint cache (`runners/oxlint/file-lint-cache.ts`) is
38702
+ * active. Defaults ON — repeat scans re-lint only the files whose content
38703
+ * changed, and correctness is guaranteed byte-identical to a cold scan by the
38704
+ * always-fresh cross-file sidecar. Opt-OUT, two knobs (matching the whole-repo
38705
+ * scan cache's `REACT_DOCTOR_NO_CACHE`):
38706
+ *
38707
+ * - `REACT_DOCTOR_NO_CACHE` — the global off-switch; disables BOTH the
38708
+ * whole-repo scan cache and this per-file cache.
38709
+ * - `REACT_DOCTOR_NO_FILE_CACHE` — granular: bust only the per-file cache
38710
+ * while keeping the whole-repo short-circuit.
38711
+ *
38712
+ * Tests override via `Layer.succeed(PerFileLintCacheEnabled, false)`.
38713
+ */
38714
+ var PerFileLintCacheEnabled = class extends Reference("react-doctor/PerFileLintCacheEnabled", { defaultValue: () => {
38715
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
38716
+ const noFileCache = process.env["REACT_DOCTOR_NO_FILE_CACHE"]?.toLowerCase() ?? "";
38717
+ if (CACHE_DISABLED_VALUES$1.has(noCache)) return false;
38718
+ if (CACHE_DISABLED_VALUES$1.has(noFileCache)) return false;
38719
+ return true;
38421
38720
  } }) {};
38422
38721
  const DIAGNOSTIC_SURFACES = [
38423
38722
  "cli",
@@ -38851,6 +39150,8 @@ const assignFixGroups = (diagnostics) => {
38851
39150
  };
38852
39151
  });
38853
39152
  };
39153
+ const compareStrings = (left, right) => left < right ? -1 : left > right ? 1 : 0;
39154
+ 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
39155
  const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
38855
39156
  const buildExpoCheckContext = (rootDirectory, expoVersion) => {
38856
39157
  const packageJson = readPackageJson$1(Path.join(rootDirectory, "package.json"));
@@ -39357,10 +39658,15 @@ const buildHardeningDiagnostic = (input) => ({
39357
39658
  column: input.column ?? 0,
39358
39659
  category: "Security"
39359
39660
  });
39360
- const checkPnpmHardening = (rootDirectory) => {
39361
- if (!isPnpmManagedProject(rootDirectory)) return [];
39362
- const workspacePath = Path.join(rootDirectory, PNPM_WORKSPACE_FILE);
39363
- const settings = parseHardeningSettings(isFile(workspacePath) ? NFS.readFileSync(workspacePath, "utf-8") : "");
39661
+ const checkPnpmHardening = (scanDirectory) => {
39662
+ if (!isPnpmManagedProject(scanDirectory)) return [];
39663
+ const workspacePath = Path.join(scanDirectory, PNPM_WORKSPACE_FILE);
39664
+ const hasWorkspaceFile = isFile(workspacePath);
39665
+ if (!hasWorkspaceFile) {
39666
+ const monorepoRoot = findMonorepoRoot(scanDirectory);
39667
+ if (monorepoRoot !== null && isFile(Path.join(monorepoRoot, PNPM_WORKSPACE_FILE))) return [];
39668
+ }
39669
+ const settings = parseHardeningSettings(hasWorkspaceFile ? NFS.readFileSync(workspacePath, "utf-8") : "");
39364
39670
  const diagnostics = [];
39365
39671
  if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
39366
39672
  message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
@@ -40161,6 +40467,22 @@ process.stdin.on("end", () => {
40161
40467
  ...(workerInput.ignorePatterns.length > 0
40162
40468
  ? { ignorePatterns: workerInput.ignorePatterns }
40163
40469
  : {}),
40470
+ // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports,
40471
+ // unusedDependencies, circularDependencies). Everything else deslop can compute
40472
+ // is pure wasted work for us, and it's the bulk of the runtime:
40473
+ // - semantic: a full TS Program for unusedTypes/enum/class-members/
40474
+ // misclassifiedDependencies (~37-45% of the phase).
40475
+ // - reportCodeQuality: the duplicate-block, complexity, feature-flag,
40476
+ // TypeScript-smell, private-type-leak and re-export-cycle detectors. These
40477
+ // are the single most expensive pass — duplicate-block detection alone was
40478
+ // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code
40479
+ // speedup on a large repo.
40480
+ // Both are provably safe: the consumed graph findings are computed by their own
40481
+ // detectors, independent of these passes (confirmed byte-identical on
40482
+ // excalidraw + mui-material + sentry). tsConfigPath stays — the module resolver
40483
+ // needs it for path-alias resolution in the import graph.
40484
+ semantic: { enabled: false },
40485
+ reportCodeQuality: false,
40164
40486
  };
40165
40487
  const result = await analyze(defineConfig(config));
40166
40488
  emit({ ok: true, result: normalizeResult(result) });
@@ -40290,7 +40612,11 @@ const createDeadCodeWorker = (input) => {
40290
40612
  "pipe",
40291
40613
  "pipe"
40292
40614
  ],
40293
- windowsHide: true
40615
+ windowsHide: true,
40616
+ env: input.parseConcurrency === void 0 ? process.env : {
40617
+ ...process.env,
40618
+ DESLOP_PARSE_CONCURRENCY: String(input.parseConcurrency)
40619
+ }
40294
40620
  });
40295
40621
  const stdoutChunks = [];
40296
40622
  const stderrChunks = [];
@@ -40335,28 +40661,25 @@ const createDeadCodeWorker = (input) => {
40335
40661
  }
40336
40662
  };
40337
40663
  };
40338
- const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve, reject) => {
40664
+ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs, abortSignal) => new Promise((resolve, reject) => {
40339
40665
  let didSettle = false;
40340
- const timeoutHandle = setTimeout(() => {
40341
- if (didSettle) return;
40342
- didSettle = true;
40343
- handle.terminate?.();
40344
- reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`));
40345
- }, timeoutMs);
40346
- timeoutHandle.unref?.();
40347
- handle.result.then((value) => {
40348
- if (didSettle) return;
40349
- didSettle = true;
40350
- clearTimeout(timeoutHandle);
40351
- handle.terminate?.();
40352
- resolve(value);
40353
- }, (error) => {
40666
+ const settle = (finish) => {
40354
40667
  if (didSettle) return;
40355
40668
  didSettle = true;
40356
40669
  clearTimeout(timeoutHandle);
40670
+ abortSignal?.removeEventListener("abort", onAbort);
40357
40671
  handle.terminate?.();
40358
- reject(error);
40359
- });
40672
+ finish();
40673
+ };
40674
+ const onAbort = () => settle(() => reject(/* @__PURE__ */ new Error("Dead-code worker aborted.")));
40675
+ const timeoutHandle = setTimeout(() => settle(() => reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`))), timeoutMs);
40676
+ timeoutHandle.unref?.();
40677
+ if (abortSignal?.aborted) {
40678
+ onAbort();
40679
+ return;
40680
+ }
40681
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
40682
+ handle.result.then((value) => settle(() => resolve(value)), (error) => settle(() => reject(error)));
40360
40683
  });
40361
40684
  const checkDeadCode = async (options) => {
40362
40685
  const rootDirectory = toCanonicalPath(options.rootDirectory);
@@ -40368,8 +40691,9 @@ const checkDeadCode = async (options) => {
40368
40691
  entryPatterns,
40369
40692
  tsConfigPath: resolveTsConfigPath(rootDirectory),
40370
40693
  ignorePatterns,
40371
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js")
40372
- }), options.workerTimeoutMs ?? 12e4));
40694
+ deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
40695
+ parseConcurrency: options.parseConcurrency
40696
+ }), options.workerTimeoutMs ?? 12e4, options.abortSignal));
40373
40697
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
40374
40698
  const diagnostics = [];
40375
40699
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -40467,7 +40791,37 @@ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
40467
40791
  return true;
40468
40792
  };
40469
40793
  const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
40470
- const excludeMinifiedFiles = (rootDirectory, relativePaths) => relativePaths.filter((relativePath) => !isLargeMinifiedFile(Path.resolve(rootDirectory, relativePath)));
40794
+ /**
40795
+ * Budget for the dead-code phase, scaled to the work. deslop's graph build is
40796
+ * CPU-bound and roughly linear in file count, so a fixed 120s cap is too tight
40797
+ * for a large repo (where the pass legitimately runs that long) and is then
40798
+ * tipped over by any concurrent load — silently dropping every dead-code
40799
+ * finding. Scaling the budget with file count (and inversely with the core
40800
+ * share when overlapped) lets the pass complete, while the ceiling still
40801
+ * reclaims a genuinely wedged worker. Returns the in-worker SIGKILL deadline
40802
+ * and the Effect-side phase backstop that sits a margin above it.
40803
+ */
40804
+ const resolveDeadCodeTimeout = (input) => {
40805
+ const coreShareFactor = Math.max(1, input.fullConcurrency / Math.max(1, input.deadCodeConcurrency));
40806
+ const workerTimeoutMs = Math.min(DEAD_CODE_TIMEOUT_CEILING_MS, Math.max(DEAD_CODE_WORKER_TIMEOUT_MS, Math.ceil(input.sourceFileCount * 30 * coreShareFactor)));
40807
+ return {
40808
+ workerTimeoutMs,
40809
+ phaseTimeoutMs: workerTimeoutMs + DEAD_CODE_PHASE_TIMEOUT_OVER_WORKER_MS
40810
+ };
40811
+ };
40812
+ const collectSizedSourceFiles = (rootDirectory, relativePaths) => {
40813
+ const entries = [];
40814
+ for (const relativePath of relativePaths) {
40815
+ const absolutePath = Path.resolve(rootDirectory, relativePath);
40816
+ const sizeBytes = statSourceFileSize(absolutePath);
40817
+ if (isLargeMinifiedFile(absolutePath, sizeBytes)) continue;
40818
+ entries.push({
40819
+ path: relativePath,
40820
+ sizeBytes: sizeBytes ?? 0
40821
+ });
40822
+ }
40823
+ return entries;
40824
+ };
40471
40825
  const listSourceFilesViaGit = (rootDirectory) => {
40472
40826
  const result = spawnSync("git", [
40473
40827
  "ls-files",
@@ -40500,7 +40854,8 @@ const listSourceFilesViaFilesystem = (rootDirectory) => {
40500
40854
  }
40501
40855
  return filePaths;
40502
40856
  };
40503
- const listSourceFiles = (rootDirectory) => excludeMinifiedFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
40857
+ const listSourceFilesWithSize = (rootDirectory) => collectSizedSourceFiles(rootDirectory, listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory));
40858
+ const listSourceFiles = (rootDirectory) => listSourceFilesWithSize(rootDirectory).map((entry) => entry.path);
40504
40859
  const resolveLintIncludePaths = (rootDirectory, userConfig, project) => {
40505
40860
  if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
40506
40861
  const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
@@ -40543,9 +40898,12 @@ var Config = class Config extends Service()("react-doctor/Config") {
40543
40898
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
40544
40899
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
40545
40900
  return yield* tryPromise({
40546
- try: () => checkDeadCode({
40901
+ try: (signal) => checkDeadCode({
40547
40902
  rootDirectory: input.rootDirectory,
40548
- userConfig: input.userConfig
40903
+ userConfig: input.userConfig,
40904
+ parseConcurrency: input.parseConcurrency,
40905
+ workerTimeoutMs: input.workerTimeoutMs,
40906
+ abortSignal: signal
40549
40907
  }),
40550
40908
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
40551
40909
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
@@ -41258,6 +41616,14 @@ const neutralizeDisableDirectives = async (rootDirectory, includePaths) => {
41258
41616
  process.removeListener("exit", onExit);
41259
41617
  };
41260
41618
  };
41619
+ const ROOT_DIRECTORY_PLACEHOLDER = "<root>";
41620
+ const normalizeConfigForHash = (config) => {
41621
+ const clone = JSON.parse(JSON.stringify(config));
41622
+ if (clone?.settings?.["react-doctor"]) clone.settings["react-doctor"].rootDirectory = ROOT_DIRECTORY_PLACEHOLDER;
41623
+ if (Array.isArray(clone?.jsPlugins)) clone.jsPlugins = clone.jsPlugins.map((_, index) => `<plugin:${index}>`);
41624
+ return clone;
41625
+ };
41626
+ const computeRulesetHash = (input) => crypto.createHash("sha1").update(JSON.stringify(normalizeConfigForHash(input.config))).update("\0").update([...input.toolchainVersions].join("\0")).update("\0").update([...input.ignorePatterns].join("\n")).update(" ").update(input.tsconfigContent ?? "").digest("hex");
41261
41627
  /**
41262
41628
  * Loads a plugin module via the local require resolver and extracts
41263
41629
  * `(name, ruleNames)` from either `module.exports.meta + rules` or
@@ -41412,8 +41778,8 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
41412
41778
  }
41413
41779
  return enabled;
41414
41780
  };
41415
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false }) => {
41416
- const reactHooksJsPlugin = disableReactHooksJsPlugin ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
41781
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false, ruleSelection }) => {
41782
+ const reactHooksJsPlugin = disableReactHooksJsPlugin || ruleSelection === "sidecar" ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
41417
41783
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
41418
41784
  const jsPlugins = [];
41419
41785
  if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
@@ -41422,6 +41788,8 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41422
41788
  for (const registryEntry of REACT_DOCTOR_RULES) {
41423
41789
  const rule = reactDoctorPlugin.rules[registryEntry.id];
41424
41790
  if (!rule) continue;
41791
+ if (ruleSelection === "cacheable" && CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
41792
+ if (ruleSelection === "sidecar" && !CROSS_FILE_RULE_IDS.has(registryEntry.id)) continue;
41425
41793
  if (rule.scan !== void 0) continue;
41426
41794
  if (customRulesOnly && registryEntry.originallyExternal) continue;
41427
41795
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -41436,7 +41804,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41436
41804
  enabledReactDoctorRules[registryEntry.key] = severity;
41437
41805
  }
41438
41806
  const userPluginRules = {};
41439
- for (const userPlugin of userPlugins) {
41807
+ if (ruleSelection !== "sidecar") for (const userPlugin of userPlugins) {
41440
41808
  Object.assign(userPluginRules, buildUserPluginRules(userPlugin, severityControls));
41441
41809
  jsPlugins.push(userPlugin.entry);
41442
41810
  }
@@ -41466,6 +41834,100 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41466
41834
  }
41467
41835
  };
41468
41836
  };
41837
+ const atomicWriteJson = (filePath, value) => {
41838
+ try {
41839
+ NFS.mkdirSync(Path.dirname(filePath), { recursive: true });
41840
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
41841
+ NFS.writeFileSync(temporaryPath, JSON.stringify(value));
41842
+ NFS.renameSync(temporaryPath, filePath);
41843
+ } catch {
41844
+ return;
41845
+ }
41846
+ };
41847
+ const failOpenReadJson = (filePath, fallback) => {
41848
+ try {
41849
+ return JSON.parse(NFS.readFileSync(filePath, "utf8"));
41850
+ } catch {
41851
+ return fallback;
41852
+ }
41853
+ };
41854
+ const validateDiagnostic = decodeUnknownSync(Diagnostic);
41855
+ const decodeFileDiagnostics = (raw) => {
41856
+ if (!Array.isArray(raw)) return null;
41857
+ try {
41858
+ for (const entry of raw) validateDiagnostic(entry);
41859
+ return raw;
41860
+ } catch {
41861
+ return null;
41862
+ }
41863
+ };
41864
+ const emptyCache = () => ({
41865
+ version: 1,
41866
+ rulesets: {}
41867
+ });
41868
+ const loadRulesetEntries = (cacheFilePath, rulesetHash) => {
41869
+ const entries = /* @__PURE__ */ new Map();
41870
+ const persisted = failOpenReadJson(cacheFilePath, emptyCache());
41871
+ if (persisted.version !== 1 || !isRecord$2(persisted.rulesets)) return entries;
41872
+ const bucket = persisted.rulesets[rulesetHash];
41873
+ if (!isRecord$2(bucket) || !isRecord$2(bucket.files)) return entries;
41874
+ for (const [fileKey, rawDiagnostics] of Object.entries(bucket.files)) {
41875
+ const decoded = decodeFileDiagnostics(rawDiagnostics);
41876
+ if (decoded !== null) entries.set(fileKey, decoded);
41877
+ }
41878
+ return entries;
41879
+ };
41880
+ const createFileLintCache = (cacheDirectory, rulesetHash) => {
41881
+ const cacheFilePath = Path.join(cacheDirectory, FILE_LINT_CACHE_FILENAME);
41882
+ const entries = loadRulesetEntries(cacheFilePath, rulesetHash);
41883
+ return {
41884
+ lookup: (fileKey) => entries.get(fileKey) ?? null,
41885
+ store: (fileKey, diagnostics) => {
41886
+ entries.delete(fileKey);
41887
+ entries.set(fileKey, diagnostics);
41888
+ },
41889
+ persist: () => {
41890
+ const onDisk = failOpenReadJson(cacheFilePath, emptyCache());
41891
+ const rulesets = onDisk.version === 1 && isRecord$2(onDisk.rulesets) ? { ...onDisk.rulesets } : {};
41892
+ const existingBucket = rulesets[rulesetHash];
41893
+ const existingFiles = isRecord$2(existingBucket) && isRecord$2(existingBucket.files) ? existingBucket.files : {};
41894
+ const ourFiles = {};
41895
+ for (const [fileKey, diagnostics] of entries) ourFiles[fileKey] = diagnostics;
41896
+ const cappedEntries = Object.entries({
41897
+ ...existingFiles,
41898
+ ...ourFiles
41899
+ }).slice(-FILE_LINT_CACHE_MAX_FILE_COUNT);
41900
+ rulesets[rulesetHash] = {
41901
+ updatedAtMs: Date.now(),
41902
+ files: Object.fromEntries(cappedEntries)
41903
+ };
41904
+ const keptHashes = Object.entries(rulesets).sort(([, first], [, second]) => second.updatedAtMs - first.updatedAtMs).slice(0, 8).map(([hash]) => hash);
41905
+ const prunedRulesets = {};
41906
+ for (const hash of keptHashes) prunedRulesets[hash] = rulesets[hash];
41907
+ atomicWriteJson(cacheFilePath, {
41908
+ version: 1,
41909
+ rulesets: prunedRulesets
41910
+ });
41911
+ }
41912
+ };
41913
+ };
41914
+ const bundledRequire$2 = createRequire(import.meta.url);
41915
+ const TOOLCHAIN_PACKAGE_SPECIFIERS$1 = [
41916
+ "oxlint/package.json",
41917
+ "oxlint-plugin-react-doctor/package.json",
41918
+ "eslint-plugin-react-hooks/package.json"
41919
+ ];
41920
+ const resolveOxlintToolchainVersions = () => {
41921
+ const versions = [`node=${process.version}`];
41922
+ for (const specifier of TOOLCHAIN_PACKAGE_SPECIFIERS$1) try {
41923
+ const packageJson = bundledRequire$2(specifier);
41924
+ const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
41925
+ versions.push(`${specifier}=${version}`);
41926
+ } catch {
41927
+ versions.push(`${specifier}=missing`);
41928
+ }
41929
+ return versions;
41930
+ };
41469
41931
  const esmRequire = createRequire(import.meta.url);
41470
41932
  const resolveOxlintBinary = () => {
41471
41933
  const oxlintMainPath = esmRequire.resolve("oxlint");
@@ -42147,15 +42609,19 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
42147
42609
  };
42148
42610
  });
42149
42611
  };
42150
- const SANITIZED_ENV = (() => {
42151
- const sanitized = {};
42152
- for (const [name, value] of Object.entries(process.env)) {
42612
+ const buildOxlintChildEnv = (sourceEnv) => {
42613
+ const childEnv = {};
42614
+ for (const [name, value] of Object.entries(sourceEnv)) {
42153
42615
  if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
42154
42616
  if (name.startsWith("npm_config_")) continue;
42155
- sanitized[name] = value;
42617
+ childEnv[name] = value;
42156
42618
  }
42157
- return sanitized;
42158
- })();
42619
+ const isCompileCacheDisabled = Boolean(sourceEnv.NODE_DISABLE_COMPILE_CACHE);
42620
+ const isCompileCacheAlreadySet = childEnv.NODE_COMPILE_CACHE !== void 0;
42621
+ if (!isCompileCacheDisabled && !isCompileCacheAlreadySet) childEnv.NODE_COMPILE_CACHE = Path.join(os.tmpdir(), NODE_COMPILE_CACHE_DIR_NAME);
42622
+ return childEnv;
42623
+ };
42624
+ const SANITIZED_ENV = buildOxlintChildEnv(process.env);
42159
42625
  /**
42160
42626
  * Spawn one oxlint subprocess with hard ceilings on wall time and
42161
42627
  * output size. Returns stdout on success; raises a tagged
@@ -42172,7 +42638,11 @@ const SANITIZED_ENV = (() => {
42172
42638
  * The first three are splittable (the caller's binary-split retry
42173
42639
  * shrinks the batch and re-spawns); the fourth isn't.
42174
42640
  */
42175
- const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES) => new Promise((resolve, reject) => {
42641
+ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLINT_SPAWN_TIMEOUT_MS, outputMaxBytes = OXLINT_OUTPUT_MAX_BYTES, abortSignal) => new Promise((resolve, reject) => {
42642
+ if (abortSignal?.aborted) {
42643
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
42644
+ return;
42645
+ }
42176
42646
  const child = spawn(nodeBinaryPath, args, {
42177
42647
  cwd: rootDirectory,
42178
42648
  env: SANITIZED_ENV,
@@ -42182,7 +42652,14 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42182
42652
  "pipe"
42183
42653
  ]
42184
42654
  });
42655
+ const onAbort = () => {
42656
+ child.kill("SIGKILL");
42657
+ reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: "lint phase aborted" }) }));
42658
+ };
42659
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
42660
+ const clearAbortListener = () => abortSignal?.removeEventListener("abort", onAbort);
42185
42661
  const timeoutHandle = setTimeout(() => {
42662
+ clearAbortListener();
42186
42663
  child.kill("SIGKILL");
42187
42664
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42188
42665
  kind: "timeout",
@@ -42217,10 +42694,12 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42217
42694
  });
42218
42695
  child.on("error", (error) => {
42219
42696
  clearTimeout(timeoutHandle);
42697
+ clearAbortListener();
42220
42698
  reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: error }) }));
42221
42699
  });
42222
42700
  child.on("close", (_code, signal) => {
42223
42701
  clearTimeout(timeoutHandle);
42702
+ clearAbortListener();
42224
42703
  if (didKillForSize) {
42225
42704
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42226
42705
  kind: "output-too-large",
@@ -42287,26 +42766,28 @@ const isParallelismRelatedSpawnError = (error) => {
42287
42766
  * loop with a slimmer config in that case.
42288
42767
  */
42289
42768
  const spawnLintBatches = async (input) => {
42290
- const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
42769
+ const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes, splitTotalBudgetMs = OXLINT_SPLIT_TOTAL_BUDGET_MS, splitMaxDepth = 8, signal } = input;
42291
42770
  const requestedConcurrency = resolveScanConcurrency(input.concurrency ?? 1);
42292
42771
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
42293
42772
  const runBatchPass = async (concurrency) => {
42294
42773
  const allDiagnostics = [];
42295
42774
  const droppedFiles = [];
42296
42775
  let firstDropReason = null;
42297
- const spawnLintBatch = async (batch) => {
42776
+ const splitDeadlineMs = Date.now() + splitTotalBudgetMs;
42777
+ const spawnLintBatch = async (batch, depth) => {
42298
42778
  const batchArgs = [...baseArgs, ...batch];
42299
42779
  try {
42300
- return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes), project, rootDirectory);
42780
+ return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes, signal), project, rootDirectory);
42301
42781
  } catch (error) {
42302
42782
  if (!isSplittableReactDoctorError(error)) throw error;
42303
- if (batch.length <= 1) {
42783
+ const splitBudgetExhausted = Date.now() >= splitDeadlineMs || depth >= splitMaxDepth;
42784
+ if (batch.length <= 1 || splitBudgetExhausted) {
42304
42785
  droppedFiles.push(...batch);
42305
- if (firstDropReason === null) firstDropReason = error.message;
42786
+ if (firstDropReason === null) firstDropReason = splitBudgetExhausted && batch.length > 1 ? `${error.message} (split budget exhausted after ${splitMaxDepth} levels / ${splitTotalBudgetMs / MILLISECONDS_PER_SECOND}s)` : error.message;
42306
42787
  return [];
42307
42788
  }
42308
42789
  const splitIndex = Math.ceil(batch.length / 2);
42309
- return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
42790
+ return [...await spawnLintBatch(batch.slice(0, splitIndex), depth + 1), ...await spawnLintBatch(batch.slice(splitIndex), depth + 1)];
42310
42791
  }
42311
42792
  };
42312
42793
  let startedFileCount = 0;
@@ -42323,7 +42804,7 @@ const spawnLintBatches = async (input) => {
42323
42804
  try {
42324
42805
  const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
42325
42806
  startedFileCount += batch.length;
42326
- const batchDiagnostics = await spawnLintBatch(batch);
42807
+ const batchDiagnostics = await spawnLintBatch(batch, 0);
42327
42808
  scannedFileCount += batch.length;
42328
42809
  if (onFileProgress) {
42329
42810
  displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
@@ -42384,6 +42865,22 @@ const validateRuleRegistration = () => {
42384
42865
  ].filter((entry) => entry !== null).join("; ");
42385
42866
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
42386
42867
  };
42868
+ const hashFileContents = (filePath) => {
42869
+ try {
42870
+ return crypto.createHash("sha1").update(NFS.readFileSync(filePath)).digest("hex");
42871
+ } catch {
42872
+ return null;
42873
+ }
42874
+ };
42875
+ const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
42876
+ const resolveReactDoctorCacheDir = (projectDirectory) => {
42877
+ const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
42878
+ if (cacheDirOverride) return Path.join(cacheDirOverride, projectCacheSubdir(projectDirectory));
42879
+ const nodeModulesDirectory = Path.join(projectDirectory, "node_modules");
42880
+ if (NFS.existsSync(nodeModulesDirectory)) return Path.join(nodeModulesDirectory, ".cache", "react-doctor");
42881
+ return Path.join(os.tmpdir(), "react-doctor-cache", projectCacheSubdir(projectDirectory));
42882
+ };
42883
+ const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
42387
42884
  /**
42388
42885
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
42389
42886
  * the runner: once for the primary scan, once for the
@@ -42442,7 +42939,7 @@ const reactHooksJsPluginDropNote = (error) => {
42442
42939
  * 6. always restore disable directives + clean up the temp dir
42443
42940
  */
42444
42941
  const runOxlint = async (options) => {
42445
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, spawnTimeoutMs, outputMaxBytes } = options;
42942
+ const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, onCacheStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "arrival" } = options;
42446
42943
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
42447
42944
  const severityControls = buildRuleSeverityControls(userConfig);
42448
42945
  validateRuleRegistration();
@@ -42459,30 +42956,156 @@ const runOxlint = async (options) => {
42459
42956
  serverAuthFunctionNames,
42460
42957
  severityControls,
42461
42958
  userPlugins,
42462
- disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
42959
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
42960
+ ruleSelection: overrides.ruleSelection
42463
42961
  });
42464
42962
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
42465
42963
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
42466
42964
  const configPath = Path.join(configDirectory, "oxlintrc.json");
42467
42965
  try {
42468
- const baseArgs = [
42469
- resolveOxlintBinary(),
42470
- "-c",
42471
- configPath,
42472
- "--format",
42473
- "json"
42474
- ];
42966
+ const oxlintBinary = resolveOxlintBinary();
42967
+ const sharedArgs = [];
42968
+ let tsconfigContent = null;
42475
42969
  if (project.hasTypeScript) {
42476
42970
  const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
42477
- if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
42971
+ if (tsconfigRelativePath) {
42972
+ sharedArgs.push("--tsconfig", tsconfigRelativePath);
42973
+ try {
42974
+ tsconfigContent = NFS.readFileSync(Path.resolve(rootDirectory, tsconfigRelativePath), "utf8");
42975
+ } catch {
42976
+ tsconfigContent = null;
42977
+ }
42978
+ }
42478
42979
  }
42479
42980
  const combinedPatterns = collectIgnorePatterns(rootDirectory);
42480
42981
  if (combinedPatterns.length > 0) {
42481
42982
  const combinedIgnorePath = Path.join(configDirectory, "combined.ignore");
42482
42983
  NFS.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
42483
- baseArgs.push("--ignore-path", combinedIgnorePath);
42984
+ sharedArgs.push("--ignore-path", combinedIgnorePath);
42484
42985
  }
42485
- const fileBatches = batchIncludePaths(baseArgs, includePaths !== void 0 ? includePaths : listSourceFiles(rootDirectory));
42986
+ const makeBaseArgs = (oxlintConfigPath) => [
42987
+ oxlintBinary,
42988
+ "-c",
42989
+ oxlintConfigPath,
42990
+ "--format",
42991
+ "json",
42992
+ ...sharedArgs
42993
+ ];
42994
+ const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
42995
+ const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
42996
+ const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
42997
+ if (files.length === 0) return {
42998
+ diagnostics: [],
42999
+ didDropReactHooksJsPlugin: false,
43000
+ hadPartialFailure: false
43001
+ };
43002
+ let hadPartialFailure = false;
43003
+ const reportPartialFailure = (reason) => {
43004
+ hadPartialFailure = true;
43005
+ onPartialFailure?.(reason);
43006
+ };
43007
+ const passConfigPath = Path.join(configDirectory, configFileName);
43008
+ const passBaseArgs = makeBaseArgs(passConfigPath);
43009
+ const passFileBatches = batchIncludePaths(passBaseArgs, files);
43010
+ const spawnPass = () => spawnLintBatches({
43011
+ baseArgs: passBaseArgs,
43012
+ fileBatches: passFileBatches,
43013
+ rootDirectory,
43014
+ nodeBinaryPath,
43015
+ project,
43016
+ onPartialFailure: reportPartialFailure,
43017
+ onFileProgress: fileProgress,
43018
+ spawnTimeoutMs,
43019
+ outputMaxBytes,
43020
+ concurrency: options.concurrency,
43021
+ signal: options.signal
43022
+ });
43023
+ writeOxlintConfig(passConfigPath, buildConfigForPass({}));
43024
+ try {
43025
+ return {
43026
+ diagnostics: await spawnPass(),
43027
+ didDropReactHooksJsPlugin: false,
43028
+ hadPartialFailure
43029
+ };
43030
+ } catch (error) {
43031
+ const reactHooksJsDropNote = reactHooksJsPluginDropNote(error);
43032
+ if (reactHooksJsDropNote === null) throw error;
43033
+ writeOxlintConfig(passConfigPath, buildConfigForPass({ disableReactHooksJsPlugin: true }));
43034
+ const diagnostics = await spawnPass();
43035
+ reportPartialFailure(reactHooksJsDropNote);
43036
+ return {
43037
+ diagnostics,
43038
+ didDropReactHooksJsPlugin: true,
43039
+ hadPartialFailure
43040
+ };
43041
+ }
43042
+ };
43043
+ if (perFileLintCacheEnabled && respectInlineDisables && !project.hasReactCompiler && extendsPaths.length === 0 && userPlugins.length === 0) {
43044
+ const rulesetHash = computeRulesetHash({
43045
+ config: buildConfig({
43046
+ extendsPaths: [],
43047
+ ruleSelection: "cacheable"
43048
+ }),
43049
+ toolchainVersions: resolveOxlintToolchainVersions(),
43050
+ ignorePatterns: combinedPatterns,
43051
+ tsconfigContent
43052
+ });
43053
+ const cache = createFileLintCache(resolveReactDoctorCacheDir(rootDirectory), rulesetHash);
43054
+ const cacheKeyByFile = /* @__PURE__ */ new Map();
43055
+ const missFiles = [];
43056
+ const replayedDiagnostics = [];
43057
+ for (const candidateFile of candidateFiles) {
43058
+ const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
43059
+ if (contentHash === null) {
43060
+ missFiles.push(candidateFile);
43061
+ continue;
43062
+ }
43063
+ const cacheKey = `${candidateFile.replaceAll("\\", "/")}${contentHash}`;
43064
+ cacheKeyByFile.set(candidateFile, cacheKey);
43065
+ const cachedDiagnostics = cache.lookup(cacheKey);
43066
+ if (cachedDiagnostics === null) missFiles.push(candidateFile);
43067
+ else replayedDiagnostics.push(...cachedDiagnostics);
43068
+ }
43069
+ const cacheHitFileCount = candidateFiles.length - missFiles.length;
43070
+ const cacheableResult = await runConfigOverFiles((overrides) => buildConfig({
43071
+ extendsPaths: [],
43072
+ ruleSelection: "cacheable",
43073
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
43074
+ }), "oxlintrc.cacheable.json", missFiles, void 0);
43075
+ const sidecarResult = await runConfigOverFiles(() => buildConfig({
43076
+ extendsPaths: [],
43077
+ ruleSelection: "sidecar"
43078
+ }), "oxlintrc.sidecar.json", candidateFiles, options.onFileProgress);
43079
+ onCacheStats?.(cacheHitFileCount, candidateFiles.length);
43080
+ const missFileByNormalizedPath = /* @__PURE__ */ new Map();
43081
+ for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
43082
+ const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
43083
+ let isAttributionSound = true;
43084
+ for (const diagnostic of cacheableResult.diagnostics) {
43085
+ const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
43086
+ if (missFile === void 0) {
43087
+ isAttributionSound = false;
43088
+ break;
43089
+ }
43090
+ const fileDiagnostics = freshDiagnosticsByFile.get(missFile) ?? [];
43091
+ fileDiagnostics.push(diagnostic);
43092
+ freshDiagnosticsByFile.set(missFile, fileDiagnostics);
43093
+ }
43094
+ if (!cacheableResult.didDropReactHooksJsPlugin && !cacheableResult.hadPartialFailure && isAttributionSound) {
43095
+ for (const missFile of missFiles) {
43096
+ const cacheKey = cacheKeyByFile.get(missFile);
43097
+ if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
43098
+ }
43099
+ cache.persist();
43100
+ }
43101
+ return dedupeDiagnostics([
43102
+ ...replayedDiagnostics,
43103
+ ...cacheableResult.diagnostics,
43104
+ ...sidecarResult.diagnostics
43105
+ ]);
43106
+ }
43107
+ const baseArgs = makeBaseArgs(configPath);
43108
+ const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
42486
43109
  const runBatches = () => spawnLintBatches({
42487
43110
  baseArgs,
42488
43111
  fileBatches,
@@ -42493,7 +43116,8 @@ const runOxlint = async (options) => {
42493
43116
  onFileProgress: options.onFileProgress,
42494
43117
  spawnTimeoutMs,
42495
43118
  outputMaxBytes,
42496
- concurrency: options.concurrency
43119
+ concurrency: options.concurrency,
43120
+ signal: options.signal
42497
43121
  });
42498
43122
  writeOxlintConfig(configPath, buildConfig({ extendsPaths }));
42499
43123
  try {
@@ -42572,9 +43196,11 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
42572
43196
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
42573
43197
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
42574
43198
  const concurrency = yield* OxlintConcurrency;
43199
+ const lintBatchOrdering = yield* LintBatchOrdering;
43200
+ const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
42575
43201
  const collectedFailures = [];
42576
43202
  const diagnostics = yield* tryPromise({
42577
- try: () => runOxlint({
43203
+ try: (signal) => runOxlint({
42578
43204
  rootDirectory: input.rootDirectory,
42579
43205
  project: input.project,
42580
43206
  includePaths: input.includePaths ? [...input.includePaths] : void 0,
@@ -42589,9 +43215,13 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
42589
43215
  collectedFailures.push(reason);
42590
43216
  },
42591
43217
  onFileProgress: input.onFileProgress,
43218
+ perFileLintCacheEnabled,
43219
+ onCacheStats: input.onCacheStats,
42592
43220
  spawnTimeoutMs,
42593
43221
  outputMaxBytes,
42594
- concurrency
43222
+ concurrency,
43223
+ signal,
43224
+ lintBatchOrdering
42595
43225
  }),
42596
43226
  catch: ensureReactDoctorError
42597
43227
  });
@@ -42983,14 +43613,46 @@ const parseArtifactFromBody = (body) => {
42983
43613
  }
42984
43614
  return null;
42985
43615
  };
42986
- const fetchSocketArtifact = (dependency) => tryPromise(async (signal) => {
43616
+ const isSupplyChainCacheDisabled = () => {
43617
+ const noCache = process.env["REACT_DOCTOR_NO_CACHE"]?.toLowerCase() ?? "";
43618
+ return noCache === "1" || noCache === "true";
43619
+ };
43620
+ const supplyChainCacheFile = (cacheDirectory, dependency) => {
43621
+ const purlHash = crypto.createHash("sha256").update(toPurl(dependency)).digest("hex").slice(0, 16);
43622
+ return Path.join(cacheDirectory, SUPPLY_CHAIN_CACHE_SUBDIR, `${purlHash}.json`);
43623
+ };
43624
+ const readCachedSocketBody = (cacheFile) => {
43625
+ try {
43626
+ const entry = JSON.parse(NFS.readFileSync(cacheFile, "utf-8"));
43627
+ 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;
43628
+ } catch {}
43629
+ return null;
43630
+ };
43631
+ const writeCachedSocketBody = (cacheFile, body) => {
43632
+ try {
43633
+ NFS.mkdirSync(Path.dirname(cacheFile), { recursive: true });
43634
+ NFS.writeFileSync(cacheFile, JSON.stringify({
43635
+ fetchedAtMs: Date.now(),
43636
+ body
43637
+ }));
43638
+ } catch {}
43639
+ };
43640
+ const fetchSocketArtifact = (dependency, cacheDirectory) => tryPromise(async (signal) => {
43641
+ const cacheFile = cacheDirectory === null ? null : supplyChainCacheFile(cacheDirectory, dependency);
43642
+ if (cacheFile !== null) {
43643
+ const cachedBody = readCachedSocketBody(cacheFile);
43644
+ if (cachedBody !== null) return parseArtifactFromBody(cachedBody);
43645
+ }
42987
43646
  const requestUrl = `${SOCKET_FREE_PURL_API_BASE}/${encodeURIComponent(toPurl(dependency))}`;
42988
43647
  const response = await fetch(requestUrl, {
42989
43648
  headers: { "User-Agent": SOCKET_FREE_USER_AGENT },
42990
43649
  signal
42991
43650
  });
42992
43651
  if (!response.ok) return null;
42993
- return parseArtifactFromBody(await response.text());
43652
+ const body = await response.text();
43653
+ const artifact = parseArtifactFromBody(body);
43654
+ if (artifact !== null && cacheFile !== null) writeCachedSocketBody(cacheFile, body);
43655
+ return artifact;
42994
43656
  }).pipe(timeout(FETCH_TIMEOUT_MS), orElseSucceed(() => null), tap$1((artifact) => {
42995
43657
  const scoreAttributes = {};
42996
43658
  if (artifact !== null) {
@@ -43095,7 +43757,8 @@ const checkSupplyChain = (input) => gen(function* () {
43095
43757
  const packageJsonPath = Path.join(input.rootDirectory, "package.json");
43096
43758
  const dependencies = collectDependenciesToScore(readPackageJson$1(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
43097
43759
  if (dependencies.length === 0) return [];
43098
- const artifacts = yield* forEach$1(dependencies, fetchSocketArtifact, { concurrency: 8 });
43760
+ const cacheDirectory = isSupplyChainCacheDisabled() ? null : resolveReactDoctorCacheDir(input.rootDirectory);
43761
+ const artifacts = yield* forEach$1(dependencies, (dependency) => fetchSocketArtifact(dependency, cacheDirectory), { concurrency: 8 }).pipe(timeoutOption(input.totalTimeoutMs ?? 9e4), map$3((maybeArtifacts) => getOrElse$1(maybeArtifacts, () => [])));
43099
43762
  const diagnostics = [];
43100
43763
  for (let index = 0; index < dependencies.length; index += 1) {
43101
43764
  const artifact = artifacts[index];
@@ -43120,6 +43783,10 @@ const checkSupplyChain = (input) => gen(function* () {
43120
43783
  * The underlying `checkSupplyChain` Effect is total/fail-open — per-package
43121
43784
  * timeouts and network failures recover to "skip" — so the stream never
43122
43785
  * fails, mirroring `DeadCode`'s stream shape so the two compose the same way.
43786
+ * The orchestrator (`run-inspect.ts`) consumes this stream on a background
43787
+ * fiber whose network time overlaps the lint pass, joined under a generous
43788
+ * wall-clock budget; a budget expiry is the same fail-open outcome as a Socket
43789
+ * outage.
43123
43790
  */
43124
43791
  var SupplyChain = class SupplyChain extends Service()("react-doctor/SupplyChain") {
43125
43792
  static layerNode = succeed$3(SupplyChain, SupplyChain.of({ run: (input) => unwrap(checkSupplyChain(input).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)), withSpan("SupplyChain.run"))) }));
@@ -43178,18 +43845,42 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
43178
43845
  *
43179
43846
  * Phases:
43180
43847
  *
43181
- * 1. Config.resolve(directory) → Project.discover → Git metadata
43848
+ * 1. Config.resolve(directory) → Project.discover → Git metadata.
43849
+ * The GitHub viewer-permission lookup is forked onto a background
43850
+ * fiber here and joined late (it feeds score metadata, not
43851
+ * diagnostics).
43182
43852
  * 2. beforeLint hook (e.g. CLI renders the project-detection block)
43183
43853
  * 3. environment checks (reduced-motion + pnpm hardening +
43184
- * expo/react-native + security scan)
43185
- * 4. Linter.run + DeadCode.run forked as concurrent fibers so
43186
- * their wall-clock times overlap. Progress spinners stay
43187
- * sequential (lint first, then dead-code) for clean terminal
43188
- * output. GitHub viewer permission also runs as a background
43189
- * fiber during this phase.
43190
- * 5. afterLint hook
43191
- * 6. Reporter.finalize
43192
- * 7. Score.compute against the surface-filtered diagnostic set
43854
+ * expo/react-native + security scan), collected synchronously
43855
+ * 4. The supply-chain check (Socket.dev) is forked onto a background
43856
+ * fiber so its ~100% network-bound time overlaps the ~100%
43857
+ * CPU/subprocess-bound lint pass below, collapsing two serial
43858
+ * phases into roughly `max(supplyChain, lint)`. It is capped by
43859
+ * `SupplyChainOverlapTimeoutMs` (measured from fork) so a hung
43860
+ * socket can't drag out its join; on timeout it fails open to no
43861
+ * diagnostics — the same outcome class as a Socket outage.
43862
+ * 5. Linter.run runs; DeadCode.run runs concurrently (forked child
43863
+ * fiber) ONLY when the memory gate has headroom to run the 8 GB
43864
+ * dead-code child alongside the oxlint workers — or when overlap is
43865
+ * forced via REACT_DOCTOR_DEAD_CODE_OVERLAP. Otherwise dead-code
43866
+ * runs sequentially after lint, exactly as it did pre-overlap. The
43867
+ * fiber is joined (or interrupted, SIGKILLing its worker, on lint
43868
+ * failure) before diagnostics are concatenated. The afterLint hook
43869
+ * fires between lint and dead-code. Progress spinner labels AND the
43870
+ * final diagnostic / score order stay independent of execution
43871
+ * order, so terminal output is identical either way; supply-chain
43872
+ * rides alongside without a spinner.
43873
+ * 6. Join the supply-chain fiber, then assemble the diagnostics in a
43874
+ * FIXED order (env, supply-chain, lint, dead-code) so the output is
43875
+ * byte-identical regardless of which fiber settled first. The
43876
+ * viewer-permission fiber is joined later, during score-metadata
43877
+ * assembly (it feeds score metadata, not diagnostics). The per-element
43878
+ * `Reporter.emit` side-channel now interleaves supply-chain with lint
43879
+ * emits, so capture-order assertions must target the deterministic
43880
+ * concat below, not emit order (production `Reporter.layerNoop` makes
43881
+ * emit a no-op).
43882
+ * 7. Reporter.finalize
43883
+ * 8. Score.compute against the surface-filtered diagnostic set
43193
43884
  *
43194
43885
  * The orchestrator owns spinner lifecycle via `Progress`; callers
43195
43886
  * choose `Progress.layerOra(...)` for CLI feedback or
@@ -43247,10 +43938,21 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43247
43938
  ignoredTags: input.ignoredTags
43248
43939
  })
43249
43940
  ])));
43250
- const supplyChainCollected = !isDiffMode || (input.supplyChainManifestChanged ?? false) ? yield* runCollect(applyPerElementPipeline(supplyChainService.run({
43941
+ const shouldRunSupplyChain = !isDiffMode || (input.supplyChainManifestChanged ?? false);
43942
+ const supplyChainOverlapTimeout = yield* SupplyChainOverlapTimeoutMs;
43943
+ const supplyChainFiber = yield* forkChild(shouldRunSupplyChain ? runCollect(applyPerElementPipeline(supplyChainService.run({
43251
43944
  rootDirectory: scanDirectory,
43252
43945
  userConfig: resolvedConfig.config
43253
- }))) : [];
43946
+ }))).pipe(map$3((diagnostics) => ({
43947
+ diagnostics,
43948
+ timedOut: false
43949
+ })), timeout(supplyChainOverlapTimeout), orElseSucceed(() => ({
43950
+ diagnostics: [],
43951
+ timedOut: true
43952
+ }))) : succeed$2({
43953
+ diagnostics: [],
43954
+ timedOut: false
43955
+ }));
43254
43956
  const lintFailure = yield* make$13({
43255
43957
  didFail: false,
43256
43958
  reason: null,
@@ -43261,12 +43963,49 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43261
43963
  didFail: false,
43262
43964
  reason: null
43263
43965
  });
43264
- const scanConcurrency = yield* OxlintConcurrency;
43966
+ const scanConcurrency = resolveScanConcurrency(yield* OxlintConcurrency);
43967
+ const lintPhaseTimeoutMs = yield* LintPhaseTimeoutMs;
43968
+ const deadCodePhaseTimeoutMs = yield* DeadCodePhaseTimeoutMs;
43969
+ const resolveDeadCodePhaseTimeoutMs = (scaledPhaseTimeoutMs) => deadCodePhaseTimeoutMs === 15e4 ? scaledPhaseTimeoutMs : deadCodePhaseTimeoutMs;
43265
43970
  const workerCountSuffix = scanConcurrency > 1 ? ` ${highlighter.dim(`[~${scanConcurrency} workers]`)}` : "";
43971
+ const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
43972
+ const deadCodeOverlapMode = yield* DeadCodeOverlap;
43973
+ const shouldOverlapDeadCode = shouldRunDeadCode && deadCodeOverlapMode === "on";
43974
+ const deadCodeParseConcurrency = shouldOverlapDeadCode ? Math.max(1, Math.floor(scanConcurrency * DEAD_CODE_OVERLAP_PARSE_SHARE)) : void 0;
43975
+ const lintConcurrency = deadCodeParseConcurrency === void 0 ? scanConcurrency : Math.max(1, scanConcurrency - deadCodeParseConcurrency);
43976
+ const buildCollectDeadCode = (deadCodeTimeout) => runCollect(applyPerElementPipeline(deadCodeService.run({
43977
+ rootDirectory: scanDirectory,
43978
+ userConfig: resolvedConfig.config,
43979
+ parseConcurrency: deadCodeParseConcurrency,
43980
+ workerTimeoutMs: deadCodeTimeout.workerTimeoutMs
43981
+ }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43982
+ yield* set(deadCodeFailure, {
43983
+ didFail: true,
43984
+ reason: error.message
43985
+ });
43986
+ return empty$4;
43987
+ })))))).pipe(timeoutOption(deadCodeTimeout.phaseTimeoutMs), flatMap$2(match$3({
43988
+ onNone: () => set(deadCodeFailure, {
43989
+ didFail: true,
43990
+ reason: `Dead-code analysis exceeded ${Math.round(deadCodeTimeout.phaseTimeoutMs / MILLISECONDS_PER_SECOND)}s and was skipped.`
43991
+ }).pipe(as([])),
43992
+ onSome: succeed$2
43993
+ })));
43994
+ const overlapDeadCodeTimeout = resolveDeadCodeTimeout({
43995
+ sourceFileCount: project.sourceFileCount,
43996
+ deadCodeConcurrency: deadCodeParseConcurrency ?? scanConcurrency,
43997
+ fullConcurrency: scanConcurrency
43998
+ });
43999
+ const deadCodeFiber = shouldOverlapDeadCode ? yield* forkChild(buildCollectDeadCode({
44000
+ workerTimeoutMs: overlapDeadCodeTimeout.workerTimeoutMs,
44001
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(overlapDeadCodeTimeout.phaseTimeoutMs)
44002
+ })) : null;
43266
44003
  const scanProgress = yield* progressService.start("Scanning...");
43267
44004
  const scanStartTime = Date.now();
43268
44005
  let lastReportedTotalFileCount = 0;
43269
- const lintCollected = yield* runCollect(applyPerElementPipeline(linterService.run({
44006
+ let lintCacheHitFileCount = null;
44007
+ let lintCacheTotalFileCount = null;
44008
+ const baseLintStream = linterService.run({
43270
44009
  rootDirectory: scanDirectory,
43271
44010
  project,
43272
44011
  includePaths: lintIncludePaths ?? void 0,
@@ -43280,6 +44019,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43280
44019
  onFileProgress: (scannedFileCount, totalFileCount) => {
43281
44020
  lastReportedTotalFileCount = totalFileCount;
43282
44021
  runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
44022
+ },
44023
+ onCacheStats: (cacheHitFileCount, totalConsideredFileCount) => {
44024
+ lintCacheHitFileCount = cacheHitFileCount;
44025
+ lintCacheTotalFileCount = totalConsideredFileCount;
43283
44026
  }
43284
44027
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43285
44028
  yield* set(lintFailure, {
@@ -43289,36 +44032,54 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43289
44032
  reasonKind: error.reason._tag === "OxlintUnavailable" ? error.reason.kind : null
43290
44033
  });
43291
44034
  return empty$4;
43292
- }))))));
44035
+ }))));
44036
+ const lintCollected = yield* runCollect(applyPerElementPipeline(shouldOverlapDeadCode ? baseLintStream.pipe(provideService(OxlintConcurrency, lintConcurrency)) : baseLintStream)).pipe(timeoutOption(lintPhaseTimeoutMs), flatMap$2(match$3({
44037
+ onNone: () => set(lintFailure, {
44038
+ didFail: true,
44039
+ reason: `Lint analysis exceeded ${lintPhaseTimeoutMs / MILLISECONDS_PER_SECOND}s and was skipped.`,
44040
+ reasonTag: "OxlintBatchExceeded",
44041
+ reasonKind: null
44042
+ }).pipe(as([])),
44043
+ onSome: succeed$2
44044
+ })));
43293
44045
  const lintFailureState = yield* get$2(lintFailure);
43294
44046
  yield* afterLint(lintFailureState.didFail);
43295
44047
  if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
43296
44048
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
43297
44049
  const scannedFilesLabel = `${totalFileCount} ${totalFileCount === 1 ? "file" : "files"}`;
43298
- const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
43299
- const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`).pipe(andThen(runCollect(applyPerElementPipeline(deadCodeService.run({
43300
- rootDirectory: scanDirectory,
43301
- userConfig: resolvedConfig.config
43302
- }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43303
- yield* set(deadCodeFailure, {
43304
- didFail: true,
43305
- reason: error.message
44050
+ let deadCodeCollected = [];
44051
+ if (lintFailureState.didFail) {
44052
+ if (deadCodeFiber !== null) yield* interrupt(deadCodeFiber);
44053
+ } else if (shouldRunDeadCode) {
44054
+ yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`);
44055
+ const sequentialDeadCodeTimeout = resolveDeadCodeTimeout({
44056
+ sourceFileCount: totalFileCount,
44057
+ deadCodeConcurrency: scanConcurrency,
44058
+ fullConcurrency: scanConcurrency
43306
44059
  });
43307
- return empty$4;
43308
- }))))))));
43309
- const deadCodeFailureState = yield* get$2(deadCodeFailure);
44060
+ deadCodeCollected = deadCodeFiber !== null ? yield* join(deadCodeFiber) : yield* buildCollectDeadCode({
44061
+ workerTimeoutMs: sequentialDeadCodeTimeout.workerTimeoutMs,
44062
+ phaseTimeoutMs: resolveDeadCodePhaseTimeoutMs(sequentialDeadCodeTimeout.phaseTimeoutMs)
44063
+ });
44064
+ }
44065
+ const deadCodeFailureState = lintFailureState.didFail ? {
44066
+ didFail: false,
44067
+ reason: null
44068
+ } : yield* get$2(deadCodeFailure);
43310
44069
  const scanElapsedMilliseconds = Date.now() - scanStartTime;
43311
44070
  const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1);
43312
44071
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
43313
44072
  else if (input.suppressScanSummary) yield* scanProgress.stop();
43314
44073
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
44074
+ const supplyChainResult = yield* join(supplyChainFiber);
44075
+ const supplyChainCollected = supplyChainResult.diagnostics;
43315
44076
  yield* reporterService.finalize;
43316
- const finalDiagnostics = assignFixGroups([
44077
+ const finalDiagnostics = sortDiagnosticsStable(assignFixGroups([
43317
44078
  ...envCollected,
43318
44079
  ...supplyChainCollected,
43319
44080
  ...lintCollected,
43320
44081
  ...deadCodeCollected
43321
- ]);
44082
+ ]));
43322
44083
  const githubViewerPermission = yield* join(githubViewerPermissionFiber);
43323
44084
  const scoreMetadata = {
43324
44085
  ...repo !== null ? { repo } : {},
@@ -43354,9 +44115,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43354
44115
  lintPartialFailures,
43355
44116
  didDeadCodeFail: deadCodeFailureState.didFail,
43356
44117
  deadCodeFailureReason: deadCodeFailureState.reason,
44118
+ deadCodeOverlapped: shouldOverlapDeadCode,
43357
44119
  scannedFileCount: totalFileCount,
43358
44120
  scannedFilePaths,
43359
- scanElapsedMilliseconds
44121
+ scanElapsedMilliseconds,
44122
+ scanConcurrency,
44123
+ supplyChainOverlapTimedOut: supplyChainResult.timedOut,
44124
+ lintCacheHitFileCount,
44125
+ lintCacheTotalFileCount
43360
44126
  };
43361
44127
  }).pipe(withSpan("runInspect", { attributes: {
43362
44128
  "inspect.directory": input.directory,
@@ -43364,7 +44130,7 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43364
44130
  "inspect.runDeadCode": input.runDeadCode,
43365
44131
  "inspect.isCi": input.isCi,
43366
44132
  "inspect.scoreSurface": input.scoreSurface ?? "score"
43367
- } }));
44133
+ } }), (scanProgram) => flatMap$2(ScanDeadlineMs, (scanDeadlineMs) => scanProgram.pipe(timeout(scanDeadlineMs), catchTag$1("TimeoutError", () => new ReactDoctorError({ reason: new ScanDeadlineExceeded({ detail: `${scanDeadlineMs / MILLISECONDS_PER_SECOND}s elapsed` }) })))));
43368
44134
  const parseNodeVersion = (versionString) => {
43369
44135
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
43370
44136
  return {
@@ -44142,7 +44908,7 @@ const makeNoopConsole = () => ({
44142
44908
  });
44143
44909
  //#endregion
44144
44910
  //#region src/cli/utils/version.ts
44145
- const VERSION = "0.5.7-dev.242bf69";
44911
+ const VERSION = "0.5.7-dev.2cadd3f";
44146
44912
  //#endregion
44147
44913
  //#region src/cli/utils/json-mode.ts
44148
44914
  let context = null;
@@ -44295,7 +45061,8 @@ const buildRunContext = () => {
44295
45061
  terminalKind: detectTerminalKind(),
44296
45062
  jsonMode: isJsonModeActive(),
44297
45063
  debug: isDebugFlagEnabled(),
44298
- invokedVia: detectInvokedVia()
45064
+ invokedVia: detectInvokedVia(),
45065
+ lintBatchOrdering: resolveLintBatchOrdering()
44299
45066
  };
44300
45067
  };
44301
45068
  //#endregion
@@ -44368,7 +45135,8 @@ const buildSentryScope = (runContext = buildRunContext()) => {
44368
45135
  jsonMode: runContext.jsonMode,
44369
45136
  debug: runContext.debug,
44370
45137
  invokedVia: runContext.invokedVia,
44371
- nodeMajor: runContext.nodeMajor
45138
+ nodeMajor: runContext.nodeMajor,
45139
+ lintBatchOrdering: runContext.lintBatchOrdering
44372
45140
  };
44373
45141
  const contexts = { run: { ...runContext } };
44374
45142
  const projectInfo = getSentryProjectInfo();
@@ -44504,13 +45272,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
44504
45272
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
44505
45273
  * standard `SENTRY_RELEASE` override.
44506
45274
  */
44507
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.7-dev.242bf69`;
45275
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.7-dev.2cadd3f`;
44508
45276
  /**
44509
45277
  * Deployment environment shown in Sentry's environment filter. Defaults to
44510
45278
  * `production` for tagged releases and `development` for dev/unbuilt versions,
44511
45279
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
44512
45280
  */
44513
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.7-dev.242bf69") ? "development" : "production");
45281
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.7-dev.2cadd3f") ? "development" : "production");
44514
45282
  /**
44515
45283
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
44516
45284
  * (set to `0` to disable tracing) and falls back to
@@ -44713,7 +45481,7 @@ const externalSpanFrom = (sentrySpan) => {
44713
45481
  * in-memory tracer — identical to the prior default behavior.
44714
45482
  */
44715
45483
  const applyObservability = (program, rootSentrySpan) => {
44716
- if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(provideService(ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(provide(layerOtlp));
45484
+ if (isOtlpExportConfigured()) return (rootSentrySpan ? program.pipe(provideService$2(ParentSpan, externalSpanFrom(rootSentrySpan))) : program).pipe(provide(layerOtlp));
44717
45485
  if (rootSentrySpan) return program.pipe(withTracer(makeSentryTracer(rootSentrySpan)));
44718
45486
  return program.pipe(provide(layerOtlp));
44719
45487
  };
@@ -48186,6 +48954,9 @@ const buildOutcomeAttributes = (input) => {
48186
48954
  "migration.largestRuleBucketSites": largestRuleBucket ? largestRuleBucket.siteCount : null,
48187
48955
  "migration.largestRuleBucketRule": largestRuleBucket ? largestRuleBucket.ruleKey : null,
48188
48956
  scannedFileCount: result.scannedFileCount ?? null,
48957
+ lintCacheHitFiles: result.lintCacheHitFileCount ?? null,
48958
+ lintCacheTotalFiles: result.lintCacheTotalFileCount ?? null,
48959
+ lintCacheHitRatio: result.lintCacheTotalFileCount != null && result.lintCacheTotalFileCount > 0 ? (result.lintCacheHitFileCount ?? 0) / result.lintCacheTotalFileCount : null,
48189
48960
  elapsedMs: result.elapsedMilliseconds,
48190
48961
  scanPhaseMs: result.scanElapsedMilliseconds ?? null,
48191
48962
  score: result.score ? result.score.score : null,
@@ -48195,7 +48966,10 @@ const buildOutcomeAttributes = (input) => {
48195
48966
  didLintFail: input.didLintFail ?? null,
48196
48967
  lintFailureReasonKind: input.lintFailureReasonKind ?? null,
48197
48968
  lintPartialFailureCount: input.lintPartialFailureCount ?? null,
48198
- didDeadCodeFail: input.didDeadCodeFail ?? null
48969
+ lintDroppedFileCount: input.lintDroppedFileCount ?? null,
48970
+ didDeadCodeFail: input.didDeadCodeFail ?? null,
48971
+ supplyChainOverlapTimedOut: input.supplyChainOverlapTimedOut ?? null,
48972
+ deadCodeOverlapped: input.deadCodeOverlapped ?? null
48199
48973
  };
48200
48974
  for (const [category, count] of countByCategory) attributes[`diag.category.${toCategoryKey(category)}`] = count;
48201
48975
  if (result.baselineDelta) {
@@ -48264,6 +49038,32 @@ const recordRunEvent = (rootSpan, input) => {
48264
49038
  } catch {}
48265
49039
  };
48266
49040
  //#endregion
49041
+ //#region src/cli/utils/resolve-worker-telemetry.ts
49042
+ /**
49043
+ * Projects the resolved lint worker count into the `(workerCount, parallel)`
49044
+ * telemetry pair. `resolvedWorkerCount` is the count the scan actually fanned
49045
+ * out to (`InspectOutput.scanConcurrency`); `pinnedConcurrency` is the caller's
49046
+ * `inspect({ concurrency })` pin, used as the fallback when no scan resolved a
49047
+ * count (the pre-scan failure path, or a cache entry persisted before the
49048
+ * resolved count was tracked). `parallel` is derived from the count — NOT from
49049
+ * whether a count was pinned — so the common auto path (no pin) still reports
49050
+ * parallelism correctly instead of always reading `false`.
49051
+ */
49052
+ const resolveWorkerTelemetry = (resolvedWorkerCount, pinnedConcurrency) => {
49053
+ const workerCount = resolvedWorkerCount ?? pinnedConcurrency;
49054
+ return {
49055
+ workerCount,
49056
+ parallel: workerCount !== void 0 && workerCount > 1
49057
+ };
49058
+ };
49059
+ //#endregion
49060
+ //#region src/cli/utils/count-dropped-lint-files.ts
49061
+ const DROPPED_FILES_MESSAGE_PATTERN = /^(\d+) file\(s\) failed to lint and were skipped/;
49062
+ const countDroppedLintFiles = (lintPartialFailures) => lintPartialFailures.reduce((total, message) => {
49063
+ const match = DROPPED_FILES_MESSAGE_PATTERN.exec(message);
49064
+ return match ? total + Number(match[1]) : total;
49065
+ }, 0);
49066
+ //#endregion
48267
49067
  //#region src/cli/utils/path-format.ts
48268
49068
  const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
48269
49069
  //#endregion
@@ -48880,25 +49680,182 @@ const canAnimateOnboarding = (stream = process.stdout) => {
48880
49680
  return !isGitHookEnvironment() && !isCiEnvironment();
48881
49681
  };
48882
49682
  //#endregion
48883
- //#region src/cli/utils/onboarding-state.ts
48884
- const ONBOARDED_AT_KEY = "onboardedAt";
48885
- const getOnboardingStore = (options = {}) => new Conf({
49683
+ //#region src/cli/utils/now-iso.ts
49684
+ const nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
49685
+ const ONBOARDING_EVENT = "onboarding";
49686
+ const CI_PITCH_EVENT = "ci-pitch";
49687
+ const ACTION_UPGRADE_EVENT = "action-upgrade-v2";
49688
+ const SETUP_HINT_EVENT = "setup-hint";
49689
+ const foldLegacyDecisions = (projects, legacy, eventId) => {
49690
+ for (const [hash, record] of Object.entries(legacy ?? {})) {
49691
+ const existing = projects[hash] ?? { rootDirectory: record.rootDirectory ?? "" };
49692
+ projects[hash] = {
49693
+ ...existing,
49694
+ events: {
49695
+ ...existing.events,
49696
+ [eventId]: {
49697
+ firedAt: record.at ?? nowIso(),
49698
+ version: 1,
49699
+ ...record.outcome ? { outcome: record.outcome } : {}
49700
+ }
49701
+ }
49702
+ };
49703
+ }
49704
+ };
49705
+ const migrateCliState = (state) => {
49706
+ if (state.schemaVersion === 2) return state;
49707
+ const projects = {};
49708
+ for (const [hash, record] of Object.entries(state.projects ?? {})) {
49709
+ const carried = {
49710
+ rootDirectory: record.rootDirectory,
49711
+ ...record.events ? { events: record.events } : {},
49712
+ ...record.migrations ? { migrations: record.migrations } : {}
49713
+ };
49714
+ projects[hash] = record.setupPrompt === false ? {
49715
+ ...carried,
49716
+ events: {
49717
+ ...carried.events,
49718
+ [SETUP_HINT_EVENT]: {
49719
+ firedAt: nowIso(),
49720
+ version: 1,
49721
+ outcome: "declined"
49722
+ }
49723
+ }
49724
+ } : carried;
49725
+ }
49726
+ foldLegacyDecisions(projects, state.ciPrompts, CI_PITCH_EVENT);
49727
+ foldLegacyDecisions(projects, state.actionUpgrades, ACTION_UPGRADE_EVENT);
49728
+ return {
49729
+ schemaVersion: 2,
49730
+ global: typeof state.onboardedAt === "string" ? { events: { [ONBOARDING_EVENT]: {
49731
+ firedAt: state.onboardedAt,
49732
+ version: 1
49733
+ } } } : {},
49734
+ projects
49735
+ };
49736
+ };
49737
+ const resolveConfigDir = (options) => options.cwd ?? (process.env["REACT_DOCTOR_CONFIG_DIR"] || void 0);
49738
+ const openStore = (options = {}) => new Conf({
48886
49739
  projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
48887
- cwd: options.cwd
49740
+ cwd: resolveConfigDir(options)
48888
49741
  });
48889
- const hasCompletedOnboarding = (options = {}) => {
49742
+ const openMigratedStore = (options) => {
49743
+ const store = openStore(options);
49744
+ if (store.store.schemaVersion !== 2) store.store = migrateCliState(store.store);
49745
+ return store;
49746
+ };
49747
+ const readCliState = (select, fallback, options = {}) => {
48890
49748
  try {
48891
- return typeof getOnboardingStore(options).get(ONBOARDED_AT_KEY) === "string";
49749
+ return select(openMigratedStore(options).store);
48892
49750
  } catch {
49751
+ return fallback;
49752
+ }
49753
+ };
49754
+ const updateCliState = (update, options = {}) => {
49755
+ try {
49756
+ const store = openMigratedStore(options);
49757
+ store.store = update(store.store);
48893
49758
  return true;
49759
+ } catch {
49760
+ return false;
48894
49761
  }
48895
49762
  };
49763
+ //#endregion
49764
+ //#region src/cli/utils/hash-project-root.ts
49765
+ const hashProjectRoot = (projectRoot) => createHash("sha256").update(Path.resolve(projectRoot)).digest("hex");
49766
+ //#endregion
49767
+ //#region src/cli/utils/cli-lifecycle.ts
49768
+ const versionOf = (item) => item.version ?? 1;
49769
+ const selectScope = (state, scoped, projectRoot) => scoped.scope === "global" ? state.global : projectRoot === void 0 ? void 0 : state.projects?.[hashProjectRoot(projectRoot)];
49770
+ const updateScope = (state, scoped, projectRoot, updateScopeState) => {
49771
+ if (scoped.scope === "global") return {
49772
+ ...state,
49773
+ global: updateScopeState(state.global ?? {})
49774
+ };
49775
+ if (projectRoot === void 0) return state;
49776
+ const projectKey = hashProjectRoot(projectRoot);
49777
+ const base = state.projects?.[projectKey] ?? { rootDirectory: Path.resolve(projectRoot) };
49778
+ return {
49779
+ ...state,
49780
+ projects: {
49781
+ ...state.projects,
49782
+ [projectKey]: {
49783
+ ...base,
49784
+ ...updateScopeState(base)
49785
+ }
49786
+ }
49787
+ };
49788
+ };
49789
+ const isGatePending = (gate, target = {}, options = {}) => {
49790
+ if (gate.scope === "project" && target.projectRoot === void 0) return false;
49791
+ return readCliState((state) => {
49792
+ const record = selectScope(state, gate, target.projectRoot)?.events?.[gate.id];
49793
+ return !record || record.version < versionOf(gate);
49794
+ }, gate.fireWhenUnknown ?? false, options);
49795
+ };
49796
+ const recordGate = (gate, target = {}, options = {}) => updateCliState((state) => updateScope(state, gate, target.projectRoot, (scope) => ({
49797
+ ...scope,
49798
+ events: {
49799
+ ...scope.events,
49800
+ [gate.id]: {
49801
+ firedAt: nowIso(),
49802
+ version: versionOf(gate),
49803
+ ...target.outcome ? { outcome: target.outcome } : {}
49804
+ }
49805
+ }
49806
+ })), options);
49807
+ const isMigrationPending = (migration, target = {}, options = {}) => {
49808
+ if (migration.scope === "project" && target.projectRoot === void 0) return false;
49809
+ return readCliState((state) => {
49810
+ const record = selectScope(state, migration, target.projectRoot)?.migrations?.[migration.id];
49811
+ return !record || record.version < versionOf(migration);
49812
+ }, false, options);
49813
+ };
49814
+ const recordMigration = (migration, projectRoot, record, options) => updateCliState((state) => updateScope(state, migration, projectRoot, (scope) => ({
49815
+ ...scope,
49816
+ migrations: {
49817
+ ...scope.migrations,
49818
+ [migration.id]: record
49819
+ }
49820
+ })), options);
49821
+ const runMigrations = async (migrations, target = {}, options = {}) => {
49822
+ const results = [];
49823
+ for (const migration of migrations) {
49824
+ if (!isMigrationPending(migration, target, options)) {
49825
+ results.push({
49826
+ id: migration.id,
49827
+ ran: false,
49828
+ applied: true
49829
+ });
49830
+ continue;
49831
+ }
49832
+ let applied = false;
49833
+ try {
49834
+ applied = await migration.run({ projectRoot: target.projectRoot });
49835
+ } catch {
49836
+ applied = false;
49837
+ }
49838
+ if (applied) recordMigration(migration, target.projectRoot, {
49839
+ ranAt: nowIso(),
49840
+ version: versionOf(migration)
49841
+ }, options);
49842
+ results.push({
49843
+ id: migration.id,
49844
+ ran: true,
49845
+ applied
49846
+ });
49847
+ }
49848
+ return results;
49849
+ };
49850
+ //#endregion
49851
+ //#region src/cli/utils/onboarding-state.ts
49852
+ const ONBOARDING_GATE = {
49853
+ id: ONBOARDING_EVENT,
49854
+ scope: "global"
49855
+ };
49856
+ const hasCompletedOnboarding = (options = {}) => !isGatePending(ONBOARDING_GATE, {}, options);
48896
49857
  const markOnboardingComplete = (options = {}) => {
48897
- try {
48898
- const store = getOnboardingStore(options);
48899
- if (typeof store.get(ONBOARDED_AT_KEY) === "string") return;
48900
- store.set(ONBOARDED_AT_KEY, (/* @__PURE__ */ new Date()).toISOString());
48901
- } catch {}
49858
+ if (isGatePending(ONBOARDING_GATE, {}, options)) recordGate(ONBOARDING_GATE, {}, options);
48902
49859
  };
48903
49860
  //#endregion
48904
49861
  //#region src/cli/utils/render-project-detection.ts
@@ -49473,12 +50430,12 @@ const resolveCacheFilePath = (projectDirectory) => {
49473
50430
  const readPersistedCache = (cacheFilePath) => {
49474
50431
  try {
49475
50432
  const parsed = JSON.parse(fs.readFileSync(cacheFilePath, "utf8"));
49476
- if (!isRecord(parsed) || parsed.version !== 1) return {
49477
- version: 1,
50433
+ if (!isRecord(parsed) || parsed.version !== 2) return {
50434
+ version: 2,
49478
50435
  entries: []
49479
50436
  };
49480
50437
  if (!Array.isArray(parsed.entries)) return {
49481
- version: 1,
50438
+ version: 2,
49482
50439
  entries: []
49483
50440
  };
49484
50441
  const entries = [];
@@ -49488,12 +50445,12 @@ const readPersistedCache = (cacheFilePath) => {
49488
50445
  entries.push(entry);
49489
50446
  }
49490
50447
  return {
49491
- version: 1,
50448
+ version: 2,
49492
50449
  entries
49493
50450
  };
49494
50451
  } catch {
49495
50452
  return {
49496
- version: 1,
50453
+ version: 2,
49497
50454
  entries: []
49498
50455
  };
49499
50456
  }
@@ -49546,7 +50503,7 @@ const buildScanResultCacheKey = (input) => {
49546
50503
  if (headSha === null) return null;
49547
50504
  if (stringifyStableJson(input.userConfig) === null) return null;
49548
50505
  const cacheKeyJson = stringifyStableJson({
49549
- schemaVersion: 1,
50506
+ schemaVersion: 2,
49550
50507
  projectIdentity: resolveProjectIdentity(input.projectDirectory),
49551
50508
  headSha,
49552
50509
  reactDoctorVersion: input.version,
@@ -49566,6 +50523,7 @@ const buildScanResultCacheKey = (input) => {
49566
50523
  adoptExistingLintConfig: input.options.adoptExistingLintConfig,
49567
50524
  ignoredTags: [...input.options.ignoredTags].sort(),
49568
50525
  concurrency: input.options.concurrency,
50526
+ lintBatchOrdering: resolveLintBatchOrdering(),
49569
50527
  baselineRef: input.options.baseline?.ref,
49570
50528
  changedLineRanges: input.options.changedLineRanges ?? void 0,
49571
50529
  noScore: input.options.noScore,
@@ -49583,7 +50541,7 @@ const createScanResultCache = (projectDirectory) => {
49583
50541
  for (const entry of persistedCache.entries) entries.set(entry.key, entry);
49584
50542
  const persist = () => {
49585
50543
  writePersistedCache(cacheFilePath, {
49586
- version: 1,
50544
+ version: 2,
49587
50545
  entries: [...entries.values()].sort((firstEntry, secondEntry) => secondEntry.createdAtMs - firstEntry.createdAtMs).slice(0, 20)
49588
50546
  });
49589
50547
  };
@@ -49599,7 +50557,7 @@ const createScanResultCache = (projectDirectory) => {
49599
50557
  }
49600
50558
  };
49601
50559
  };
49602
- const shouldStoreScanPayload = (payload) => !payload.didLintFail && !payload.didDeadCodeFail && payload.lintPartialFailures.length === 0;
50560
+ const shouldStoreScanPayload = (payload) => !payload.didLintFail && !payload.didDeadCodeFail && payload.lintPartialFailures.length === 0 && !payload.supplyChainOverlapTimedOut;
49603
50561
  //#endregion
49604
50562
  //#region src/inspect.ts
49605
50563
  const silentConsole = makeNoopConsole();
@@ -49663,21 +50621,24 @@ const deriveScope = (options) => {
49663
50621
  if (options.changedLineRanges !== null) return "lines";
49664
50622
  return options.includePaths.length > 0 ? "files" : "full";
49665
50623
  };
49666
- const buildRunEventConfig = (options, userConfig, hasCustomConfig) => ({
49667
- scope: deriveScope(options),
49668
- parallel: options.concurrency !== void 0,
49669
- workerCount: options.concurrency,
49670
- lint: options.lint,
49671
- deadCode: options.deadCode,
49672
- scoreOnly: options.scoreOnly,
49673
- noScore: options.noScore,
49674
- respectInlineDisables: options.respectInlineDisables,
49675
- showWarnings: options.warnings,
49676
- usedOutputDir: options.outputDirectory !== null,
49677
- ignoredTagCount: options.ignoredTags.size,
49678
- hasCustomConfig,
49679
- userConfig
49680
- });
50624
+ const buildRunEventConfig = (options, userConfig, hasCustomConfig, resolvedWorkerCount) => {
50625
+ const { workerCount, parallel } = resolveWorkerTelemetry(resolvedWorkerCount, options.concurrency);
50626
+ return {
50627
+ scope: deriveScope(options),
50628
+ parallel,
50629
+ workerCount,
50630
+ lint: options.lint,
50631
+ deadCode: options.deadCode,
50632
+ scoreOnly: options.scoreOnly,
50633
+ noScore: options.noScore,
50634
+ respectInlineDisables: options.respectInlineDisables,
50635
+ showWarnings: options.warnings,
50636
+ usedOutputDir: options.outputDirectory !== null,
50637
+ ignoredTagCount: options.ignoredTags.size,
50638
+ hasCustomConfig,
50639
+ userConfig
50640
+ };
50641
+ };
49681
50642
  const inspect = async (directory, inputOptions = {}) => {
49682
50643
  const startTime = performance$1.now();
49683
50644
  const isConcurrentScan = inputOptions.concurrentScan === true;
@@ -49769,7 +50730,7 @@ const runBaselineComparison = async (params) => {
49769
50730
  resolveLocalGithubViewerPermission: false,
49770
50731
  suppressScanSummary: true,
49771
50732
  supplyChainManifestChanged: params.options.supplyChainManifestChanged
49772
- }, {}).pipe(provide(baseLayers), provideService(Console, silentConsole))));
50733
+ }, {}).pipe(provide(baseLayers), provideService$2(Console, silentConsole))));
49773
50734
  if (baseOutput.didLintFail) return null;
49774
50735
  const delta = computeDiagnosticDelta({
49775
50736
  headDiagnostics: params.headDiagnostics,
@@ -49854,7 +50815,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49854
50815
  runId: getRunId(),
49855
50816
  resolveLocalGithubViewerPermission: !options.noScore,
49856
50817
  suppressScanSummary: options.suppressRendering,
49857
- supplyChainManifestChanged: options.supplyChainManifestChanged
50818
+ supplyChainManifestChanged: options.supplyChainManifestChanged,
50819
+ concurrentScan: options.concurrentScan
49858
50820
  }, { beforeLint: (projectInfo, lintIncludePaths) => gen(function* () {
49859
50821
  recordSentryProjectContext(projectInfo, rootSentrySpan, { concurrentScan: options.concurrentScan });
49860
50822
  recordCount(METRIC.projectDetected, 1);
@@ -49868,7 +50830,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49868
50830
  lintSourceFileCount
49869
50831
  });
49870
50832
  }) });
49871
- const output = await runPromise(restoreLegacyThrow(applyObservability(options.silent ? program.pipe(provide(layers), provideService(Console, silentConsole)) : program.pipe(provide(layers)), rootSentrySpan)));
50833
+ const output = await runPromise(restoreLegacyThrow(applyObservability(options.silent ? program.pipe(provide(layers), provideService$2(Console, silentConsole)) : program.pipe(provide(layers)), rootSentrySpan)));
49872
50834
  const didLintFail = lintBindingMissing || output.didLintFail;
49873
50835
  const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
49874
50836
  if (!options.scoreOnly && !lintBindingMissing && output.didLintFail && lintFailureReason !== null) if (output.lintFailureReasonKind === "native-binding-missing") runConsole(log(highlighter.gray(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`)));
@@ -49905,12 +50867,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49905
50867
  lintPartialFailures: output.lintPartialFailures,
49906
50868
  didDeadCodeFail: output.didDeadCodeFail,
49907
50869
  deadCodeFailureReason: output.deadCodeFailureReason,
50870
+ deadCodeOverlapped: output.deadCodeOverlapped,
49908
50871
  directory: output.resolvedDirectory,
49909
50872
  scannedFileCount: output.scannedFileCount,
49910
50873
  scannedFilePaths: output.scannedFilePaths,
49911
50874
  scanElapsedMilliseconds: output.scanElapsedMilliseconds,
50875
+ scanConcurrency: output.scanConcurrency,
49912
50876
  baselineDelta,
49913
- lintFailureReasonKind: lintBindingMissing ? "native-binding-missing" : output.lintFailureReasonKind
50877
+ lintFailureReasonKind: lintBindingMissing ? "native-binding-missing" : output.lintFailureReasonKind,
50878
+ supplyChainOverlapTimedOut: output.supplyChainOverlapTimedOut
49914
50879
  };
49915
50880
  if (cacheKey !== null && scanResultCache !== null && shouldStoreScanPayload(payload)) scanResultCache.store(cacheKey, payload);
49916
50881
  const result = await renderAndRecordScan({
@@ -49921,12 +50886,14 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49921
50886
  startTime,
49922
50887
  rootSentrySpan,
49923
50888
  scanMode: baselineDelta ? "baseline" : isDiffMode ? "diff" : "full",
49924
- baselineDegraded
50889
+ baselineDegraded,
50890
+ lintCacheHitFileCount: output.lintCacheHitFileCount,
50891
+ lintCacheTotalFileCount: output.lintCacheTotalFileCount
49925
50892
  });
49926
50893
  recordOnboardingCompletion(options);
49927
50894
  return result;
49928
50895
  };
49929
- const runMaybeSilent = (effect, silent) => silent ? effect.pipe(provideService(Console, silentConsole)) : effect;
50896
+ const runMaybeSilent = (effect, silent) => silent ? effect.pipe(provideService$2(Console, silentConsole)) : effect;
49930
50897
  const renderCachedProjectDetection = async (input) => {
49931
50898
  if (input.options.scoreOnly || input.options.suppressRendering) return;
49932
50899
  await runPromise(runMaybeSilent(printProjectDetection({
@@ -49954,14 +50921,17 @@ const renderAndRecordScan = async (input) => {
49954
50921
  scannedFileCount: input.payload.scannedFileCount,
49955
50922
  scannedFilePaths: input.payload.scannedFilePaths,
49956
50923
  scanElapsedMilliseconds: input.payload.scanElapsedMilliseconds,
50924
+ lintCacheHitFileCount: input.lintCacheHitFileCount ?? null,
50925
+ lintCacheTotalFileCount: input.lintCacheTotalFileCount ?? null,
49957
50926
  baselineDelta: input.payload.baselineDelta
49958
50927
  }), input.options.silent));
50928
+ const { workerCount: resolvedWorkerCount, parallel } = resolveWorkerTelemetry(input.payload.scanConcurrency, input.options.concurrency);
49959
50929
  recordScanMetrics({
49960
50930
  result,
49961
50931
  mode: input.scanMode,
49962
50932
  baselineDegraded: input.baselineDegraded,
49963
- parallel: input.options.concurrency !== void 0,
49964
- workerCount: input.options.concurrency,
50933
+ parallel,
50934
+ workerCount: resolvedWorkerCount,
49965
50935
  lint: input.options.lint,
49966
50936
  deadCode: input.options.deadCode,
49967
50937
  scoreOnly: input.options.scoreOnly,
@@ -49971,19 +50941,22 @@ const renderAndRecordScan = async (input) => {
49971
50941
  didDeadCodeFail: input.payload.didDeadCodeFail
49972
50942
  });
49973
50943
  recordRunEvent(input.rootSentrySpan, {
49974
- ...buildRunEventConfig(input.options, input.userConfig, input.hasCustomConfig),
50944
+ ...buildRunEventConfig(input.options, input.userConfig, input.hasCustomConfig, resolvedWorkerCount),
49975
50945
  result,
49976
50946
  mode: input.scanMode,
49977
50947
  gateExempt: input.baselineDegraded,
49978
50948
  didLintFail: input.payload.didLintFail,
49979
50949
  lintFailureReasonKind: input.payload.lintFailureReasonKind,
49980
50950
  lintPartialFailureCount: input.payload.lintPartialFailures.length,
49981
- didDeadCodeFail: input.payload.didDeadCodeFail
50951
+ lintDroppedFileCount: countDroppedLintFiles(input.payload.lintPartialFailures),
50952
+ didDeadCodeFail: input.payload.didDeadCodeFail,
50953
+ supplyChainOverlapTimedOut: input.payload.supplyChainOverlapTimedOut,
50954
+ deadCodeOverlapped: input.payload.deadCodeOverlapped
49982
50955
  });
49983
50956
  return result;
49984
50957
  };
49985
50958
  const finalizeAndRender = (input) => gen(function* () {
49986
- const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds, baselineDelta } = input;
50959
+ const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds, lintCacheHitFileCount, lintCacheTotalFileCount, baselineDelta } = input;
49987
50960
  const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
49988
50961
  didLintFail,
49989
50962
  lintFailureReason,
@@ -50003,6 +50976,10 @@ const finalizeAndRender = (input) => gen(function* () {
50003
50976
  scannedFileCount,
50004
50977
  scannedFilePaths,
50005
50978
  scanElapsedMilliseconds,
50979
+ ...lintCacheTotalFileCount !== null ? {
50980
+ lintCacheHitFileCount,
50981
+ lintCacheTotalFileCount
50982
+ } : {},
50006
50983
  ...baselineDelta ? { baselineDelta } : {}
50007
50984
  });
50008
50985
  if (options.suppressRendering) return buildResult();
@@ -50542,54 +51519,27 @@ const upgradeReactDoctorWorkflowInPlace = (projectRoot) => {
50542
51519
  }
50543
51520
  };
50544
51521
  //#endregion
50545
- //#region src/cli/utils/hash-project-root.ts
50546
- const hashProjectRoot = (projectRoot) => createHash("sha256").update(Path.resolve(projectRoot)).digest("hex");
50547
- //#endregion
50548
- //#region src/cli/utils/project-decision-store.ts
50549
- const createProjectDecisionStore = (storeKey) => {
50550
- const getStore = (options = {}) => new Conf({
50551
- projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
50552
- cwd: options.cwd
50553
- });
50554
- return {
50555
- getConfigPath: (options = {}) => getStore(options).path,
50556
- hasHandled: (projectRoot, options = {}) => {
50557
- try {
50558
- return Boolean(getStore(options).get(storeKey, {})[hashProjectRoot(projectRoot)]);
50559
- } catch {
50560
- return true;
50561
- }
50562
- },
50563
- record: (projectRoot, outcome, options = {}) => {
50564
- try {
50565
- const store = getStore(options);
50566
- store.set(storeKey, {
50567
- ...store.get(storeKey, {}),
50568
- [hashProjectRoot(projectRoot)]: {
50569
- rootDirectory: Path.resolve(projectRoot),
50570
- outcome,
50571
- at: (/* @__PURE__ */ new Date()).toISOString()
50572
- }
50573
- });
50574
- return true;
50575
- } catch {
50576
- return false;
50577
- }
50578
- }
50579
- };
50580
- };
50581
- //#endregion
50582
51522
  //#region src/cli/utils/action-upgrade-prompt.ts
50583
- const store$1 = createProjectDecisionStore("actionUpgrades");
50584
- store$1.getConfigPath;
50585
- const hasHandledActionUpgrade = store$1.hasHandled;
50586
- const recordActionUpgradeDecision = store$1.record;
51523
+ const ACTION_UPGRADE_GATE = {
51524
+ id: ACTION_UPGRADE_EVENT,
51525
+ scope: "project"
51526
+ };
51527
+ const hasHandledActionUpgrade = (projectRoot, options = {}) => !isGatePending(ACTION_UPGRADE_GATE, { projectRoot }, options);
51528
+ const recordActionUpgradeDecision = (projectRoot, outcome, options = {}) => recordGate(ACTION_UPGRADE_GATE, {
51529
+ projectRoot,
51530
+ outcome
51531
+ }, options);
50587
51532
  //#endregion
50588
51533
  //#region src/cli/utils/ci-prompt-decision.ts
50589
- const store = createProjectDecisionStore("ciPrompts");
50590
- store.getConfigPath;
50591
- const hasHandledCiPrompt = store.hasHandled;
50592
- const recordCiPromptDecision = store.record;
51534
+ const CI_PITCH_GATE = {
51535
+ id: CI_PITCH_EVENT,
51536
+ scope: "project"
51537
+ };
51538
+ const hasHandledCiPrompt = (projectRoot, options = {}) => !isGatePending(CI_PITCH_GATE, { projectRoot }, options);
51539
+ const recordCiPromptDecision = (projectRoot, outcome, options = {}) => recordGate(CI_PITCH_GATE, {
51540
+ projectRoot,
51541
+ outcome
51542
+ }, options);
50593
51543
  //#endregion
50594
51544
  //#region src/cli/utils/open-url.ts
50595
51545
  const resolveOpenCommand = (url) => {
@@ -50755,39 +51705,80 @@ const DEFAULT_PR_TITLE = "Add React Doctor to GitHub Actions";
50755
51705
  const DEFAULT_PR_BODY = `Adds a [React Doctor](https://www.react.doctor) scan to every pull request and every push to the default branch. The workflow file is documented inline.
50756
51706
 
50757
51707
  Docs: https://www.react.doctor/ci`;
50758
- const findUniqueBranchName = async (cwd) => {
50759
- if (!(await runCommand("git", [
51708
+ const findUniqueBranchName = async (cwd, run) => {
51709
+ if (!(await run("git", [
50760
51710
  "rev-parse",
50761
51711
  "--verify",
50762
51712
  NEW_BRANCH_PREFIX
50763
51713
  ], cwd)).success) return NEW_BRANCH_PREFIX;
50764
51714
  return `${NEW_BRANCH_PREFIX}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace(/[-:T]/g, "")}`;
50765
51715
  };
51716
+ const findExistingSetupPullRequest = async (cwd, run) => {
51717
+ const prList = await run("gh", [
51718
+ "pr",
51719
+ "list",
51720
+ "--state",
51721
+ "open",
51722
+ "--json",
51723
+ "headRefName,url",
51724
+ "--limit",
51725
+ String(100)
51726
+ ], cwd);
51727
+ if (!prList.success) return null;
51728
+ try {
51729
+ return JSON.parse(prList.stdout).find((pullRequest) => (pullRequest.headRefName ?? "").startsWith(NEW_BRANCH_PREFIX)) ?? null;
51730
+ } catch {
51731
+ return null;
51732
+ }
51733
+ };
51734
+ const hasUnrelatedTrackedChanges = async (cwd, workflowRelative, run) => {
51735
+ const statusProbe = await run("git", [
51736
+ "status",
51737
+ "--porcelain",
51738
+ "--",
51739
+ ".",
51740
+ `:!${workflowRelative}`
51741
+ ], cwd);
51742
+ if (!statusProbe.success) return true;
51743
+ return statusProbe.stdout.split(/\r?\n/).filter(Boolean).some((statusLine) => !statusLine.startsWith("??"));
51744
+ };
50766
51745
  const openWorkflowPullRequest = async (params) => {
50767
51746
  const workflowPath = Path.resolve(params.workflowPath);
50768
51747
  const commitMessage = params.commitMessage ?? DEFAULT_COMMIT_MESSAGE;
50769
51748
  const prTitle = params.prTitle ?? DEFAULT_PR_TITLE;
50770
51749
  const prBody = params.prBody ?? DEFAULT_PR_BODY;
50771
- const repoRootProbe = await runCommand("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
51750
+ const run = params.run ?? runCommand;
51751
+ const checkCommandAvailable = params.checkCommandAvailable ?? isCommandAvailable;
51752
+ const repoRootProbe = await run("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
50772
51753
  if (!repoRootProbe.success) return {
50773
51754
  status: "not-attempted",
50774
51755
  reason: "not-a-git-repo"
50775
51756
  };
50776
51757
  const cwd = repoRootProbe.stdout;
50777
- if (!isCommandAvailable("gh")) return {
51758
+ const workflowRelative = toForwardSlashes(Path.relative(cwd, workflowPath));
51759
+ if (!checkCommandAvailable("gh")) return {
50778
51760
  status: "not-attempted",
50779
51761
  reason: "gh-not-installed"
50780
51762
  };
50781
- if (!(await runCommand("gh", ["auth", "status"], cwd)).success) return {
51763
+ if (!(await run("gh", ["auth", "status"], cwd)).success) return {
50782
51764
  status: "not-attempted",
50783
51765
  reason: "gh-not-authenticated"
50784
51766
  };
50785
- const defaultBranch = params.baseBranch ?? await detectDefaultBranch(cwd);
51767
+ const existingSetupPullRequest = await findExistingSetupPullRequest(cwd, run);
51768
+ if (existingSetupPullRequest) return {
51769
+ status: "pr-exists",
51770
+ url: existingSetupPullRequest.url ?? ""
51771
+ };
51772
+ if (await hasUnrelatedTrackedChanges(cwd, workflowRelative, run)) return {
51773
+ status: "not-attempted",
51774
+ reason: "working-tree-dirty"
51775
+ };
51776
+ const defaultBranch = params.baseBranch ?? await detectDefaultBranch(cwd, run);
50786
51777
  if (!defaultBranch) return {
50787
51778
  status: "not-attempted",
50788
51779
  reason: "no-default-branch"
50789
51780
  };
50790
- const previousBranchProbe = await runCommand("git", [
51781
+ const previousBranchProbe = await run("git", [
50791
51782
  "rev-parse",
50792
51783
  "--abbrev-ref",
50793
51784
  "HEAD"
@@ -50797,13 +51788,13 @@ const openWorkflowPullRequest = async (params) => {
50797
51788
  reason: "detached-head"
50798
51789
  };
50799
51790
  const previousBranch = previousBranchProbe.stdout;
50800
- await runCommand("git", [
51791
+ await run("git", [
50801
51792
  "fetch",
50802
51793
  "origin",
50803
51794
  defaultBranch
50804
51795
  ], cwd);
50805
- const newBranch = await findUniqueBranchName(cwd);
50806
- if (!(await runCommand("git", [
51796
+ const newBranch = await findUniqueBranchName(cwd, run);
51797
+ if (!(await run("git", [
50807
51798
  "checkout",
50808
51799
  "-b",
50809
51800
  newBranch,
@@ -50813,17 +51804,17 @@ const openWorkflowPullRequest = async (params) => {
50813
51804
  reason: "checkout-failed"
50814
51805
  };
50815
51806
  const restoreToPreviousBranch = async (deleteNewBranch) => {
50816
- await runCommand("git", ["checkout", previousBranch], cwd);
50817
- if (deleteNewBranch) await runCommand("git", [
51807
+ await run("git", ["checkout", previousBranch], cwd);
51808
+ if (deleteNewBranch) await run("git", [
50818
51809
  "branch",
50819
51810
  "-D",
50820
51811
  newBranch
50821
51812
  ], cwd);
50822
51813
  };
50823
- if (!(await runCommand("git", [
51814
+ if (!(await run("git", [
50824
51815
  "add",
50825
51816
  "--",
50826
- Path.relative(cwd, workflowPath)
51817
+ workflowRelative
50827
51818
  ], cwd)).success) {
50828
51819
  await restoreToPreviousBranch(true);
50829
51820
  return {
@@ -50831,7 +51822,7 @@ const openWorkflowPullRequest = async (params) => {
50831
51822
  reason: "git-add-failed"
50832
51823
  };
50833
51824
  }
50834
- if (!(await runCommand("git", [
51825
+ if (!(await run("git", [
50835
51826
  "commit",
50836
51827
  "-m",
50837
51828
  commitMessage
@@ -50842,7 +51833,7 @@ const openWorkflowPullRequest = async (params) => {
50842
51833
  reason: "git-commit-failed"
50843
51834
  };
50844
51835
  }
50845
- if (!(await runCommand("git", [
51836
+ if (!(await run("git", [
50846
51837
  "push",
50847
51838
  "-u",
50848
51839
  "origin",
@@ -50854,7 +51845,7 @@ const openWorkflowPullRequest = async (params) => {
50854
51845
  reason: "git-push-failed"
50855
51846
  };
50856
51847
  }
50857
- const prCreate = await runCommand("gh", [
51848
+ const prCreate = await run("gh", [
50858
51849
  "pr",
50859
51850
  "create",
50860
51851
  "--title",
@@ -50878,12 +51869,13 @@ const openWorkflowPullRequest = async (params) => {
50878
51869
  };
50879
51870
  const stageWorkflowFile = async (params) => {
50880
51871
  const workflowPath = Path.resolve(params.workflowPath);
50881
- const repoRootProbe = await runCommand("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
51872
+ const run = params.run ?? runCommand;
51873
+ const repoRootProbe = await run("git", ["rev-parse", "--show-toplevel"], Path.dirname(workflowPath));
50882
51874
  if (!repoRootProbe.success) return false;
50883
- return (await runCommand("git", [
51875
+ return (await run("git", [
50884
51876
  "add",
50885
51877
  "--",
50886
- Path.relative(repoRootProbe.stdout, workflowPath)
51878
+ toForwardSlashes(Path.relative(repoRootProbe.stdout, workflowPath))
50887
51879
  ], repoRootProbe.stdout)).success;
50888
51880
  };
50889
51881
  //#endregion
@@ -50924,6 +51916,7 @@ const setUpGitHubActions = async (options) => {
50924
51916
  baseBranch: defaultBranch
50925
51917
  });
50926
51918
  if (pullRequestResult.status === "pr-opened") pullRequestSpinner.succeed(`Opened pull request for review: ${highlighter.info(pullRequestResult.url)}`);
51919
+ else if (pullRequestResult.status === "pr-exists") pullRequestSpinner.succeed(`A React Doctor setup pull request is already open: ${highlighter.info(pullRequestResult.url)}`);
50927
51920
  else if (pullRequestResult.status === "branch-pushed") pullRequestSpinner.warn(`Pushed branch ${highlighter.bold(pullRequestResult.branch)} but couldn't open a PR. Open one with: gh pr create --head ${pullRequestResult.branch}`);
50928
51921
  else {
50929
51922
  pullRequestSpinner.stop();
@@ -52024,7 +53017,12 @@ const upgradeGitHubActionsWorkflow = async (workflow) => {
52024
53017
  prBody: UPGRADE_PR_BODY
52025
53018
  });
52026
53019
  if (pullRequestResult.status === "pr-opened") upgradeSpinner.succeed(`Opened pull request for review: ${highlighter.info(pullRequestResult.url)}`);
52027
- else if (pullRequestResult.status === "branch-pushed") upgradeSpinner.warn(`Pushed branch ${highlighter.bold(pullRequestResult.branch)} but couldn't open a PR. Open one with: gh pr create --head ${pullRequestResult.branch}`);
53020
+ else if (pullRequestResult.status === "pr-exists") {
53021
+ try {
53022
+ NFS.writeFileSync(workflow.workflowPath, workflow.content);
53023
+ } catch {}
53024
+ upgradeSpinner.succeed(`A React Doctor pull request is already open: ${highlighter.info(pullRequestResult.url)}`);
53025
+ } else if (pullRequestResult.status === "branch-pushed") upgradeSpinner.warn(`Pushed branch ${highlighter.bold(pullRequestResult.branch)} but couldn't open a PR. Open one with: gh pr create --head ${pullRequestResult.branch}`);
52028
53026
  else {
52029
53027
  upgradeSpinner.stop();
52030
53028
  try {
@@ -52138,6 +53136,47 @@ const handoffToAgent = async (input) => {
52138
53136
  }
52139
53137
  };
52140
53138
  //#endregion
53139
+ //#region src/cli/utils/migrate-action-pin.ts
53140
+ const WORKFLOWS_DIRECTORY = Path.join(".github", "workflows");
53141
+ const RECOMMENDED_ACTION_REF = "v2";
53142
+ const MUTABLE_ACTION_REF = /(uses:\s*[\w.-]+\/react-doctor@)(?:main|master)\b/g;
53143
+ const isWorkflowFile = (fileName) => /\.ya?ml$/.test(fileName);
53144
+ /**
53145
+ * Rewrites mutable `@main` / `@master` React Doctor GitHub Action references in
53146
+ * the repo's `.github/workflows/*.yml` to the recommended floating major
53147
+ * (`@v2`) — a supply-chain hardening (#299) that also moves the workflow onto
53148
+ * the current (install- and scan-cached) action release. Pinned tags / SHAs are
53149
+ * deliberate and left untouched. Returns the absolute paths of the workflow
53150
+ * files it rewrote — empty when there's nothing to migrate.
53151
+ */
53152
+ const migrateActionPin = (projectRoot) => {
53153
+ const workflowsDirectory = Path.join(projectRoot, WORKFLOWS_DIRECTORY);
53154
+ let entries;
53155
+ try {
53156
+ entries = NFS.readdirSync(workflowsDirectory, { withFileTypes: true });
53157
+ } catch {
53158
+ return [];
53159
+ }
53160
+ const rewrittenFiles = [];
53161
+ for (const entry of entries) {
53162
+ if (!entry.isFile() || !isWorkflowFile(entry.name)) continue;
53163
+ const workflowPath = Path.join(workflowsDirectory, entry.name);
53164
+ let contents;
53165
+ try {
53166
+ contents = NFS.readFileSync(workflowPath, "utf-8");
53167
+ } catch {
53168
+ continue;
53169
+ }
53170
+ const updated = contents.replace(MUTABLE_ACTION_REF, `$1${RECOMMENDED_ACTION_REF}`);
53171
+ if (updated === contents) continue;
53172
+ try {
53173
+ NFS.writeFileSync(workflowPath, updated);
53174
+ rewrittenFiles.push(workflowPath);
53175
+ } catch {}
53176
+ }
53177
+ return rewrittenFiles;
53178
+ };
53179
+ //#endregion
52141
53180
  //#region src/cli/utils/read-object-file.ts
52142
53181
  /**
52143
53182
  * Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
@@ -52202,6 +53241,35 @@ export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
52202
53241
  NFS.rmSync(legacy.legacyFilePath, { force: true });
52203
53242
  return targetPath;
52204
53243
  };
53244
+ const PROJECT_MIGRATIONS = [{
53245
+ id: "config-json-to-ts",
53246
+ scope: "project",
53247
+ run: ({ projectRoot }) => {
53248
+ if (projectRoot === void 0) return false;
53249
+ const legacyConfig = findLegacyConfig(projectRoot);
53250
+ if (!legacyConfig) return false;
53251
+ const migratedPath = migrateLegacyConfig(legacyConfig);
53252
+ if (!migratedPath) return false;
53253
+ cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
53254
+ cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, projectRoot)} and commit it.`);
53255
+ cliLogger.break();
53256
+ return true;
53257
+ }
53258
+ }, {
53259
+ id: "action-pin-main-to-v2",
53260
+ scope: "project",
53261
+ run: ({ projectRoot }) => {
53262
+ if (projectRoot === void 0) return false;
53263
+ const rewrittenFiles = migrateActionPin(projectRoot);
53264
+ if (rewrittenFiles.length === 0) return false;
53265
+ const relativeFiles = rewrittenFiles.map((file) => toRelativePath(file, projectRoot)).join(", ");
53266
+ cliLogger.success(`Pinned the React Doctor action to @v2 in ${relativeFiles}`);
53267
+ 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.");
53268
+ cliLogger.break();
53269
+ return true;
53270
+ }
53271
+ }];
53272
+ const runProjectMigrations = (projectRoot, options = {}) => runMigrations(PROJECT_MIGRATIONS, { projectRoot }, options);
52205
53273
  //#endregion
52206
53274
  //#region src/cli/utils/print-branded-header.ts
52207
53275
  /**
@@ -52421,36 +53489,16 @@ const printMultiProjectSummary = (input) => gen(function* () {
52421
53489
  });
52422
53490
  //#endregion
52423
53491
  //#region src/cli/utils/prompt-install-setup.ts
52424
- const getSetupPromptStore = (options = {}) => new Conf({
52425
- projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
52426
- cwd: options.cwd
52427
- });
52428
- const getSetupPromptProjectKey = (projectRoot) => hashProjectRoot(projectRoot);
52429
- const hasDisabledSetupPrompt = (projectRoot, storeOptions = {}) => {
52430
- try {
52431
- return getSetupPromptStore(storeOptions).get("projects", {})[getSetupPromptProjectKey(projectRoot)]?.setupPrompt === false;
52432
- } catch {
52433
- return false;
52434
- }
52435
- };
52436
- const disableSetupPrompt = (projectRoot, storeOptions = {}) => {
52437
- try {
52438
- const store = getSetupPromptStore(storeOptions);
52439
- const projects = store.get("projects", {});
52440
- const projectKey = getSetupPromptProjectKey(projectRoot);
52441
- store.set("projects", {
52442
- ...projects,
52443
- [projectKey]: {
52444
- ...projects[projectKey] ?? {},
52445
- rootDirectory: Path.resolve(projectRoot),
52446
- setupPrompt: false
52447
- }
52448
- });
52449
- return true;
52450
- } catch {
52451
- return false;
52452
- }
52453
- };
53492
+ const SETUP_HINT_GATE = {
53493
+ id: SETUP_HINT_EVENT,
53494
+ scope: "project",
53495
+ fireWhenUnknown: true
53496
+ };
53497
+ const hasDisabledSetupPrompt = (projectRoot, options = {}) => !isGatePending(SETUP_HINT_GATE, { projectRoot }, options);
53498
+ const disableSetupPrompt = (projectRoot, options = {}) => recordGate(SETUP_HINT_GATE, {
53499
+ projectRoot,
53500
+ outcome: "declined"
53501
+ }, options);
52454
53502
  const resolveInstallSetupProjectRoot = (options) => {
52455
53503
  if (options.scanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
52456
53504
  const packageDirectories = /* @__PURE__ */ new Set();
@@ -52978,15 +54026,9 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
52978
54026
  * are left untouched — the loader still reads the legacy file as a deprecated
52979
54027
  * fallback and warns — so a scan never mutates the repo unattended.
52980
54028
  */
52981
- const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
54029
+ const maybeMigrateLegacyConfig = async (requestedDirectory, { isQuiet, isStaged }) => {
52982
54030
  if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
52983
- const legacyConfig = findLegacyConfig(requestedDirectory);
52984
- if (!legacyConfig) return;
52985
- const migratedPath = migrateLegacyConfig(legacyConfig);
52986
- if (!migratedPath) return;
52987
- cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
52988
- cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
52989
- cliLogger.break();
54031
+ await runProjectMigrations(requestedDirectory);
52990
54032
  };
52991
54033
  const inspectAction = async (directory, flags) => {
52992
54034
  const isScoreOnly = Boolean(flags.score);
@@ -53001,7 +54043,7 @@ const inspectAction = async (directory, flags) => {
53001
54043
  recordCount(METRIC.cliInvoked, 1, { command: "inspect" });
53002
54044
  try {
53003
54045
  validateModeFlags(flags);
53004
- maybeMigrateLegacyConfig(requestedDirectory, {
54046
+ await maybeMigrateLegacyConfig(requestedDirectory, {
53005
54047
  isQuiet,
53006
54048
  isStaged: Boolean(flags.staged)
53007
54049
  });
@@ -54305,4 +55347,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
54305
55347
  export {};
54306
55348
 
54307
55349
  //# sourceMappingURL=cli.js.map
54308
- //# debugId=4aa7cab0-c8a8-5513-bde6-3f7a0a1bdaa7
55350
+ //# debugId=fc85ab90-476c-5c45-bfa1-cfead22c445d