react-doctor 0.5.6 → 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]="79c9f922-1713-5894-a0aa-142d0cd9c7ba")}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,12 +9,12 @@ 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";
16
16
  import * as NodeUrl from "node:url";
17
- import { fileURLToPath } from "node:url";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { createJiti } from "jiti";
19
19
  import * as Crypto from "node:crypto";
20
20
  import crypto, { createHash, randomUUID } from "node:crypto";
@@ -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
@@ -22358,7 +22406,8 @@ var Diagnostic = class extends Class("Diagnostic")({
22358
22406
  category: String$1,
22359
22407
  fileContext: optional(Literals(["test", "story"])),
22360
22408
  suppressionHint: optional(String$1),
22361
- relatedLocations: optional(ArraySchema(DiagnosticRelatedLocation))
22409
+ relatedLocations: optional(ArraySchema(DiagnosticRelatedLocation)),
22410
+ fixGroupId: optional(String$1)
22362
22411
  }) {};
22363
22412
  const JsonReportMode = Literals([
22364
22413
  "full",
@@ -22400,6 +22449,7 @@ var JsonReportProjectEntry = class extends Class("JsonReportProjectEntry")({
22400
22449
  score: Unknown,
22401
22450
  skippedChecks: ArraySchema(String$1),
22402
22451
  skippedCheckReasons: optional(Record$1(String$1, String$1)),
22452
+ scannedFileCount: optional(Number$1),
22403
22453
  elapsedMilliseconds: Number$1
22404
22454
  }) {};
22405
22455
  /**
@@ -28269,6 +28319,14 @@ const runWith = (self, f, onHalt) => suspend$2(() => {
28269
28319
  return catchDone(flatMap$2(toTransform(self)(done$1(), scope), f), onHalt ? onHalt : succeed$2).pipe(onExit$2((exit) => close(scope, exit)));
28270
28320
  });
28271
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
+ /**
28272
28330
  * Runs a channel and applies an effect to each output element.
28273
28331
  *
28274
28332
  * **Example** (Running effects for each output)
@@ -29657,6 +29715,44 @@ const splitLines = (self) => self.channel.pipe(pipeTo(splitLines$1()), fromChann
29657
29715
  */
29658
29716
  const ensuring = /* @__PURE__ */ dual(2, (self, finalizer) => fromChannel(ensuring$1(self.channel, finalizer)));
29659
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
+ /**
29660
29756
  * Runs a stream with a sink and returns the sink result.
29661
29757
  *
29662
29758
  * **Example** (Running a stream with a sink)
@@ -33086,7 +33182,7 @@ const make$8 = /* @__PURE__ */ fnUntraced(function* (options) {
33086
33182
  const runFork = runForkWith(services);
33087
33183
  const exportInterval = max(fromInputUnsafe(options.exportInterval), zero);
33088
33184
  let disabledUntil = void 0;
33089
- 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({
33090
33186
  schedule: policy,
33091
33187
  times: 3
33092
33188
  }));
@@ -35883,16 +35979,23 @@ const isMinifiedSource = (absolutePath) => {
35883
35979
  if (fileDescriptor !== void 0) NFS.closeSync(fileDescriptor);
35884
35980
  }
35885
35981
  };
35886
- const isLargeMinifiedFile = (absolutePath) => {
35887
- let sizeBytes;
35982
+ const cachedIsLargeMinifiedByPath = /* @__PURE__ */ new Map();
35983
+ const statSourceFileSize = (absolutePath) => {
35888
35984
  try {
35889
- sizeBytes = NFS.statSync(absolutePath).size;
35985
+ return NFS.statSync(absolutePath).size;
35890
35986
  } catch {
35891
- return false;
35987
+ return null;
35892
35988
  }
35893
- if (sizeBytes < 2e4) return false;
35894
- return isMinifiedSource(absolutePath);
35895
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;
35997
+ };
35998
+ const isErrnoException = (error) => error instanceof Error && "code" in error;
35896
35999
  const IGNORABLE_READDIR_ERROR_CODES = new Set([
35897
36000
  "EACCES",
35898
36001
  "EPERM",
@@ -35902,11 +36005,7 @@ const IGNORABLE_READDIR_ERROR_CODES = new Set([
35902
36005
  "ELOOP",
35903
36006
  "ENAMETOOLONG"
35904
36007
  ]);
35905
- const isIgnorableReaddirError = (error) => {
35906
- if (typeof error !== "object" || error === null) return false;
35907
- const errorCode = error.code;
35908
- return typeof errorCode === "string" && IGNORABLE_READDIR_ERROR_CODES.has(errorCode);
35909
- };
36008
+ const isIgnorableReaddirError = (error) => isErrnoException(error) && typeof error.code === "string" && IGNORABLE_READDIR_ERROR_CODES.has(error.code);
35910
36009
  const readDirectoryEntries = (directoryPath) => {
35911
36010
  try {
35912
36011
  return NFS.readdirSync(directoryPath, { withFileTypes: true });
@@ -35953,7 +36052,7 @@ const readPackageJsonUncached = (packageJsonPath) => {
35953
36052
  return JSON.parse(NFS.readFileSync(packageJsonPath, "utf-8"));
35954
36053
  } catch (error) {
35955
36054
  if (error instanceof SyntaxError) return {};
35956
- if (error instanceof Error && "code" in error) {
36055
+ if (isErrnoException(error)) {
35957
36056
  const { code } = error;
35958
36057
  if (code === "EISDIR" || code === "EACCES" || code === "EPERM" || code === "ENOENT") return {};
35959
36058
  }
@@ -36678,17 +36777,13 @@ const isPackageJsonReactNativeAware = (packageJson) => {
36678
36777
  return false;
36679
36778
  };
36680
36779
  const hasReactNativeWorkspaceAnywhere = (rootDirectory, rootPackageJson) => someWorkspacePackageJson(rootDirectory, rootPackageJson, isPackageJsonReactNativeAware);
36681
- const getExpoDependencySpec = (packageJson) => {
36682
- const spec = packageJson.dependencies?.expo ?? packageJson.devDependencies?.expo ?? packageJson.peerDependencies?.expo ?? packageJson.optionalDependencies?.expo;
36780
+ const getDependencySpec = (packageJson, packageName) => {
36781
+ const spec = packageJson.dependencies?.[packageName] ?? packageJson.devDependencies?.[packageName] ?? packageJson.peerDependencies?.[packageName] ?? packageJson.optionalDependencies?.[packageName];
36683
36782
  return typeof spec === "string" ? spec : null;
36684
36783
  };
36685
- const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
36784
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => getDependencySpec(packageJson, "expo"));
36686
36785
  const SHOPIFY_FLASH_LIST_PACKAGE_NAME = "@shopify/flash-list";
36687
- const getShopifyFlashListDependencySpec = (packageJson) => {
36688
- const spec = packageJson.dependencies?.["@shopify/flash-list"] ?? packageJson.devDependencies?.["@shopify/flash-list"] ?? packageJson.peerDependencies?.["@shopify/flash-list"] ?? packageJson.optionalDependencies?.["@shopify/flash-list"];
36689
- return typeof spec === "string" ? spec : null;
36690
- };
36691
- const findShopifyFlashListVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getShopifyFlashListDependencySpec);
36786
+ const findShopifyFlashListVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => getDependencySpec(packageJson, SHOPIFY_FLASH_LIST_PACKAGE_NAME));
36692
36787
  const resolveCatalogBackedDependencyVersion = ({ rootDirectory, rootPackageJson, packageName, version }) => {
36693
36788
  if (version === null || !isCatalogReference(version)) return version;
36694
36789
  const catalogName = extractCatalogName(version);
@@ -36700,11 +36795,7 @@ const resolveCatalogBackedDependencyVersion = ({ rootDirectory, rootPackageJson,
36700
36795
  if (!isFile(monorepoPackageJsonPath)) return version;
36701
36796
  return resolveCatalogVersion(readPackageJson$1(monorepoPackageJsonPath), packageName, monorepoRoot, catalogName) ?? version;
36702
36797
  };
36703
- const getNextjsDependencySpec = (packageJson) => {
36704
- const spec = packageJson.dependencies?.next ?? packageJson.devDependencies?.next ?? packageJson.peerDependencies?.next ?? packageJson.optionalDependencies?.next;
36705
- return typeof spec === "string" ? spec : null;
36706
- };
36707
- const findNextjsVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getNextjsDependencySpec);
36798
+ const findNextjsVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => getDependencySpec(packageJson, "next"));
36708
36799
  const getPreactVersion = (packageJson) => {
36709
36800
  return {
36710
36801
  ...packageJson.peerDependencies,
@@ -36771,6 +36862,7 @@ const DOCS_URL = "https://react.doctor/docs";
36771
36862
  const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
36772
36863
  const FETCH_TIMEOUT_MS = 1e4;
36773
36864
  const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
36865
+ const PER_WORKER_MEM_BUDGET_BYTES = 1024 * 1024 * 1024;
36774
36866
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
36775
36867
  const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
36776
36868
  const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
@@ -36793,6 +36885,11 @@ const ES_TARGET_YEAR_BY_NAME = {
36793
36885
  esnext: 9999
36794
36886
  };
36795
36887
  /**
36888
+ * tsconfig filenames probed when resolving a project's TypeScript
36889
+ * compiler options — the root config first, then a monorepo base config.
36890
+ */
36891
+ const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
36892
+ /**
36796
36893
  * Project-config files that `StagedFiles.materialize` copies into
36797
36894
  * the temp directory alongside staged sources so oxlint resolves
36798
36895
  * `tsconfig` / `package.json` / lint configs the same way it would
@@ -36842,7 +36939,16 @@ const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
36842
36939
  const SKILL_NAME = "react-doctor";
36843
36940
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
36844
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;
36845
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;
36846
36952
  const RECOMMENDED_PNPM_MINIMUM_RELEASE_AGE_MINUTES = 10080;
36847
36953
  const REACT_SERVER_DOM_PACKAGES = [
36848
36954
  "react-server-dom-webpack",
@@ -36865,14 +36971,25 @@ const APP_ONLY_RULE_KEYS = new Set([
36865
36971
  ]);
36866
36972
  const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
36867
36973
  const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
36974
+ const ROOT_CAUSE_GROUPABLE_RULE_KEYS = new Set([
36975
+ "react-doctor/no-derived-state",
36976
+ "react-doctor/no-derived-state-effect",
36977
+ "react-doctor/no-derived-useState",
36978
+ "react-doctor/no-adjust-state-on-prop-change",
36979
+ "react-doctor/no-reset-all-state-on-prop-change"
36980
+ ]);
36868
36981
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
36869
36982
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
36870
36983
  const SOCKET_FREE_PURL_API_BASE = "https://firewall-api.socket.dev/purl";
36871
36984
  const SOCKET_PACKAGE_PAGE_BASE = "https://socket.dev/npm/package";
36872
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;
36873
36988
  const SUPPLY_CHAIN_PLUGIN = "socket";
36874
36989
  const SUPPLY_CHAIN_RULE = "low-supply-chain-score";
36875
36990
  const SUPPLY_CHAIN_CATEGORY = "Security";
36991
+ const SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS = 9e4;
36992
+ const SUPPLY_CHAIN_CACHE_SUBDIR = "supply-chain";
36876
36993
  const SUPPLY_CHAIN_IGNORED_PACKAGES = new Set(["next"]);
36877
36994
  const TSCONFIG_FILENAME = "tsconfig.json";
36878
36995
  const isRelativeExtendsValue = (extendsValue) => extendsValue.startsWith("./") || extendsValue.startsWith("../") || Path.isAbsolute(extendsValue);
@@ -37314,6 +37431,7 @@ const isTailwindAtLeast = (detected, required) => {
37314
37431
  if (detected.major !== required.major) return detected.major > required.major;
37315
37432
  return detected.minor >= required.minor;
37316
37433
  };
37434
+ const messageFromUnknown = (error) => error instanceof Error ? error.message : String(error);
37317
37435
  var InvalidGlobPatternError = class extends Error {
37318
37436
  pattern;
37319
37437
  reason;
@@ -37342,7 +37460,7 @@ const compileGlobPattern = (rawPattern) => {
37342
37460
  try {
37343
37461
  return import_picomatch.default.makeRe(normalizeGlobPattern(rawPattern), PICOMATCH_OPTIONS);
37344
37462
  } catch (caughtError) {
37345
- throw new InvalidGlobPatternError(rawPattern, caughtError instanceof Error ? caughtError.message : String(caughtError));
37463
+ throw new InvalidGlobPatternError(rawPattern, messageFromUnknown(caughtError));
37346
37464
  }
37347
37465
  };
37348
37466
  const compileGlobPatternsLenient = (patterns, onInvalid) => {
@@ -37438,115 +37556,6 @@ const buildRuleSeverityControls = (config) => {
37438
37556
  ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
37439
37557
  };
37440
37558
  };
37441
- const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
37442
- const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
37443
- const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
37444
- let stringDelimiter = null;
37445
- for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
37446
- const character = line[charIndex];
37447
- if (stringDelimiter !== null) {
37448
- if (character === "\\") {
37449
- charIndex++;
37450
- continue;
37451
- }
37452
- if (character === stringDelimiter) stringDelimiter = null;
37453
- continue;
37454
- }
37455
- if (character === "\"" || character === "'" || character === "`") {
37456
- stringDelimiter = character;
37457
- continue;
37458
- }
37459
- if (character === "/" && line[charIndex + 1] === "/") return true;
37460
- }
37461
- return false;
37462
- };
37463
- const findOpenerTagOnLine = (line) => {
37464
- for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
37465
- if (match.index === void 0) continue;
37466
- if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
37467
- }
37468
- return null;
37469
- };
37470
- const findJsxOpenerSpan = (lines, openerLineIndex) => {
37471
- const openerLine = lines[openerLineIndex];
37472
- if (openerLine === void 0) return null;
37473
- const opener = findOpenerTagOnLine(openerLine);
37474
- if (!opener) return null;
37475
- const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
37476
- let braceDepth = 0;
37477
- let innerAngleDepth = 0;
37478
- let stringDelimiter = null;
37479
- for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
37480
- const currentLine = lines[lineIndex];
37481
- const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
37482
- for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
37483
- const character = currentLine[charIndex];
37484
- if (stringDelimiter !== null) {
37485
- if (character === "\\") {
37486
- charIndex++;
37487
- continue;
37488
- }
37489
- if (character === stringDelimiter) stringDelimiter = null;
37490
- continue;
37491
- }
37492
- if (character === "\"" || character === "'" || character === "`") {
37493
- stringDelimiter = character;
37494
- continue;
37495
- }
37496
- if (character === "{") {
37497
- braceDepth++;
37498
- continue;
37499
- }
37500
- if (character === "}") {
37501
- braceDepth--;
37502
- continue;
37503
- }
37504
- if (braceDepth !== 0) continue;
37505
- if (character === "<") {
37506
- const followCharacter = currentLine[charIndex + 1];
37507
- if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
37508
- continue;
37509
- }
37510
- if (character !== ">") continue;
37511
- const previousCharacter = currentLine[charIndex - 1];
37512
- const nextCharacter = currentLine[charIndex + 1];
37513
- if (previousCharacter === "=" || nextCharacter === "=") continue;
37514
- if (innerAngleDepth > 0) {
37515
- innerAngleDepth--;
37516
- continue;
37517
- }
37518
- return lineIndex;
37519
- }
37520
- }
37521
- return null;
37522
- };
37523
- const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
37524
- for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
37525
- const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
37526
- if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
37527
- }
37528
- return null;
37529
- };
37530
- const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
37531
- const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
37532
- const collected = [];
37533
- let isStillInChain = true;
37534
- for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
37535
- const candidateLine = lines[candidateIndex];
37536
- if (candidateLine === void 0) break;
37537
- const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
37538
- if (match) {
37539
- collected.push({
37540
- commentLineIndex: candidateIndex,
37541
- ruleList: match[1],
37542
- isInChain: isStillInChain
37543
- });
37544
- continue;
37545
- }
37546
- isStillInChain = false;
37547
- }
37548
- return collected;
37549
- };
37550
37559
  const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
37551
37560
  "effect/no-adjust-state-on-prop-change": "react-doctor/no-adjust-state-on-prop-change",
37552
37561
  "effect/no-chain-state-updates": "react-doctor/no-chain-state-updates",
@@ -37671,7 +37680,13 @@ for (const [legacyRuleKey, nativeRuleKey] of Object.entries(LEGACY_RULE_KEY_TO_N
37671
37680
  }
37672
37681
  const getLegacyRuleKeysForNative = (ruleKey) => NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.get(ruleKey) ?? [];
37673
37682
  const canonicalizeRuleKey = (ruleKey) => LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey] ?? ruleKey;
37674
- const isSameRuleKey = (candidateRuleKey, targetRuleKey) => canonicalizeRuleKey(candidateRuleKey) === canonicalizeRuleKey(targetRuleKey);
37683
+ const isReactDoctorShortIdOf = (bareRuleKey, qualifiedRuleKey) => !bareRuleKey.includes("/") && qualifiedRuleKey === `react-doctor/${bareRuleKey}`;
37684
+ const isSameRuleKey = (candidateRuleKey, targetRuleKey) => {
37685
+ const canonicalCandidate = canonicalizeRuleKey(candidateRuleKey);
37686
+ const canonicalTarget = canonicalizeRuleKey(targetRuleKey);
37687
+ if (canonicalCandidate === canonicalTarget) return true;
37688
+ return isReactDoctorShortIdOf(canonicalCandidate, canonicalTarget) || isReactDoctorShortIdOf(canonicalTarget, canonicalCandidate);
37689
+ };
37675
37690
  const getEquivalentRuleKeys = (ruleKey) => {
37676
37691
  const nativeRuleKey = canonicalizeRuleKey(ruleKey);
37677
37692
  return [nativeRuleKey, ...getLegacyRuleKeysForNative(nativeRuleKey)];
@@ -37681,12 +37696,182 @@ const stripDescriptionTail = (ruleList) => {
37681
37696
  if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
37682
37697
  return ruleList.slice(0, descriptionMatch.index);
37683
37698
  };
37684
- const isRuleListedInComment = (ruleList, ruleId) => {
37699
+ const tokenizeRuleList = (ruleList) => {
37685
37700
  const trimmed = ruleList?.trim();
37686
- if (!trimmed) return true;
37701
+ if (!trimmed) return [];
37687
37702
  const ruleSection = stripDescriptionTail(trimmed).trim();
37688
- if (!ruleSection) return true;
37689
- return ruleSection.split(/[,\s]+/).some((token) => isSameRuleKey(token.trim(), ruleId));
37703
+ if (!ruleSection) return [];
37704
+ return ruleSection.split(/[,\s]+/).map((token) => token.trim()).filter(Boolean);
37705
+ };
37706
+ const FOREIGN_INLINE_DISABLE_PATTERN = /(?:\/\/|\/\*)[ \t]*(eslint|oxlint)-disable-(next-line|line)(?![\w-])([^\r\n]*)/;
37707
+ const FOREIGN_BLOCK_DISABLE_PATTERN = /\/\*[ \t]*(eslint|oxlint)-disable(?![\w-])([^*\r\n]*)/;
37708
+ const FOREIGN_BLOCK_ENABLE_PATTERN = /\/\*[ \t]*(?:eslint|oxlint)-enable(?![\w-])([^*\r\n]*)/;
37709
+ const buildHint = (tool, token, ruleId) => `oxlint matches plugin rules only by their full name, so \`${token}\` in your ${tool}-disable comment does not silence \`${ruleId}\` — change it to \`${ruleId}\`.`;
37710
+ const tokenMisnamesRule = (token, ruleId) => token !== ruleId && isSameRuleKey(token, ruleId);
37711
+ const detectInlineNearMiss = (lines, diagnosticLineIndex, ruleId) => {
37712
+ const candidates = [{
37713
+ line: lines[diagnosticLineIndex],
37714
+ requiredScope: "line"
37715
+ }, {
37716
+ line: lines[diagnosticLineIndex - 1],
37717
+ requiredScope: "next-line"
37718
+ }];
37719
+ for (const { line, requiredScope } of candidates) {
37720
+ const match = line?.match(FOREIGN_INLINE_DISABLE_PATTERN);
37721
+ if (!match) continue;
37722
+ const [, tool, scope, ruleList] = match;
37723
+ if (scope !== requiredScope) continue;
37724
+ const tokens = tokenizeRuleList(ruleList);
37725
+ if (tokens.includes(ruleId)) continue;
37726
+ for (const token of tokens) if (tokenMisnamesRule(token, ruleId)) return buildHint(tool, token, ruleId);
37727
+ }
37728
+ return null;
37729
+ };
37730
+ const detectBlockNearMiss = (lines, diagnosticLineIndex, ruleId) => {
37731
+ let openMisname = null;
37732
+ const lastLineIndex = Math.min(diagnosticLineIndex, lines.length - 1);
37733
+ for (let lineIndex = 0; lineIndex <= lastLineIndex; lineIndex++) {
37734
+ const line = lines[lineIndex];
37735
+ if (line === void 0 || !line.includes("-disable") && !line.includes("-enable")) continue;
37736
+ const disableMatch = line.match(FOREIGN_BLOCK_DISABLE_PATTERN);
37737
+ if (disableMatch) {
37738
+ const [, tool, ruleList] = disableMatch;
37739
+ const tokens = tokenizeRuleList(ruleList);
37740
+ if (tokens.includes(ruleId)) openMisname = null;
37741
+ else {
37742
+ const misnamed = tokens.find((token) => tokenMisnamesRule(token, ruleId));
37743
+ if (misnamed) openMisname = {
37744
+ tool,
37745
+ token: misnamed
37746
+ };
37747
+ }
37748
+ continue;
37749
+ }
37750
+ const enableMatch = line.match(FOREIGN_BLOCK_ENABLE_PATTERN);
37751
+ if (enableMatch) {
37752
+ const enabledRules = tokenizeRuleList(enableMatch[1]);
37753
+ if (enabledRules.length === 0 || enabledRules.some((rule) => isSameRuleKey(rule, ruleId))) openMisname = null;
37754
+ }
37755
+ }
37756
+ return openMisname ? buildHint(openMisname.tool, openMisname.token, ruleId) : null;
37757
+ };
37758
+ const detectForeignDisableNearMiss = (lines, diagnosticLineIndex, ruleId) => {
37759
+ if (!ruleId.startsWith("react-doctor/")) return null;
37760
+ return detectInlineNearMiss(lines, diagnosticLineIndex, ruleId) ?? detectBlockNearMiss(lines, diagnosticLineIndex, ruleId);
37761
+ };
37762
+ const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
37763
+ const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
37764
+ const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
37765
+ let stringDelimiter = null;
37766
+ for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
37767
+ const character = line[charIndex];
37768
+ if (stringDelimiter !== null) {
37769
+ if (character === "\\") {
37770
+ charIndex++;
37771
+ continue;
37772
+ }
37773
+ if (character === stringDelimiter) stringDelimiter = null;
37774
+ continue;
37775
+ }
37776
+ if (character === "\"" || character === "'" || character === "`") {
37777
+ stringDelimiter = character;
37778
+ continue;
37779
+ }
37780
+ if (character === "/" && line[charIndex + 1] === "/") return true;
37781
+ }
37782
+ return false;
37783
+ };
37784
+ const findOpenerTagOnLine = (line) => {
37785
+ for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
37786
+ if (match.index === void 0) continue;
37787
+ if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
37788
+ }
37789
+ return null;
37790
+ };
37791
+ const findJsxOpenerSpan = (lines, openerLineIndex) => {
37792
+ const openerLine = lines[openerLineIndex];
37793
+ if (openerLine === void 0) return null;
37794
+ const opener = findOpenerTagOnLine(openerLine);
37795
+ if (!opener) return null;
37796
+ const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
37797
+ let braceDepth = 0;
37798
+ let innerAngleDepth = 0;
37799
+ let stringDelimiter = null;
37800
+ for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
37801
+ const currentLine = lines[lineIndex];
37802
+ const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
37803
+ for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
37804
+ const character = currentLine[charIndex];
37805
+ if (stringDelimiter !== null) {
37806
+ if (character === "\\") {
37807
+ charIndex++;
37808
+ continue;
37809
+ }
37810
+ if (character === stringDelimiter) stringDelimiter = null;
37811
+ continue;
37812
+ }
37813
+ if (character === "\"" || character === "'" || character === "`") {
37814
+ stringDelimiter = character;
37815
+ continue;
37816
+ }
37817
+ if (character === "{") {
37818
+ braceDepth++;
37819
+ continue;
37820
+ }
37821
+ if (character === "}") {
37822
+ braceDepth--;
37823
+ continue;
37824
+ }
37825
+ if (braceDepth !== 0) continue;
37826
+ if (character === "<") {
37827
+ const followCharacter = currentLine[charIndex + 1];
37828
+ if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
37829
+ continue;
37830
+ }
37831
+ if (character !== ">") continue;
37832
+ const previousCharacter = currentLine[charIndex - 1];
37833
+ const nextCharacter = currentLine[charIndex + 1];
37834
+ if (previousCharacter === "=" || nextCharacter === "=") continue;
37835
+ if (innerAngleDepth > 0) {
37836
+ innerAngleDepth--;
37837
+ continue;
37838
+ }
37839
+ return lineIndex;
37840
+ }
37841
+ }
37842
+ return null;
37843
+ };
37844
+ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
37845
+ for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
37846
+ const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
37847
+ if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
37848
+ }
37849
+ return null;
37850
+ };
37851
+ const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
37852
+ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
37853
+ const collected = [];
37854
+ let isStillInChain = true;
37855
+ for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
37856
+ const candidateLine = lines[candidateIndex];
37857
+ if (candidateLine === void 0) break;
37858
+ const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
37859
+ if (match) {
37860
+ collected.push({
37861
+ commentLineIndex: candidateIndex,
37862
+ ruleList: match[1],
37863
+ isInChain: isStillInChain
37864
+ });
37865
+ continue;
37866
+ }
37867
+ isStillInChain = false;
37868
+ }
37869
+ return collected;
37870
+ };
37871
+ const isRuleListedInComment = (ruleList, ruleId) => {
37872
+ const tokens = tokenizeRuleList(ruleList);
37873
+ if (tokens.length === 0) return true;
37874
+ return tokens.some((token) => isSameRuleKey(token, ruleId));
37690
37875
  };
37691
37876
  const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
37692
37877
  const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
@@ -37730,7 +37915,7 @@ const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
37730
37915
  };
37731
37916
  return {
37732
37917
  isSuppressed: false,
37733
- nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
37918
+ nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId) ?? detectForeignDisableNearMiss(lines, diagnosticLineIndex, ruleId)
37734
37919
  };
37735
37920
  };
37736
37921
  /**
@@ -38156,6 +38341,11 @@ var OxlintBatchExceeded = class extends TaggedErrorClass()("OxlintBatchExceeded"
38156
38341
  }
38157
38342
  }
38158
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
+ };
38159
38349
  var OxlintSpawnFailed = class extends TaggedErrorClass()("OxlintSpawnFailed", { cause: Unknown }) {
38160
38350
  get message() {
38161
38351
  return `Failed to run oxlint: ${pretty(fail$6(this.cause))}`;
@@ -38219,6 +38409,7 @@ var GitBaseBranchInvalid = class extends TaggedErrorClass()("GitBaseBranchInvali
38219
38409
  const ReactDoctorErrorReason = Union([
38220
38410
  OxlintUnavailable,
38221
38411
  OxlintBatchExceeded,
38412
+ ScanDeadlineExceeded,
38222
38413
  OxlintSpawnFailed,
38223
38414
  OxlintOutputUnparseable,
38224
38415
  ConfigParseFailed,
@@ -38291,15 +38482,105 @@ const layerOtlp = unwrap$3(gen(function* () {
38291
38482
  }).pipe(provide$2(layer$9));
38292
38483
  }).pipe(orDie));
38293
38484
  /**
38294
- * Resolves a requested lint worker count to a clamped integer within
38295
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
38296
- * 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
38297
38550
  * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
38298
38551
  */
38299
38552
  const resolveScanConcurrency = (requested) => {
38300
- const desired = requested === "auto" ? os.availableParallelism() : requested;
38301
- if (!Number.isFinite(desired) || desired < 1) return 1;
38302
- 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";
38303
38584
  };
38304
38585
  /**
38305
38586
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -38307,11 +38588,38 @@ const resolveScanConcurrency = (requested) => {
38307
38588
  * microVMs without recompiling react-doctor. Tests override via
38308
38589
  * `Layer.succeed(OxlintSpawnTimeoutMs, ...)`.
38309
38590
  */
38310
- var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTimeoutMs", { defaultValue: () => {
38311
- const raw = process.env["REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS"];
38312
- 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;
38313
38621
  const parsed = Number(raw);
38314
- if (!Number.isFinite(parsed) || parsed <= 0) return OXLINT_SPAWN_TIMEOUT_MS;
38622
+ if (!Number.isFinite(parsed) || parsed <= 0) return SUPPLY_CHAIN_OVERLAP_TIMEOUT_MS;
38315
38623
  return parsed;
38316
38624
  } }) {};
38317
38625
  /**
@@ -38322,31 +38630,93 @@ var OxlintSpawnTimeoutMs = class extends Reference("react-doctor/OxlintSpawnTime
38322
38630
  */
38323
38631
  var OxlintOutputMaxBytes = class extends Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
38324
38632
  /**
38325
- * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
38326
- * to auto-detected CPU cores (parallel) so large repos scan fast out of
38327
- * the box; `spawnLintBatches` transparently falls back to a single worker
38328
- * if a parallel run exhausts system resources. The CLI's `--no-parallel`
38329
- * flag forces serial via `Layer.succeed`; the `REACT_DOCTOR_PARALLEL` env
38330
- * var seeds the default for programmatic / CI callers that never touch the
38331
- * flag parallelism is opt-OUT, so only the explicit serial values pin
38332
- * one worker:
38333
- *
38334
- * - 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
38335
38644
  * - `0` / `false` / `off` → `1` (serial)
38336
38645
  * - a positive integer → that many workers (clamped)
38337
- * - any other value → available CPU cores (clamped)
38646
+ * - any other value → memory-and-core-budgeted auto count
38338
38647
  *
38339
38648
  * The resolved value is always within
38340
- * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
38649
+ * `[MIN_SCAN_CONCURRENCY, HARD_MAX_SCAN_CONCURRENCY]`.
38341
38650
  */
38342
38651
  var OxlintConcurrency = class extends Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
38343
38652
  const raw = process.env["REACT_DOCTOR_PARALLEL"];
38344
- if (raw === void 0) return resolveScanConcurrency("auto");
38653
+ if (raw === void 0) return resolveAutoScanConcurrency();
38345
38654
  const normalized = raw.trim().toLowerCase();
38346
38655
  if (normalized === "0" || normalized === "false" || normalized === "off") return 1;
38347
38656
  const parsed = Number.parseInt(normalized, 10);
38348
38657
  if (Number.isInteger(parsed) && parsed > 0) return resolveScanConcurrency(parsed);
38349
- 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;
38350
38720
  } }) {};
38351
38721
  const DIAGNOSTIC_SURFACES = [
38352
38722
  "cli",
@@ -38520,7 +38890,6 @@ const PACKAGE_JSON_FILENAME = "package.json";
38520
38890
  const PACKAGE_JSON_CONFIG_KEY$1 = "reactDoctor";
38521
38891
  const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
38522
38892
  const jiti = createJiti(import.meta.url);
38523
- const formatError = (error) => error instanceof Error ? error.message : String(error);
38524
38893
  const importDefaultExport = async (jitiInstance, filePath) => {
38525
38894
  const imported = await jitiInstance.import(filePath);
38526
38895
  return imported?.default ?? imported;
@@ -38552,7 +38921,7 @@ const loadModuleConfig = async (filePath) => {
38552
38921
  try {
38553
38922
  return await importDefaultExport(aliasJiti, filePath);
38554
38923
  } catch (retryError) {
38555
- throw new Error(`${formatError(error)} (retry with ${SELF_PACKAGE_IMPORT_SPECIFIER} aliased to the running react-doctor package also failed: ${formatError(retryError)})`, { cause: retryError });
38924
+ throw new Error(`${messageFromUnknown(error)} (retry with ${SELF_PACKAGE_IMPORT_SPECIFIER} aliased to the running react-doctor package also failed: ${messageFromUnknown(retryError)})`, { cause: retryError });
38556
38925
  }
38557
38926
  }
38558
38927
  };
@@ -38601,7 +38970,7 @@ const loadLegacyConfig = (directory) => {
38601
38970
  }
38602
38971
  warn(`${LEGACY_CONFIG_FILENAME} must contain an object, ignoring.`);
38603
38972
  } catch (error) {
38604
- warn(`Failed to load ${LEGACY_CONFIG_FILENAME}: ${formatError(error)}`);
38973
+ warn(`Failed to load ${LEGACY_CONFIG_FILENAME}: ${messageFromUnknown(error)}`);
38605
38974
  }
38606
38975
  return {
38607
38976
  status: "invalid",
@@ -38628,7 +38997,7 @@ const loadConfigFromDirectory = async (directory) => {
38628
38997
  warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
38629
38998
  sawBrokenConfigFile = true;
38630
38999
  } catch (error) {
38631
- warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
39000
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${messageFromUnknown(error)}`);
38632
39001
  sawBrokenConfigFile = true;
38633
39002
  }
38634
39003
  }
@@ -38758,6 +39127,31 @@ const resolveScanTarget = async (requestedDirectory, options = {}) => {
38758
39127
  didRedirectViaRootDir: redirectedDirectory !== null
38759
39128
  };
38760
39129
  };
39130
+ const buildFixGroupId = (diagnostic) => createHash("sha1").update(JSON.stringify([
39131
+ diagnostic.filePath,
39132
+ `${diagnostic.plugin}/${diagnostic.rule}`,
39133
+ diagnostic.message
39134
+ ])).digest("hex").slice(0, 16);
39135
+ const isGroupableRule = (diagnostic) => ROOT_CAUSE_GROUPABLE_RULE_KEYS.has(`${diagnostic.plugin}/${diagnostic.rule}`);
39136
+ const assignFixGroups = (diagnostics) => {
39137
+ const siteCountByGroupId = /* @__PURE__ */ new Map();
39138
+ for (const diagnostic of diagnostics) {
39139
+ if (!isGroupableRule(diagnostic)) continue;
39140
+ const groupId = buildFixGroupId(diagnostic);
39141
+ siteCountByGroupId.set(groupId, (siteCountByGroupId.get(groupId) ?? 0) + 1);
39142
+ }
39143
+ return diagnostics.map((diagnostic) => {
39144
+ if (!isGroupableRule(diagnostic)) return diagnostic;
39145
+ const groupId = buildFixGroupId(diagnostic);
39146
+ if ((siteCountByGroupId.get(groupId) ?? 0) < 2) return diagnostic;
39147
+ return {
39148
+ ...diagnostic,
39149
+ fixGroupId: groupId
39150
+ };
39151
+ });
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));
38761
39155
  const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
38762
39156
  const buildExpoCheckContext = (rootDirectory, expoVersion) => {
38763
39157
  const packageJson = readPackageJson$1(Path.join(rootDirectory, "package.json"));
@@ -39264,10 +39658,15 @@ const buildHardeningDiagnostic = (input) => ({
39264
39658
  column: input.column ?? 0,
39265
39659
  category: "Security"
39266
39660
  });
39267
- const checkPnpmHardening = (rootDirectory) => {
39268
- if (!isPnpmManagedProject(rootDirectory)) return [];
39269
- const workspacePath = Path.join(rootDirectory, PNPM_WORKSPACE_FILE);
39270
- 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") : "");
39271
39670
  const diagnostics = [];
39272
39671
  if (settings.minimumReleaseAge === null) diagnostics.push(buildHardeningDiagnostic({
39273
39672
  message: "pnpm-workspace.yaml is missing `minimumReleaseAge` — newly published versions can ship malware that gets caught and unpublished within hours",
@@ -39903,7 +40302,7 @@ const readIgnoreFile = (filePath) => {
39903
40302
  try {
39904
40303
  content = NFS.readFileSync(filePath, "utf-8");
39905
40304
  } catch (error) {
39906
- const errnoCode = error?.code;
40305
+ const errnoCode = isErrnoException(error) ? error.code : void 0;
39907
40306
  if (errnoCode && errnoCode !== "ENOENT") runSync(warn$1(`Could not read ignore file ${filePath}: ${errnoCode}`));
39908
40307
  return [];
39909
40308
  }
@@ -39941,8 +40340,8 @@ const collectIgnorePatterns = (rootDirectory) => {
39941
40340
  cachedPatternsByRoot.set(rootDirectory, patterns);
39942
40341
  return patterns;
39943
40342
  };
40343
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
39944
40344
  const KNIP_JSON_FILENAME = "knip.json";
39945
- const isRecord$1$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
39946
40345
  const readJsonFileSafe = (filePath) => {
39947
40346
  let rawContents;
39948
40347
  try {
@@ -39958,10 +40357,10 @@ const readJsonFileSafe = (filePath) => {
39958
40357
  };
39959
40358
  const readKnipConfig = (rootDirectory) => {
39960
40359
  const knipJson = readJsonFileSafe(path.join(rootDirectory, KNIP_JSON_FILENAME));
39961
- if (isRecord$1$1(knipJson)) return knipJson;
40360
+ if (isRecord$2(knipJson)) return knipJson;
39962
40361
  const packageJson = readJsonFileSafe(path.join(rootDirectory, "package.json"));
39963
- const packageKnipConfig = isRecord$1$1(packageJson) ? packageJson.knip : null;
39964
- return isRecord$1$1(packageKnipConfig) ? packageKnipConfig : null;
40362
+ const packageKnipConfig = isRecord$2(packageJson) ? packageJson.knip : null;
40363
+ return isRecord$2(packageKnipConfig) ? packageKnipConfig : null;
39965
40364
  };
39966
40365
  const normalizePatternList = (value) => {
39967
40366
  if (typeof value === "string" && value.length > 0) return [value];
@@ -39973,10 +40372,10 @@ const prefixWorkspacePatterns = (workspacePattern, patterns) => {
39973
40372
  return patterns.map((pattern) => pattern.startsWith("!") ? `!${normalizedWorkspacePattern}/${pattern.slice(1)}` : `${normalizedWorkspacePattern}/${pattern}`);
39974
40373
  };
39975
40374
  const collectKnipWorkspacePatterns = (workspaces, settingName) => {
39976
- if (!isRecord$1$1(workspaces)) return [];
40375
+ if (!isRecord$2(workspaces)) return [];
39977
40376
  const patterns = [];
39978
40377
  for (const [workspacePattern, workspaceConfig] of Object.entries(workspaces)) {
39979
- if (!isRecord$1$1(workspaceConfig)) continue;
40378
+ if (!isRecord$2(workspaceConfig)) continue;
39980
40379
  patterns.push(...prefixWorkspacePatterns(workspacePattern, normalizePatternList(workspaceConfig[settingName])));
39981
40380
  }
39982
40381
  return patterns;
@@ -39986,12 +40385,11 @@ const collectKnipPatterns = (rootDirectory, settingName) => {
39986
40385
  if (!config) return [];
39987
40386
  return [...normalizePatternList(config[settingName]), ...collectKnipWorkspacePatterns(config.workspaces, settingName)];
39988
40387
  };
39989
- const collectDeadCodeIgnorePatterns = (rootDirectory, userConfig) => {
40388
+ const collectDeadCodeIgnorePatterns = (rootDirectory) => {
39990
40389
  const seen = /* @__PURE__ */ new Set();
39991
40390
  const sources = [
39992
40391
  readIgnoreFile(path.join(rootDirectory, ".gitignore")),
39993
40392
  collectIgnorePatterns(rootDirectory),
39994
- userConfig?.ignore?.files ?? [],
39995
40393
  collectKnipPatterns(rootDirectory, "ignore")
39996
40394
  ];
39997
40395
  for (const source of sources) for (const pattern of source) seen.add(pattern);
@@ -40022,8 +40420,6 @@ const toCanonicalPath = (filePath) => {
40022
40420
  };
40023
40421
  const DEAD_CODE_PLUGIN = "deslop";
40024
40422
  const DEAD_CODE_CATEGORY = "Maintainability";
40025
- const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
40026
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
40027
40423
  const DEAD_CODE_WORKER_SCRIPT = `
40028
40424
  const inputChunks = [];
40029
40425
  process.stdin.on("data", (chunk) => inputChunks.push(chunk));
@@ -40071,6 +40467,22 @@ process.stdin.on("end", () => {
40071
40467
  ...(workerInput.ignorePatterns.length > 0
40072
40468
  ? { ignorePatterns: workerInput.ignorePatterns }
40073
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,
40074
40486
  };
40075
40487
  const result = await analyze(defineConfig(config));
40076
40488
  emit({ ok: true, result: normalizeResult(result) });
@@ -40081,7 +40493,7 @@ process.stdin.on("end", () => {
40081
40493
  });
40082
40494
  `;
40083
40495
  const resolveTsConfigPath = (rootDirectory) => {
40084
- for (const filename of TSCONFIG_FILENAMES$1) {
40496
+ for (const filename of TSCONFIG_FILENAMES) {
40085
40497
  const candidate = Path.join(rootDirectory, filename);
40086
40498
  if (NFS.existsSync(candidate)) return candidate;
40087
40499
  }
@@ -40200,7 +40612,11 @@ const createDeadCodeWorker = (input) => {
40200
40612
  "pipe",
40201
40613
  "pipe"
40202
40614
  ],
40203
- windowsHide: true
40615
+ windowsHide: true,
40616
+ env: input.parseConcurrency === void 0 ? process.env : {
40617
+ ...process.env,
40618
+ DESLOP_PARSE_CONCURRENCY: String(input.parseConcurrency)
40619
+ }
40204
40620
  });
40205
40621
  const stdoutChunks = [];
40206
40622
  const stderrChunks = [];
@@ -40245,42 +40661,39 @@ const createDeadCodeWorker = (input) => {
40245
40661
  }
40246
40662
  };
40247
40663
  };
40248
- const runDeadCodeWorkerWithTimeout = (handle, timeoutMs) => new Promise((resolve, reject) => {
40664
+ const runDeadCodeWorkerWithTimeout = (handle, timeoutMs, abortSignal) => new Promise((resolve, reject) => {
40249
40665
  let didSettle = false;
40250
- const timeoutHandle = setTimeout(() => {
40251
- if (didSettle) return;
40252
- didSettle = true;
40253
- handle.terminate?.();
40254
- reject(/* @__PURE__ */ new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`));
40255
- }, timeoutMs);
40256
- timeoutHandle.unref?.();
40257
- handle.result.then((value) => {
40666
+ const settle = (finish) => {
40258
40667
  if (didSettle) return;
40259
40668
  didSettle = true;
40260
40669
  clearTimeout(timeoutHandle);
40670
+ abortSignal?.removeEventListener("abort", onAbort);
40261
40671
  handle.terminate?.();
40262
- resolve(value);
40263
- }, (error) => {
40264
- if (didSettle) return;
40265
- didSettle = true;
40266
- clearTimeout(timeoutHandle);
40267
- handle.terminate?.();
40268
- reject(error);
40269
- });
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)));
40270
40683
  });
40271
40684
  const checkDeadCode = async (options) => {
40272
- const { userConfig } = options;
40273
40685
  const rootDirectory = toCanonicalPath(options.rootDirectory);
40274
40686
  if (!NFS.existsSync(Path.join(rootDirectory, "package.json"))) return [];
40275
40687
  const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
40276
- const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory, userConfig);
40688
+ const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory);
40277
40689
  const result = parseDeadCodeWorkerResult(await runDeadCodeWorkerWithTimeout((options.createWorker ?? createDeadCodeWorker)({
40278
40690
  rootDirectory,
40279
40691
  entryPatterns,
40280
40692
  tsConfigPath: resolveTsConfigPath(rootDirectory),
40281
40693
  ignorePatterns,
40282
- deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js")
40283
- }), options.workerTimeoutMs ?? 12e4));
40694
+ deslopJsModuleSpecifier: options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"),
40695
+ parseConcurrency: options.parseConcurrency
40696
+ }), options.workerTimeoutMs ?? 12e4, options.abortSignal));
40284
40697
  const toRelative = (filePath) => toRelativeFilePath(rootDirectory, filePath);
40285
40698
  const diagnostics = [];
40286
40699
  for (const unusedFile of result.unusedFiles) diagnostics.push({
@@ -40378,7 +40791,37 @@ const isDiagnosticOnSurface = (diagnostic, surface, config) => {
40378
40791
  return true;
40379
40792
  };
40380
40793
  const filterDiagnosticsForSurface = (diagnostics, surface, config) => diagnostics.filter((diagnostic) => isDiagnosticOnSurface(diagnostic, surface, config));
40381
- 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
+ };
40382
40825
  const listSourceFilesViaGit = (rootDirectory) => {
40383
40826
  const result = spawnSync("git", [
40384
40827
  "ls-files",
@@ -40411,7 +40854,8 @@ const listSourceFilesViaFilesystem = (rootDirectory) => {
40411
40854
  }
40412
40855
  return filePaths;
40413
40856
  };
40414
- 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);
40415
40859
  const resolveLintIncludePaths = (rootDirectory, userConfig, project) => {
40416
40860
  if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
40417
40861
  const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
@@ -40454,24 +40898,25 @@ var Config = class Config extends Service()("react-doctor/Config") {
40454
40898
  var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
40455
40899
  static layerNode = succeed$3(DeadCode, DeadCode.of({ run: (input) => unwrap(fn("DeadCode.run")(function* () {
40456
40900
  return yield* tryPromise({
40457
- try: () => checkDeadCode({
40901
+ try: (signal) => checkDeadCode({
40458
40902
  rootDirectory: input.rootDirectory,
40459
- userConfig: input.userConfig
40903
+ userConfig: input.userConfig,
40904
+ parseConcurrency: input.parseConcurrency,
40905
+ workerTimeoutMs: input.workerTimeoutMs,
40906
+ abortSignal: signal
40460
40907
  }),
40461
40908
  catch: (cause) => new ReactDoctorError({ reason: new DeadCodeAnalysisFailed({ cause }) })
40462
40909
  }).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)));
40463
40910
  })()) }));
40464
40911
  static layerOf = (diagnostics) => succeed$3(DeadCode, DeadCode.of({ run: () => fromIterable$1(diagnostics) }));
40465
40912
  };
40466
- const createNodeReadFileLinesSync = (rootDirectory) => {
40467
- return (filePath) => {
40468
- const absolutePath = Path.isAbsolute(filePath) ? filePath : Path.join(rootDirectory, filePath);
40469
- try {
40470
- return NFS.readFileSync(absolutePath, "utf-8").split("\n");
40471
- } catch {
40472
- return null;
40473
- }
40474
- };
40913
+ const createNodeReadFileLinesSync = (rootDirectory) => (filePath) => {
40914
+ const absolutePath = Path.isAbsolute(filePath) ? filePath : Path.join(rootDirectory, filePath);
40915
+ try {
40916
+ return NFS.readFileSync(absolutePath, "utf-8").split("\n");
40917
+ } catch {
40918
+ return null;
40919
+ }
40475
40920
  };
40476
40921
  var Files = class Files extends Service()("react-doctor/Files") {
40477
40922
  static layerNode = succeed$3(Files, Files.of({
@@ -40682,7 +41127,10 @@ var Git = class Git extends Service()("react-doctor/Git") {
40682
41127
  directory: input.directory,
40683
41128
  cause
40684
41129
  }) });
40685
- }));
41130
+ }), withSpan("git.exec", { attributes: {
41131
+ "git.command": input.command,
41132
+ "git.subcommand": input.args[0] ?? ""
41133
+ } }));
40686
41134
  const runGit = (directory, args) => runCommand({
40687
41135
  command: "git",
40688
41136
  args,
@@ -40710,7 +41158,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40710
41158
  ]);
40711
41159
  if (candidates.status !== 0) return null;
40712
41160
  return trimOrNull(candidates.stdout.split("\n")[0] ?? "");
40713
- });
41161
+ }).pipe(withSpan("Git.defaultBranch"));
40714
41162
  const branchExists = (directory, branch) => runGit(directory, [
40715
41163
  "rev-parse",
40716
41164
  "--verify",
@@ -40757,7 +41205,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40757
41205
  const result = resultOption.value;
40758
41206
  if (result.status !== 0) return null;
40759
41207
  return parseGithubViewerPermission(result.stdout);
40760
- }).pipe(catch_$1(() => succeed$2(null)));
41208
+ }).pipe(catch_$1(() => succeed$2(null)), withSpan("Git.githubViewerPermission"));
40761
41209
  /**
40762
41210
  * Resolves a `--diff A..B` / `A...B` commit range into a changed-file
40763
41211
  * selection. Each endpoint is validated with `isSafeGitRevision`
@@ -40871,7 +41319,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40871
41319
  changedFiles: splitNullSeparated(diff.stdout),
40872
41320
  isCurrentChanges: false
40873
41321
  };
40874
- }),
41322
+ }).pipe(withSpan("Git.diffSelection")),
40875
41323
  stagedFilePaths: (directory) => runGit(directory, [
40876
41324
  "diff",
40877
41325
  "--cached",
@@ -40913,7 +41361,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40913
41361
  status: result.status,
40914
41362
  stdout: result.stdout
40915
41363
  };
40916
- }),
41364
+ }).pipe(withSpan("Git.grep")),
40917
41365
  changedLineRanges: ({ directory, baseRef, cached, files }) => gen(function* () {
40918
41366
  if (files.length === 0) return [];
40919
41367
  if (baseRef !== void 0 && !isSafeGitRevision(baseRef)) return null;
@@ -40929,7 +41377,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40929
41377
  ]);
40930
41378
  if (result.status !== 0) return null;
40931
41379
  return parseChangedLineRanges(result.stdout);
40932
- })
41380
+ }).pipe(withSpan("Git.changedLineRanges"))
40933
41381
  });
40934
41382
  })).pipe(provide$2(layer$3.pipe(provide$2(mergeAll$1(layer$2, layer$1)))));
40935
41383
  /**
@@ -41144,7 +41592,7 @@ const neutralizeDisableDirectives = async (rootDirectory, includePaths) => {
41144
41592
  for (const [absolutePath, originalContent] of originalContents) try {
41145
41593
  NFS.writeFileSync(absolutePath, originalContent);
41146
41594
  } catch (error) {
41147
- process.stderr.write(`[react-doctor] Failed to restore inline disable directives in ${absolutePath}: ${error instanceof Error ? error.message : String(error)}\n[react-doctor] Run: git checkout -- ${absolutePath}\n`);
41595
+ process.stderr.write(`[react-doctor] Failed to restore inline disable directives in ${absolutePath}: ${messageFromUnknown(error)}\n[react-doctor] Run: git checkout -- ${absolutePath}\n`);
41148
41596
  }
41149
41597
  };
41150
41598
  const onExit = () => restore();
@@ -41168,6 +41616,14 @@ const neutralizeDisableDirectives = async (rootDirectory, includePaths) => {
41168
41616
  process.removeListener("exit", onExit);
41169
41617
  };
41170
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");
41171
41627
  /**
41172
41628
  * Loads a plugin module via the local require resolver and extracts
41173
41629
  * `(name, ruleNames)` from either `module.exports.meta + rules` or
@@ -41250,7 +41706,7 @@ const resolveUserPlugin = (spec, configSourceDirectory) => {
41250
41706
  try {
41251
41707
  resolvedSpecifier = isRelative ? Path.resolve(configSourceDirectory, spec) : candidateRequire.resolve(spec);
41252
41708
  } catch (error) {
41253
- warnConfigIssue(`config.plugins entry "${spec}" could not be resolved from ${configSourceDirectory}: ${error instanceof Error ? error.message : String(error)}`);
41709
+ warnConfigIssue(`config.plugins entry "${spec}" could not be resolved from ${configSourceDirectory}: ${messageFromUnknown(error)}`);
41254
41710
  return null;
41255
41711
  }
41256
41712
  const { name, ruleNames } = readPluginShape(resolvedSpecifier, (target) => candidateRequire(target));
@@ -41322,8 +41778,8 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
41322
41778
  }
41323
41779
  return enabled;
41324
41780
  };
41325
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [] }) => {
41326
- const reactHooksJsPlugin = 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);
41327
41783
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
41328
41784
  const jsPlugins = [];
41329
41785
  if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
@@ -41332,6 +41788,8 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41332
41788
  for (const registryEntry of REACT_DOCTOR_RULES) {
41333
41789
  const rule = reactDoctorPlugin.rules[registryEntry.id];
41334
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;
41335
41793
  if (rule.scan !== void 0) continue;
41336
41794
  if (customRulesOnly && registryEntry.originallyExternal) continue;
41337
41795
  if (rule.framework !== "global" && !rule.requires) continue;
@@ -41346,7 +41804,7 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41346
41804
  enabledReactDoctorRules[registryEntry.key] = severity;
41347
41805
  }
41348
41806
  const userPluginRules = {};
41349
- for (const userPlugin of userPlugins) {
41807
+ if (ruleSelection !== "sidecar") for (const userPlugin of userPlugins) {
41350
41808
  Object.assign(userPluginRules, buildUserPluginRules(userPlugin, severityControls));
41351
41809
  jsPlugins.push(userPlugin.entry);
41352
41810
  }
@@ -41376,6 +41834,100 @@ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, exte
41376
41834
  }
41377
41835
  };
41378
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
+ };
41379
41931
  const esmRequire = createRequire(import.meta.url);
41380
41932
  const resolveOxlintBinary = () => {
41381
41933
  const oxlintMainPath = esmRequire.resolve("oxlint");
@@ -41383,7 +41935,6 @@ const resolveOxlintBinary = () => {
41383
41935
  return Path.join(oxlintPackageDirectory, "bin", "oxlint");
41384
41936
  };
41385
41937
  const resolvePluginPath = () => esmRequire.resolve("oxlint-plugin-react-doctor");
41386
- const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
41387
41938
  const resolveTsConfigRelativePath = (rootDirectory) => {
41388
41939
  for (const filename of TSCONFIG_FILENAMES) if (NFS.existsSync(Path.join(rootDirectory, filename))) return `./${filename}`;
41389
41940
  return null;
@@ -41755,7 +42306,7 @@ const scopeContainsNonImportBinding = (node, scopeNode, identifierName) => {
41755
42306
  const isIdentifierShadowedByLocalBinding = (identifier, sourceFile) => {
41756
42307
  let currentNode = identifier.parent;
41757
42308
  while (currentNode) {
41758
- if (isScopeNode(currentNode)) {
42309
+ if (isScopeBoundary(currentNode)) {
41759
42310
  if (scopeContainsNonImportBinding(currentNode, currentNode, identifier.text)) return true;
41760
42311
  }
41761
42312
  if (currentNode === sourceFile) return false;
@@ -41846,11 +42397,10 @@ const findResolutionInScope = (scopeNode, identifierName, reactImportBindings, s
41846
42397
  });
41847
42398
  return resolution;
41848
42399
  };
41849
- const isScopeNode = isScopeBoundary;
41850
42400
  const resolveIdentifierBinding = (identifier, reactImportBindings, sourceFile, visitedDeclarations = /* @__PURE__ */ new Set()) => {
41851
42401
  let currentNode = identifier.parent;
41852
42402
  while (currentNode) {
41853
- if (isScopeNode(currentNode)) {
42403
+ if (isScopeBoundary(currentNode)) {
41854
42404
  const resolution = findResolutionInScope(currentNode, identifier.text, reactImportBindings, sourceFile, visitedDeclarations);
41855
42405
  if (resolution) return resolution;
41856
42406
  }
@@ -42020,9 +42570,9 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
42020
42570
  try {
42021
42571
  parsed = JSON.parse(sanitizedStdout);
42022
42572
  } catch {
42023
- throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
42573
+ throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 600) }) });
42024
42574
  }
42025
- if (!isOxlintOutput(parsed)) throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
42575
+ if (!isOxlintOutput(parsed)) throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 600) }) });
42026
42576
  const minifiedFileCache = /* @__PURE__ */ new Map();
42027
42577
  const isMinifiedDiagnosticFile = (filename) => {
42028
42578
  const absolutePath = Path.isAbsolute(filename) ? filename : Path.resolve(rootDirectory || ".", filename);
@@ -42059,15 +42609,19 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
42059
42609
  };
42060
42610
  });
42061
42611
  };
42062
- const SANITIZED_ENV = (() => {
42063
- const sanitized = {};
42064
- 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)) {
42065
42615
  if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
42066
42616
  if (name.startsWith("npm_config_")) continue;
42067
- sanitized[name] = value;
42617
+ childEnv[name] = value;
42068
42618
  }
42069
- return sanitized;
42070
- })();
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);
42071
42625
  /**
42072
42626
  * Spawn one oxlint subprocess with hard ceilings on wall time and
42073
42627
  * output size. Returns stdout on success; raises a tagged
@@ -42084,7 +42638,11 @@ const SANITIZED_ENV = (() => {
42084
42638
  * The first three are splittable (the caller's binary-split retry
42085
42639
  * shrinks the batch and re-spawns); the fourth isn't.
42086
42640
  */
42087
- 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
+ }
42088
42646
  const child = spawn(nodeBinaryPath, args, {
42089
42647
  cwd: rootDirectory,
42090
42648
  env: SANITIZED_ENV,
@@ -42094,11 +42652,18 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42094
42652
  "pipe"
42095
42653
  ]
42096
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);
42097
42661
  const timeoutHandle = setTimeout(() => {
42662
+ clearAbortListener();
42098
42663
  child.kill("SIGKILL");
42099
42664
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42100
42665
  kind: "timeout",
42101
- detail: `${spawnTimeoutMs / 1e3}s budget exceeded`
42666
+ detail: `${spawnTimeoutMs / MILLISECONDS_PER_SECOND}s budget exceeded`
42102
42667
  }) }));
42103
42668
  }, spawnTimeoutMs);
42104
42669
  timeoutHandle.unref?.();
@@ -42129,10 +42694,12 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42129
42694
  });
42130
42695
  child.on("error", (error) => {
42131
42696
  clearTimeout(timeoutHandle);
42697
+ clearAbortListener();
42132
42698
  reject(new ReactDoctorError({ reason: new OxlintSpawnFailed({ cause: error }) }));
42133
42699
  });
42134
42700
  child.on("close", (_code, signal) => {
42135
42701
  clearTimeout(timeoutHandle);
42702
+ clearAbortListener();
42136
42703
  if (didKillForSize) {
42137
42704
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42138
42705
  kind: "output-too-large",
@@ -42199,26 +42766,28 @@ const isParallelismRelatedSpawnError = (error) => {
42199
42766
  * loop with a slimmer config in that case.
42200
42767
  */
42201
42768
  const spawnLintBatches = async (input) => {
42202
- 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;
42203
42770
  const requestedConcurrency = resolveScanConcurrency(input.concurrency ?? 1);
42204
42771
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
42205
42772
  const runBatchPass = async (concurrency) => {
42206
42773
  const allDiagnostics = [];
42207
42774
  const droppedFiles = [];
42208
42775
  let firstDropReason = null;
42209
- const spawnLintBatch = async (batch) => {
42776
+ const splitDeadlineMs = Date.now() + splitTotalBudgetMs;
42777
+ const spawnLintBatch = async (batch, depth) => {
42210
42778
  const batchArgs = [...baseArgs, ...batch];
42211
42779
  try {
42212
- return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes), project, rootDirectory);
42780
+ return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath, spawnTimeoutMs, outputMaxBytes, signal), project, rootDirectory);
42213
42781
  } catch (error) {
42214
42782
  if (!isSplittableReactDoctorError(error)) throw error;
42215
- if (batch.length <= 1) {
42783
+ const splitBudgetExhausted = Date.now() >= splitDeadlineMs || depth >= splitMaxDepth;
42784
+ if (batch.length <= 1 || splitBudgetExhausted) {
42216
42785
  droppedFiles.push(...batch);
42217
- 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;
42218
42787
  return [];
42219
42788
  }
42220
42789
  const splitIndex = Math.ceil(batch.length / 2);
42221
- 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)];
42222
42791
  }
42223
42792
  };
42224
42793
  let startedFileCount = 0;
@@ -42235,7 +42804,7 @@ const spawnLintBatches = async (input) => {
42235
42804
  try {
42236
42805
  const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
42237
42806
  startedFileCount += batch.length;
42238
- const batchDiagnostics = await spawnLintBatch(batch);
42807
+ const batchDiagnostics = await spawnLintBatch(batch, 0);
42239
42808
  scannedFileCount += batch.length;
42240
42809
  if (onFileProgress) {
42241
42810
  displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
@@ -42296,6 +42865,22 @@ const validateRuleRegistration = () => {
42296
42865
  ].filter((entry) => entry !== null).join("; ");
42297
42866
  console.warn(`[react-doctor] rule-registration drift: ${detail}`);
42298
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);
42299
42884
  /**
42300
42885
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
42301
42886
  * the runner: once for the primary scan, once for the
@@ -42313,6 +42898,28 @@ const writeOxlintConfig = (configPath, configToWrite) => {
42313
42898
  NFS.closeSync(fileHandle);
42314
42899
  }
42315
42900
  };
42901
+ const REACT_HOOKS_JS_DROP_PREFIX = "React Compiler rules (react-hooks-js/*) skipped — eslint-plugin-react-hooks failed to load in this environment";
42902
+ /**
42903
+ * Detects an oxlint config-load crash caused by the optional
42904
+ * `react-hooks-js` (eslint-plugin-react-hooks) React Compiler plugin and
42905
+ * builds the partial-failure note for it; returns `null` when the failure
42906
+ * was anything else.
42907
+ *
42908
+ * oxlint prints a framed error to stdout (not stderr) and exits non-zero
42909
+ * when a `jsPlugins` entry can't be imported; that non-JSON stdout
42910
+ * surfaces as `OxlintOutputUnparseable`. Because oxlint fails the WHOLE
42911
+ * config load on it, leaving the plugin in would drop every curated
42912
+ * react-doctor diagnostic too — so the caller retries with the plugin
42913
+ * stripped (issue #833). Both markers sit at the start of oxlint's
42914
+ * message, so they survive the `preview` slice even for deep pnpm paths.
42915
+ */
42916
+ const reactHooksJsPluginDropNote = (error) => {
42917
+ if (!(error instanceof ReactDoctorError) || error.reason._tag !== "OxlintOutputUnparseable") return null;
42918
+ const { preview } = error.reason;
42919
+ if (!preview.includes("Failed to load JS plugin") || !preview.includes("eslint-plugin-react-hooks")) return null;
42920
+ const underlyingReason = preview.match(/Error:[^\n]*/)?.[0]?.trim();
42921
+ return `${REACT_HOOKS_JS_DROP_PREFIX}${underlyingReason ? `: ${underlyingReason}` : ""}. Other rules ran normally.`;
42922
+ };
42316
42923
  /**
42317
42924
  * The oxlint runner. Composed of three pieces in `runners/oxlint/`:
42318
42925
  *
@@ -42332,7 +42939,7 @@ const writeOxlintConfig = (configPath, configToWrite) => {
42332
42939
  * 6. always restore disable directives + clean up the temp dir
42333
42940
  */
42334
42941
  const runOxlint = async (options) => {
42335
- 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;
42336
42943
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
42337
42944
  const severityControls = buildRuleSeverityControls(userConfig);
42338
42945
  validateRuleRegistration();
@@ -42340,38 +42947,165 @@ const runOxlint = async (options) => {
42340
42947
  const pluginPath = resolvePluginPath();
42341
42948
  const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
42342
42949
  const userPlugins = resolveUserPlugins(userConfig?.plugins, configSourceDirectory);
42343
- const buildConfig = (extendsForThisAttempt) => createOxlintConfig({
42950
+ const buildConfig = (overrides) => createOxlintConfig({
42344
42951
  pluginPath,
42345
42952
  project,
42346
42953
  customRulesOnly,
42347
- extendsPaths: extendsForThisAttempt,
42954
+ extendsPaths: overrides.extendsPaths,
42348
42955
  ignoredTags,
42349
42956
  serverAuthFunctionNames,
42350
42957
  severityControls,
42351
- userPlugins
42958
+ userPlugins,
42959
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin,
42960
+ ruleSelection: overrides.ruleSelection
42352
42961
  });
42353
42962
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
42354
42963
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
42355
42964
  const configPath = Path.join(configDirectory, "oxlintrc.json");
42356
42965
  try {
42357
- const baseArgs = [
42358
- resolveOxlintBinary(),
42359
- "-c",
42360
- configPath,
42361
- "--format",
42362
- "json"
42363
- ];
42966
+ const oxlintBinary = resolveOxlintBinary();
42967
+ const sharedArgs = [];
42968
+ let tsconfigContent = null;
42364
42969
  if (project.hasTypeScript) {
42365
42970
  const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
42366
- 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
+ }
42367
42979
  }
42368
42980
  const combinedPatterns = collectIgnorePatterns(rootDirectory);
42369
42981
  if (combinedPatterns.length > 0) {
42370
42982
  const combinedIgnorePath = Path.join(configDirectory, "combined.ignore");
42371
42983
  NFS.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
42372
- baseArgs.push("--ignore-path", combinedIgnorePath);
42984
+ sharedArgs.push("--ignore-path", combinedIgnorePath);
42985
+ }
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
+ ]);
42373
43106
  }
42374
- const fileBatches = batchIncludePaths(baseArgs, includePaths !== void 0 ? includePaths : listSourceFiles(rootDirectory));
43107
+ const baseArgs = makeBaseArgs(configPath);
43108
+ const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
42375
43109
  const runBatches = () => spawnLintBatches({
42376
43110
  baseArgs,
42377
43111
  fileBatches,
@@ -42382,14 +43116,25 @@ const runOxlint = async (options) => {
42382
43116
  onFileProgress: options.onFileProgress,
42383
43117
  spawnTimeoutMs,
42384
43118
  outputMaxBytes,
42385
- concurrency: options.concurrency
43119
+ concurrency: options.concurrency,
43120
+ signal: options.signal
42386
43121
  });
42387
- writeOxlintConfig(configPath, buildConfig(extendsPaths));
43122
+ writeOxlintConfig(configPath, buildConfig({ extendsPaths }));
42388
43123
  try {
42389
43124
  return await runBatches();
42390
43125
  } catch (error) {
43126
+ const reactHooksJsDropNote = reactHooksJsPluginDropNote(error);
43127
+ if (reactHooksJsDropNote !== null) {
43128
+ writeOxlintConfig(configPath, buildConfig({
43129
+ extendsPaths,
43130
+ disableReactHooksJsPlugin: true
43131
+ }));
43132
+ const diagnostics = await runBatches();
43133
+ onPartialFailure?.(reactHooksJsDropNote);
43134
+ return diagnostics;
43135
+ }
42391
43136
  if (extendsPaths.length === 0) throw error;
42392
- writeOxlintConfig(configPath, buildConfig([]));
43137
+ writeOxlintConfig(configPath, buildConfig({ extendsPaths: [] }));
42393
43138
  return await runBatches();
42394
43139
  }
42395
43140
  } finally {
@@ -42451,9 +43196,11 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
42451
43196
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
42452
43197
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
42453
43198
  const concurrency = yield* OxlintConcurrency;
43199
+ const lintBatchOrdering = yield* LintBatchOrdering;
43200
+ const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
42454
43201
  const collectedFailures = [];
42455
43202
  const diagnostics = yield* tryPromise({
42456
- try: () => runOxlint({
43203
+ try: (signal) => runOxlint({
42457
43204
  rootDirectory: input.rootDirectory,
42458
43205
  project: input.project,
42459
43206
  includePaths: input.includePaths ? [...input.includePaths] : void 0,
@@ -42468,9 +43215,13 @@ var Linter = class Linter extends Service()("react-doctor/Linter") {
42468
43215
  collectedFailures.push(reason);
42469
43216
  },
42470
43217
  onFileProgress: input.onFileProgress,
43218
+ perFileLintCacheEnabled,
43219
+ onCacheStats: input.onCacheStats,
42471
43220
  spawnTimeoutMs,
42472
43221
  outputMaxBytes,
42473
- concurrency
43222
+ concurrency,
43223
+ signal,
43224
+ lintBatchOrdering
42474
43225
  }),
42475
43226
  catch: ensureReactDoctorError
42476
43227
  });
@@ -42862,14 +43613,46 @@ const parseArtifactFromBody = (body) => {
42862
43613
  }
42863
43614
  return null;
42864
43615
  };
42865
- 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
+ }
42866
43646
  const requestUrl = `${SOCKET_FREE_PURL_API_BASE}/${encodeURIComponent(toPurl(dependency))}`;
42867
43647
  const response = await fetch(requestUrl, {
42868
43648
  headers: { "User-Agent": SOCKET_FREE_USER_AGENT },
42869
43649
  signal
42870
43650
  });
42871
43651
  if (!response.ok) return null;
42872
- 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;
42873
43656
  }).pipe(timeout(FETCH_TIMEOUT_MS), orElseSucceed(() => null), tap$1((artifact) => {
42874
43657
  const scoreAttributes = {};
42875
43658
  if (artifact !== null) {
@@ -42974,7 +43757,8 @@ const checkSupplyChain = (input) => gen(function* () {
42974
43757
  const packageJsonPath = Path.join(input.rootDirectory, "package.json");
42975
43758
  const dependencies = collectDependenciesToScore(readPackageJson$1(packageJsonPath), readPackageJsonText(packageJsonPath), options.includeDevDependencies);
42976
43759
  if (dependencies.length === 0) return [];
42977
- 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, () => [])));
42978
43762
  const diagnostics = [];
42979
43763
  for (let index = 0; index < dependencies.length; index += 1) {
42980
43764
  const artifact = artifacts[index];
@@ -42999,6 +43783,10 @@ const checkSupplyChain = (input) => gen(function* () {
42999
43783
  * The underlying `checkSupplyChain` Effect is total/fail-open — per-package
43000
43784
  * timeouts and network failures recover to "skip" — so the stream never
43001
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.
43002
43790
  */
43003
43791
  var SupplyChain = class SupplyChain extends Service()("react-doctor/SupplyChain") {
43004
43792
  static layerNode = succeed$3(SupplyChain, SupplyChain.of({ run: (input) => unwrap(checkSupplyChain(input).pipe(map$3((diagnostics) => fromIterable$1(diagnostics)), withSpan("SupplyChain.run"))) }));
@@ -43057,18 +43845,42 @@ const formatLintFailText = (reasonTag, nodeVersion) => {
43057
43845
  *
43058
43846
  * Phases:
43059
43847
  *
43060
- * 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).
43061
43852
  * 2. beforeLint hook (e.g. CLI renders the project-detection block)
43062
43853
  * 3. environment checks (reduced-motion + pnpm hardening +
43063
- * expo/react-native + security scan)
43064
- * 4. Linter.run + DeadCode.run forked as concurrent fibers so
43065
- * their wall-clock times overlap. Progress spinners stay
43066
- * sequential (lint first, then dead-code) for clean terminal
43067
- * output. GitHub viewer permission also runs as a background
43068
- * fiber during this phase.
43069
- * 5. afterLint hook
43070
- * 6. Reporter.finalize
43071
- * 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
43072
43884
  *
43073
43885
  * The orchestrator owns spinner lifecycle via `Progress`; callers
43074
43886
  * choose `Progress.layerOra(...)` for CLI feedback or
@@ -43126,10 +43938,21 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43126
43938
  ignoredTags: input.ignoredTags
43127
43939
  })
43128
43940
  ])));
43129
- 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({
43130
43944
  rootDirectory: scanDirectory,
43131
43945
  userConfig: resolvedConfig.config
43132
- }))) : [];
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
+ }));
43133
43956
  const lintFailure = yield* make$13({
43134
43957
  didFail: false,
43135
43958
  reason: null,
@@ -43140,12 +43963,49 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43140
43963
  didFail: false,
43141
43964
  reason: null
43142
43965
  });
43143
- 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;
43144
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;
43145
44003
  const scanProgress = yield* progressService.start("Scanning...");
43146
44004
  const scanStartTime = Date.now();
43147
44005
  let lastReportedTotalFileCount = 0;
43148
- const lintCollected = yield* runCollect(applyPerElementPipeline(linterService.run({
44006
+ let lintCacheHitFileCount = null;
44007
+ let lintCacheTotalFileCount = null;
44008
+ const baseLintStream = linterService.run({
43149
44009
  rootDirectory: scanDirectory,
43150
44010
  project,
43151
44011
  includePaths: lintIncludePaths ?? void 0,
@@ -43159,6 +44019,10 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43159
44019
  onFileProgress: (scannedFileCount, totalFileCount) => {
43160
44020
  lastReportedTotalFileCount = totalFileCount;
43161
44021
  runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
44022
+ },
44023
+ onCacheStats: (cacheHitFileCount, totalConsideredFileCount) => {
44024
+ lintCacheHitFileCount = cacheHitFileCount;
44025
+ lintCacheTotalFileCount = totalConsideredFileCount;
43162
44026
  }
43163
44027
  }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43164
44028
  yield* set(lintFailure, {
@@ -43168,36 +44032,54 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43168
44032
  reasonKind: error.reason._tag === "OxlintUnavailable" ? error.reason.kind : null
43169
44033
  });
43170
44034
  return empty$4;
43171
- }))))));
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
+ })));
43172
44045
  const lintFailureState = yield* get$2(lintFailure);
43173
44046
  yield* afterLint(lintFailureState.didFail);
43174
44047
  if (lintFailureState.didFail) yield* scanProgress.fail(formatLintFailText(lintFailureState.reasonTag, process.version));
43175
44048
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
43176
44049
  const scannedFilesLabel = `${totalFileCount} ${totalFileCount === 1 ? "file" : "files"}`;
43177
- const shouldRunDeadCode = input.runDeadCode && !isDiffMode && (showWarnings || deadCodeMaySurfaceWhenWarningsHidden(resolvedConfig.config));
43178
- const deadCodeCollected = lintFailureState.didFail || !shouldRunDeadCode ? [] : yield* scanProgress.update(`Scanned ${scannedFilesLabel}, analyzing dead code...`).pipe(andThen(runCollect(applyPerElementPipeline(deadCodeService.run({
43179
- rootDirectory: scanDirectory,
43180
- userConfig: resolvedConfig.config
43181
- }).pipe(catchTag("ReactDoctorError", (error) => unwrap(gen(function* () {
43182
- yield* set(deadCodeFailure, {
43183
- didFail: true,
43184
- 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
43185
44059
  });
43186
- return empty$4;
43187
- }))))))));
43188
- 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);
43189
44069
  const scanElapsedMilliseconds = Date.now() - scanStartTime;
43190
- const scanElapsedSeconds = (scanElapsedMilliseconds / 1e3).toFixed(1);
44070
+ const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1);
43191
44071
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
43192
44072
  else if (input.suppressScanSummary) yield* scanProgress.stop();
43193
44073
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
44074
+ const supplyChainResult = yield* join(supplyChainFiber);
44075
+ const supplyChainCollected = supplyChainResult.diagnostics;
43194
44076
  yield* reporterService.finalize;
43195
- const finalDiagnostics = [
44077
+ const finalDiagnostics = sortDiagnosticsStable(assignFixGroups([
43196
44078
  ...envCollected,
43197
44079
  ...supplyChainCollected,
43198
44080
  ...lintCollected,
43199
44081
  ...deadCodeCollected
43200
- ];
44082
+ ]));
43201
44083
  const githubViewerPermission = yield* join(githubViewerPermissionFiber);
43202
44084
  const scoreMetadata = {
43203
44085
  ...repo !== null ? { repo } : {},
@@ -43233,9 +44115,14 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43233
44115
  lintPartialFailures,
43234
44116
  didDeadCodeFail: deadCodeFailureState.didFail,
43235
44117
  deadCodeFailureReason: deadCodeFailureState.reason,
44118
+ deadCodeOverlapped: shouldOverlapDeadCode,
43236
44119
  scannedFileCount: totalFileCount,
43237
44120
  scannedFilePaths,
43238
- scanElapsedMilliseconds
44121
+ scanElapsedMilliseconds,
44122
+ scanConcurrency,
44123
+ supplyChainOverlapTimedOut: supplyChainResult.timedOut,
44124
+ lintCacheHitFileCount,
44125
+ lintCacheTotalFileCount
43239
44126
  };
43240
44127
  }).pipe(withSpan("runInspect", { attributes: {
43241
44128
  "inspect.directory": input.directory,
@@ -43243,7 +44130,7 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43243
44130
  "inspect.runDeadCode": input.runDeadCode,
43244
44131
  "inspect.isCi": input.isCi,
43245
44132
  "inspect.scoreSurface": input.scoreSurface ?? "score"
43246
- } }));
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` }) })))));
43247
44134
  const parseNodeVersion = (versionString) => {
43248
44135
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
43249
44136
  return {
@@ -43424,7 +44311,7 @@ var StagedFiles = class StagedFiles extends Service()("react-doctor/StagedFiles"
43424
44311
  static layerNode = effect(StagedFiles, gen(function* () {
43425
44312
  const git = yield* Git;
43426
44313
  return StagedFiles.of({
43427
- discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(map$3((entries) => entries.filter(isLintableSourceFile))),
44314
+ discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(map$3((entries) => entries.filter(isLintableSourceFile)), withSpan("StagedFiles.discoverSourceFiles")),
43428
44315
  materialize: ({ directory, stagedFiles, tempDirectory }) => materializeSourceTree({
43429
44316
  directory,
43430
44317
  files: stagedFiles,
@@ -43434,7 +44321,7 @@ var StagedFiles = class StagedFiles extends Service()("react-doctor/StagedFiles"
43434
44321
  tempDirectory: tree.tempDirectory,
43435
44322
  stagedFiles: tree.materializedFiles,
43436
44323
  cleanup: tree.cleanup
43437
- })))
44324
+ })), withSpan("StagedFiles.materialize"))
43438
44325
  });
43439
44326
  }));
43440
44327
  /**
@@ -43567,6 +44454,7 @@ const buildJsonReport = (input) => {
43567
44454
  score: result.score,
43568
44455
  skippedChecks: result.skippedChecks,
43569
44456
  ...result.skippedCheckReasons ? { skippedCheckReasons: result.skippedCheckReasons } : {},
44457
+ ...typeof result.scannedFileCount === "number" ? { scannedFileCount: result.scannedFileCount } : {},
43570
44458
  elapsedMilliseconds: result.elapsedMilliseconds
43571
44459
  }));
43572
44460
  const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
@@ -43841,7 +44729,7 @@ const FALSY_CI_FLAG_VALUES = new Set([
43841
44729
  "false"
43842
44730
  ]);
43843
44731
  const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
43844
- const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
44732
+ const isCiEnvironment = (env = process.env) => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(env[environmentVariable])) || isCiFlagSet(env.CI);
43845
44733
  const detectCiProvider = () => {
43846
44734
  for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
43847
44735
  return isCiFlagSet(process.env.CI) ? "unknown" : null;
@@ -43866,6 +44754,53 @@ const detectCodingAgent = () => {
43866
44754
  const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
43867
44755
  const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
43868
44756
  //#endregion
44757
+ //#region src/cli/utils/detect-terminal-kind.ts
44758
+ const TERMINAL_BY_TERM_PROGRAM = [
44759
+ ["vscode", "vscode"],
44760
+ ["iTerm.app", "iterm"],
44761
+ ["Apple_Terminal", "apple-terminal"],
44762
+ ["WezTerm", "wezterm"],
44763
+ ["ghostty", "ghostty"],
44764
+ ["Hyper", "hyper"],
44765
+ ["Tabby", "tabby"],
44766
+ ["rio", "rio"]
44767
+ ];
44768
+ /**
44769
+ * Best-effort label for the terminal emulator / editor hosting the CLI,
44770
+ * derived from terminal-identity env vars. Recorded as the `terminalKind` run
44771
+ * tag so we can see where React Doctor is actually run (nvim, VS Code, iTerm,
44772
+ * …) — the split Sentry can't otherwise see. Low-cardinality and free of any
44773
+ * username/path/secret, so it's safe as a tag. Editor terminals (nvim/vim)
44774
+ * win over the outer emulator because that's the surface a user is reading in;
44775
+ * "ci" marks a run with no interactive terminal; "unknown" when nothing matches.
44776
+ */
44777
+ const detectTerminalKind = (env = process.env) => {
44778
+ if (env.NVIM) return "neovim";
44779
+ if (env.VIM_TERMINAL) return "vim";
44780
+ const termProgram = env.TERM_PROGRAM;
44781
+ if (termProgram) {
44782
+ for (const [marker, label] of TERMINAL_BY_TERM_PROGRAM) if (termProgram === marker) return label;
44783
+ }
44784
+ if (env.KITTY_WINDOW_ID || env.TERM === "xterm-kitty") return "kitty";
44785
+ if (env.WT_SESSION) return "windows-terminal";
44786
+ if (env.ALACRITTY_WINDOW_ID || env.TERM === "alacritty") return "alacritty";
44787
+ if (env.VTE_VERSION) return "vte";
44788
+ if (env.TMUX) return "tmux";
44789
+ if (isCiEnvironment(env)) return "ci";
44790
+ return "unknown";
44791
+ };
44792
+ //#endregion
44793
+ //#region src/cli/utils/is-debug-flag.ts
44794
+ /**
44795
+ * Whether the user passed `--debug` (surface the run's Sentry trace id, and
44796
+ * force performance tracing on so there's a trace to surface). Read straight
44797
+ * from argv rather than Commander's parsed flags because `initializeSentry()`
44798
+ * runs before Commander parses — the same reason `shouldEnableSentry()` reads
44799
+ * `--no-score` from argv. Sharing this one reader keeps the init-time sampling
44800
+ * override and the end-of-run print in agreement.
44801
+ */
44802
+ const isDebugFlagEnabled = (argv = process.argv) => argv.includes("--debug");
44803
+ //#endregion
43869
44804
  //#region src/cli/utils/is-git-hook-environment.ts
43870
44805
  const isGitHookEnvironment = () => Boolean(process.env.GIT_DIR);
43871
44806
  //#endregion
@@ -43888,6 +44823,7 @@ const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
43888
44823
  const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
43889
44824
  //#endregion
43890
44825
  //#region src/cli/utils/constants.ts
44826
+ const REACT_DOCTOR_CONFIG_PROJECT_NAME = "react-doctor";
43891
44827
  const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
43892
44828
  const BASELINE_FILES_TEMP_DIR_PREFIX = "react-doctor-baseline-";
43893
44829
  const GH_DEFAULT_BRANCH_PROBE_TIMEOUT_MS = 5e3;
@@ -43972,7 +44908,7 @@ const makeNoopConsole = () => ({
43972
44908
  });
43973
44909
  //#endregion
43974
44910
  //#region src/cli/utils/version.ts
43975
- const VERSION = "0.5.6";
44911
+ const VERSION = "0.5.7-dev.2cadd3f";
43976
44912
  //#endregion
43977
44913
  //#region src/cli/utils/json-mode.ts
43978
44914
  let context = null;
@@ -44122,8 +45058,11 @@ const buildRunContext = () => {
44122
45058
  viaAction: isOfficialGithubAction(),
44123
45059
  codingAgent: detectCodingAgent(),
44124
45060
  interactive: !isNonInteractiveEnvironment(),
45061
+ terminalKind: detectTerminalKind(),
44125
45062
  jsonMode: isJsonModeActive(),
44126
- invokedVia: detectInvokedVia()
45063
+ debug: isDebugFlagEnabled(),
45064
+ invokedVia: detectInvokedVia(),
45065
+ lintBatchOrdering: resolveLintBatchOrdering()
44127
45066
  };
44128
45067
  };
44129
45068
  //#endregion
@@ -44192,9 +45131,12 @@ const buildSentryScope = (runContext = buildRunContext()) => {
44192
45131
  viaAction: runContext.viaAction,
44193
45132
  codingAgent: runContext.codingAgent,
44194
45133
  interactive: runContext.interactive,
45134
+ terminalKind: runContext.terminalKind,
44195
45135
  jsonMode: runContext.jsonMode,
45136
+ debug: runContext.debug,
44196
45137
  invokedVia: runContext.invokedVia,
44197
- nodeMajor: runContext.nodeMajor
45138
+ nodeMajor: runContext.nodeMajor,
45139
+ lintBatchOrdering: runContext.lintBatchOrdering
44198
45140
  };
44199
45141
  const contexts = { run: { ...runContext } };
44200
45142
  const projectInfo = getSentryProjectInfo();
@@ -44330,13 +45272,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
44330
45272
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
44331
45273
  * standard `SENTRY_RELEASE` override.
44332
45274
  */
44333
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.6`;
45275
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.7-dev.2cadd3f`;
44334
45276
  /**
44335
45277
  * Deployment environment shown in Sentry's environment filter. Defaults to
44336
45278
  * `production` for tagged releases and `development` for dev/unbuilt versions,
44337
45279
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
44338
45280
  */
44339
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.6") ? "development" : "production");
45281
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.7-dev.2cadd3f") ? "development" : "production");
44340
45282
  /**
44341
45283
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
44342
45284
  * (set to `0` to disable tracing) and falls back to
@@ -44400,7 +45342,7 @@ const flushSentry = async () => {
44400
45342
  const initializeSentry = () => {
44401
45343
  if (isInitialized || !shouldEnableSentry()) return;
44402
45344
  isInitialized = true;
44403
- resolvedTracesSampleRate = resolveTracesSampleRate();
45345
+ resolvedTracesSampleRate = isDebugFlagEnabled() ? 1 : resolveTracesSampleRate();
44404
45346
  const { tags, contexts } = buildSentryScope();
44405
45347
  Sentry.init({
44406
45348
  dsn: process.env.SENTRY_DSN || "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920",
@@ -44539,7 +45481,7 @@ const externalSpanFrom = (sentrySpan) => {
44539
45481
  * in-memory tracer — identical to the prior default behavior.
44540
45482
  */
44541
45483
  const applyObservability = (program, rootSentrySpan) => {
44542
- 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));
44543
45485
  if (rootSentrySpan) return program.pipe(withTracer(makeSentryTracer(rootSentrySpan)));
44544
45486
  return program.pipe(provide(layerOtlp));
44545
45487
  };
@@ -47616,6 +48558,11 @@ const setActiveRunTrace = (trace) => {
47616
48558
  activeRunTrace = trace;
47617
48559
  };
47618
48560
  const getActiveRunTrace = () => activeRunTrace;
48561
+ let lastRunTraceId = null;
48562
+ const recordRunTraceId = (traceId) => {
48563
+ lastRunTraceId = traceId;
48564
+ };
48565
+ const getLastRunTraceId = () => lastRunTraceId;
47619
48566
  //#endregion
47620
48567
  //#region src/cli/utils/to-span-attributes.ts
47621
48568
  /**
@@ -47678,14 +48625,13 @@ const withSentryRunSpan = (run, options = {}) => {
47678
48625
  op: "cli.inspect",
47679
48626
  attributes: toSpanAttributes(tags)
47680
48627
  }, (rootSpan) => {
47681
- if (options.concurrentScan !== true) {
47682
- const spanContext = rootSpan.spanContext();
47683
- setActiveRunTrace({
47684
- traceId: spanContext.traceId,
47685
- spanId: spanContext.spanId,
47686
- sampled: (spanContext.traceFlags & 1) === 1
47687
- });
47688
- }
48628
+ const spanContext = rootSpan.spanContext();
48629
+ recordRunTraceId(spanContext.traceId);
48630
+ if (options.concurrentScan !== true) setActiveRunTrace({
48631
+ traceId: spanContext.traceId,
48632
+ spanId: spanContext.spanId,
48633
+ sampled: (spanContext.traceFlags & 1) === 1
48634
+ });
47689
48635
  return run(rootSpan);
47690
48636
  });
47691
48637
  };
@@ -47825,6 +48771,42 @@ const recordScanMetrics = (input) => {
47825
48771
  });
47826
48772
  };
47827
48773
  //#endregion
48774
+ //#region src/cli/utils/diagnostic-grouping.ts
48775
+ const buildRulePriorityMap = (scores) => {
48776
+ const rulePriority = /* @__PURE__ */ new Map();
48777
+ for (const score of scores) {
48778
+ if (!score?.rules) continue;
48779
+ for (const [ruleKey, info] of Object.entries(score.rules)) if (typeof info.priority === "number") rulePriority.set(ruleKey, info.priority);
48780
+ }
48781
+ return rulePriority;
48782
+ };
48783
+ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
48784
+ const priorityA = rulePriority?.get(ruleKeyA);
48785
+ const priorityB = rulePriority?.get(ruleKeyB);
48786
+ if (priorityA === void 0 && priorityB === void 0) return 0;
48787
+ if (priorityA === void 0) return 1;
48788
+ if (priorityB === void 0) return -1;
48789
+ return priorityB - priorityA;
48790
+ };
48791
+ const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
48792
+ const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
48793
+ const getSharedFixSiteCount = (diagnostics) => {
48794
+ if (diagnostics.length < 2) return 0;
48795
+ const firstFixGroupId = diagnostics[0]?.fixGroupId;
48796
+ if (!firstFixGroupId) return 0;
48797
+ return diagnostics.every((diagnostic) => diagnostic.fixGroupId === firstFixGroupId) ? diagnostics.length : 0;
48798
+ };
48799
+ const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
48800
+ const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48801
+ const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48802
+ const buildRuleBlastRadii = (diagnostics) => buildSortedRuleGroups(diagnostics).map(([ruleKey, ruleDiagnostics]) => ({
48803
+ ruleKey,
48804
+ title: ruleDiagnostics[0].title ?? ruleKey,
48805
+ siteCount: ruleDiagnostics.length,
48806
+ fileCount: new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath)).size
48807
+ })).toSorted((left, right) => right.fileCount - left.fileCount);
48808
+ const findMigrationScaleBuckets = (diagnostics) => buildRuleBlastRadii(diagnostics).filter((bucket) => bucket.fileCount >= 40);
48809
+ //#endregion
47828
48810
  //#region src/cli/utils/cli-logger.ts
47829
48811
  /**
47830
48812
  * Thin synchronous façade over Effect's `Console` module. Used by
@@ -47941,12 +48923,17 @@ const buildOutcomeAttributes = (input) => {
47941
48923
  topRule = rule;
47942
48924
  topRuleCount = count;
47943
48925
  }
48926
+ const largestRuleBucket = buildRuleBlastRadii(result.diagnostics)[0] ?? null;
47944
48927
  let diagnosticsInTestFiles = 0;
47945
48928
  let diagnosticsInStoryFiles = 0;
48929
+ const findingsPerFixGroup = /* @__PURE__ */ new Map();
47946
48930
  for (const diagnostic of result.diagnostics) {
47947
48931
  if (diagnostic.fileContext === "test") diagnosticsInTestFiles += 1;
47948
48932
  if (diagnostic.fileContext === "story") diagnosticsInStoryFiles += 1;
48933
+ if (diagnostic.fixGroupId) findingsPerFixGroup.set(diagnostic.fixGroupId, (findingsPerFixGroup.get(diagnostic.fixGroupId) ?? 0) + 1);
47949
48934
  }
48935
+ let fixGroupedFindings = 0;
48936
+ for (const count of findingsPerFixGroup.values()) fixGroupedFindings += count;
47950
48937
  const attributes = {
47951
48938
  outcome,
47952
48939
  exitCode: wouldBlock ? 1 : 0,
@@ -47960,8 +48947,16 @@ const buildOutcomeAttributes = (input) => {
47960
48947
  diagnosticsInTestFiles,
47961
48948
  diagnosticsInStoryFiles,
47962
48949
  distinctRulesFired: countByRule.size,
48950
+ "diag.fixGroups": findingsPerFixGroup.size,
48951
+ "diag.fixGroupedFindings": fixGroupedFindings,
47963
48952
  topRule,
48953
+ "migration.largestRuleBucketFiles": largestRuleBucket ? largestRuleBucket.fileCount : null,
48954
+ "migration.largestRuleBucketSites": largestRuleBucket ? largestRuleBucket.siteCount : null,
48955
+ "migration.largestRuleBucketRule": largestRuleBucket ? largestRuleBucket.ruleKey : null,
47964
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,
47965
48960
  elapsedMs: result.elapsedMilliseconds,
47966
48961
  scanPhaseMs: result.scanElapsedMilliseconds ?? null,
47967
48962
  score: result.score ? result.score.score : null,
@@ -47971,7 +48966,10 @@ const buildOutcomeAttributes = (input) => {
47971
48966
  didLintFail: input.didLintFail ?? null,
47972
48967
  lintFailureReasonKind: input.lintFailureReasonKind ?? null,
47973
48968
  lintPartialFailureCount: input.lintPartialFailureCount ?? null,
47974
- didDeadCodeFail: input.didDeadCodeFail ?? null
48969
+ lintDroppedFileCount: input.lintDroppedFileCount ?? null,
48970
+ didDeadCodeFail: input.didDeadCodeFail ?? null,
48971
+ supplyChainOverlapTimedOut: input.supplyChainOverlapTimedOut ?? null,
48972
+ deadCodeOverlapped: input.deadCodeOverlapped ?? null
47975
48973
  };
47976
48974
  for (const [category, count] of countByCategory) attributes[`diag.category.${toCategoryKey(category)}`] = count;
47977
48975
  if (result.baselineDelta) {
@@ -48040,6 +49038,32 @@ const recordRunEvent = (rootSpan, input) => {
48040
49038
  } catch {}
48041
49039
  };
48042
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
48043
49067
  //#region src/cli/utils/path-format.ts
48044
49068
  const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
48045
49069
  //#endregion
@@ -48118,9 +49142,10 @@ const AGENT_GUIDANCE_LINES = [
48118
49142
  "Investigate deeply where relevant: race conditions, security-sensitive flows, state propagation, multi-file refactors, and downstream dependency chains.",
48119
49143
  "Ignore pure style preferences, theoretical issues without real impact, missing features, and unrelated pre-existing code.",
48120
49144
  "Start with high-confidence fixes that preserve behavior. Leave low-confidence or product-dependent changes as notes.",
48121
- "Run `npx react-doctor@latest --verbose --diff` before and after changes, plus relevant tests after each focused batch.",
49145
+ "Run `npx react-doctor@latest --verbose --scope changed` before and after changes, plus relevant tests after each focused batch.",
48122
49146
  "When available, spawn subagents or isolated worktrees for independent rule families, then review and merge only the best safe fixes.",
48123
49147
  "Split unrelated, broad, or behavior-changing work into separate PRs/branches instead of one large cleanup.",
49148
+ "When one rule spans dozens of files (a migration-scale change), fix a representative sample first, confirm the recipe holds, and get the code owner's sign-off before changing the rest. Don't mass-fix a broad pattern in one unreviewed pass.",
48124
49149
  "For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.",
48125
49150
  "If a fix needs an API, UX, or architecture decision, stop and ask before editing."
48126
49151
  ];
@@ -48130,29 +49155,6 @@ const printAgentGuidance = () => gen(function* () {
48130
49155
  yield* log("");
48131
49156
  });
48132
49157
  //#endregion
48133
- //#region src/cli/utils/diagnostic-grouping.ts
48134
- const buildRulePriorityMap = (scores) => {
48135
- const rulePriority = /* @__PURE__ */ new Map();
48136
- for (const score of scores) {
48137
- if (!score?.rules) continue;
48138
- for (const [ruleKey, info] of Object.entries(score.rules)) if (typeof info.priority === "number") rulePriority.set(ruleKey, info.priority);
48139
- }
48140
- return rulePriority;
48141
- };
48142
- const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
48143
- const priorityA = rulePriority?.get(ruleKeyA);
48144
- const priorityB = rulePriority?.get(ruleKeyB);
48145
- if (priorityA === void 0 && priorityB === void 0) return 0;
48146
- if (priorityA === void 0) return 1;
48147
- if (priorityB === void 0) return -1;
48148
- return priorityB - priorityA;
48149
- };
48150
- const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
48151
- const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
48152
- const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
48153
- const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48154
- const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48155
- //#endregion
48156
49158
  //#region src/cli/utils/box-text.ts
48157
49159
  const ESCAPE = String.fromCharCode(27);
48158
49160
  const ANSI_ESCAPE_PATTERN = new RegExp(`${ESCAPE}\\[[0-9;]*m`, "g");
@@ -48193,6 +49195,15 @@ const boxText = (content, innerWidth) => {
48193
49195
  ].join("\n");
48194
49196
  };
48195
49197
  //#endregion
49198
+ //#region src/cli/utils/resolve-absolute-path.ts
49199
+ /**
49200
+ * Resolves a diagnostic's `filePath` (relative to its project root, or
49201
+ * already absolute) to an absolute path. Shared by the code-frame reader and
49202
+ * the terminal hyperlink builder so both turn a relative path into the same
49203
+ * on-disk location.
49204
+ */
49205
+ const resolveAbsolutePath = (filePath, rootDirectory) => Path.isAbsolute(filePath) ? filePath : Path.resolve(rootDirectory || ".", filePath);
49206
+ //#endregion
48196
49207
  //#region src/cli/utils/build-code-frame.ts
48197
49208
  /**
48198
49209
  * Renders a syntax-highlighted source excerpt around a diagnostic site
@@ -48203,7 +49214,7 @@ const boxText = (content, innerWidth) => {
48203
49214
  */
48204
49215
  const buildCodeFrame = (input) => {
48205
49216
  if (input.line <= 0) return null;
48206
- const absolutePath = Path.isAbsolute(input.filePath) ? input.filePath : Path.resolve(input.rootDirectory || ".", input.filePath);
49217
+ const absolutePath = resolveAbsolutePath(input.filePath, input.rootDirectory);
48207
49218
  let source;
48208
49219
  try {
48209
49220
  source = NFS.readFileSync(absolutePath, "utf8");
@@ -48243,6 +49254,16 @@ const resolveMeasureWidth = (reservedColumns = 0) => resolveClampedWidth({
48243
49254
  const DIVIDER_INDENT = " ";
48244
49255
  const buildSectionDivider = () => highlighter.dim(`${DIVIDER_INDENT}${"─".repeat(resolveMeasureWidth(2))}`);
48245
49256
  //#endregion
49257
+ //#region src/cli/utils/format-hyperlink.ts
49258
+ const OSC = "\x1B]";
49259
+ const ST = "\x1B\\";
49260
+ /**
49261
+ * Wraps `text` in an OSC 8 hyperlink pointing at `uri`. The visible characters
49262
+ * are exactly `text`; the link is carried in escape sequences a capable
49263
+ * terminal turns into a click target.
49264
+ */
49265
+ const formatHyperlink = (text, uri) => `${OSC}8;;${uri}${ST}${text}${OSC}8;;${ST}`;
49266
+ //#endregion
48246
49267
  //#region src/cli/utils/indent-multiline-text.ts
48247
49268
  const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
48248
49269
  //#endregion
@@ -48396,17 +49417,23 @@ const clusterNearbyDiagnostics = (diagnostics) => {
48396
49417
  }
48397
49418
  return clusters;
48398
49419
  };
48399
- const formatClusterLocation = (cluster) => {
49420
+ const formatClusterLocationText = (cluster) => {
49421
+ const { filePath } = cluster.diagnostics[0];
49422
+ if (cluster.startLine <= 0) return filePath;
49423
+ if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
49424
+ return `${filePath}:${cluster.startLine}`;
49425
+ };
49426
+ const formatClusterLocation = (cluster, resolveSourceRoot, hyperlinks) => {
48400
49427
  const lead = cluster.diagnostics[0];
48401
49428
  const contextTag = formatFileContextTag(lead);
48402
- if (cluster.startLine <= 0) return `${lead.filePath}${contextTag}`;
48403
- if (cluster.endLine > cluster.startLine) return `${lead.filePath}:${cluster.startLine}-${cluster.endLine}${contextTag}`;
48404
- return `${lead.filePath}:${cluster.startLine}${contextTag}`;
49429
+ const location = formatClusterLocationText(cluster);
49430
+ if (!hyperlinks) return `${location}${contextTag}`;
49431
+ return `${formatHyperlink(location, pathToFileURL(resolveAbsolutePath(lead.filePath, resolveSourceRoot(lead))).href)}${contextTag}`;
48405
49432
  };
48406
- const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
49433
+ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame, hyperlinks) => {
48407
49434
  const lead = cluster.diagnostics[0];
48408
49435
  const isMultiSite = cluster.diagnostics.length > 1;
48409
- const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
49436
+ const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster, resolveSourceRoot, hyperlinks)}`)];
48410
49437
  const codeFrame = renderCodeFrame ? buildCodeFrame({
48411
49438
  filePath: lead.filePath,
48412
49439
  line: cluster.startLine,
@@ -48425,7 +49452,7 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame
48425
49452
  }
48426
49453
  return lines;
48427
49454
  };
48428
- const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite, isAgentEnvironment) => {
49455
+ const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite, isAgentEnvironment, hyperlinks) => {
48429
49456
  const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
48430
49457
  const { severity } = representative;
48431
49458
  const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
@@ -48439,13 +49466,15 @@ const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, rende
48439
49466
  const impactMessages = isCollapsedWarningGroup ? [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.message))] : [representative.message];
48440
49467
  for (const impactMessage of impactMessages) for (const explanationLine of wrapTextToWidth(impactMessage, resolveMeasureWidth(4), { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
48441
49468
  if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, resolveMeasureWidth(4), { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
49469
+ const sharedFixSiteCount = getSharedFixSiteCount(ruleDiagnostics);
49470
+ if (sharedFixSiteCount > 0) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}↳ One fix clears all ${sharedFixSiteCount} findings.`));
48442
49471
  if (renderEverySite && isAgentEnvironment) {
48443
49472
  const fixRecipeLine = formatFixRecipeLine(representative);
48444
49473
  if (fixRecipeLine) lines.push(highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${fixRecipeLine}`));
48445
49474
  }
48446
49475
  const renderCodeFrame = severity === "error";
48447
49476
  const sites = renderEverySite ? ruleDiagnostics : [representative];
48448
- if (!(isCollapsedWarningGroup && representative.help.includes(representative.filePath))) for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
49477
+ if (!(isCollapsedWarningGroup && representative.help.includes(representative.filePath))) for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame, hyperlinks));
48449
49478
  return lines;
48450
49479
  };
48451
49480
  const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
@@ -48457,8 +49486,21 @@ const buildOverflowSummaryLine = (diagnostics, rulePriority) => {
48457
49486
  const command = highlighter.bold(highlighter.info("npx react-doctor@latest --verbose"));
48458
49487
  return ` ${highlighter.dim("Run")} ${command} ${highlighter.dim("to list every error and warning")}`;
48459
49488
  };
49489
+ const formatMigrationBucketLine = (bucket) => `${TOP_ERROR_DETAIL_INDENT}${bucket.title} ${highlighter.gray(`×${bucket.siteCount} across ${bucket.fileCount} files`)}`;
49490
+ const buildMigrationScaleAdvisoryLines = (diagnostics) => {
49491
+ const buckets = findMigrationScaleBuckets(diagnostics);
49492
+ if (buckets.length === 0) return [];
49493
+ const shownBuckets = buckets.slice(0, 3);
49494
+ const lines = [` ${highlighter.warn("⚠")} ${highlighter.bold("Migration-scale change")}${highlighter.dim(": sample before you sweep")}`, ...shownBuckets.map(formatMigrationBucketLine)];
49495
+ const remainingBuckets = buckets.length - shownBuckets.length;
49496
+ if (remainingBuckets > 0) lines.push(highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}+${remainingBuckets} more ${remainingBuckets === 1 ? "rule" : "rules"} at this scale`));
49497
+ for (const guidanceLine of wrapTextToWidth("Fixing all of them at once is hard to review and prone to subtle mistakes across the whole repo. Fix a representative few first and confirm the recipe holds. Then get the code owner's sign-off before changing the rest.", resolveMeasureWidth(4), { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${guidanceLine}`));
49498
+ const command = highlighter.info("npx react-doctor@latest <path>");
49499
+ lines.push(`${TOP_ERROR_DETAIL_INDENT}${highlighter.dim("Scope it down one area at a time:")} ${command}`);
49500
+ return lines;
49501
+ };
48460
49502
  const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
48461
- const buildTopErrorsSection = (diagnostics, resolveSourceRoot, rulePriority) => {
49503
+ const buildTopErrorsSection = (diagnostics, resolveSourceRoot, hyperlinks, rulePriority) => {
48462
49504
  const topRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority).slice(0, 3);
48463
49505
  if (topRuleGroups.length === 0) return {
48464
49506
  lines: [],
@@ -48468,7 +49510,7 @@ const buildTopErrorsSection = (diagnostics, resolveSourceRoot, rulePriority) =>
48468
49510
  const blockOffsets = [];
48469
49511
  for (const [ruleKey, ruleDiagnostics] of topRuleGroups) {
48470
49512
  blockOffsets.push(lines.length);
48471
- lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false, false));
49513
+ lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false, false, hyperlinks));
48472
49514
  lines.push("");
48473
49515
  }
48474
49516
  return {
@@ -48506,24 +49548,24 @@ const buildOverviewHeaderLines = (diagnostics) => {
48506
49548
  * single Effect.forEach over Console.log so failures or fiber
48507
49549
  * interruption produce predictable partial output.
48508
49550
  */
48509
- const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false, onboarding = {}) => gen(function* () {
49551
+ const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false, onboarding = {}, hyperlinks = false) => gen(function* () {
48510
49552
  const sectionPause = onboarding.sectionPause ?? void_;
48511
49553
  const animateCountUp = onboarding.animateCountUp ?? false;
48512
49554
  const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
48513
49555
  let detailLines;
48514
49556
  let topErrorBlockOffsets = [];
48515
49557
  if (!isVerbose) {
48516
- const topErrors = buildTopErrorsSection(diagnostics, resolveSourceRoot, rulePriority);
49558
+ const topErrors = buildTopErrorsSection(diagnostics, resolveSourceRoot, hyperlinks, rulePriority);
48517
49559
  detailLines = topErrors.lines;
48518
49560
  topErrorBlockOffsets = topErrors.blockOffsets;
48519
49561
  } else detailLines = buildSortedRuleGroups(diagnostics, rulePriority).flatMap(([ruleKey, ruleDiagnostics]) => {
48520
- return [...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true, isAgentEnvironment), ""];
49562
+ return [...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true, isAgentEnvironment, hyperlinks), ""];
48521
49563
  });
48522
49564
  const overflowLine = isVerbose ? void 0 : buildOverflowSummaryLine(diagnostics, rulePriority);
48523
49565
  const categoryTallies = buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCategoryTally);
48524
49566
  const categoryLines = buildCategoryTallyLines(categoryTallies);
48525
49567
  const overviewDividerLines = detailLines.length > 0 && categoryLines.length > 0 ? [buildSectionDivider()] : [];
48526
- const { lines, sectionStarts } = joinSections(detailLines, overviewDividerLines, buildOverviewHeaderLines(diagnostics), categoryLines, overflowLine ? [overflowLine] : []);
49568
+ const { lines, sectionStarts } = joinSections(detailLines, overviewDividerLines, buildOverviewHeaderLines(diagnostics), categoryLines, overflowLine ? [overflowLine] : [], buildMigrationScaleAdvisoryLines(diagnostics));
48527
49569
  const [detailStart, , , categoryStart] = sectionStarts;
48528
49570
  const pauseBeforeLineIndices = detailStart == null ? /* @__PURE__ */ new Set() : new Set(topErrorBlockOffsets.map((offset) => detailStart + offset));
48529
49571
  let lineIndex = 0;
@@ -48578,6 +49620,48 @@ const computeProjectedScore = async (topErrorSource, rescoreSource, currentScore
48578
49620
  //#endregion
48579
49621
  //#region src/cli/utils/filter-diagnostics-by-categories.ts
48580
49622
  const filterDiagnosticsByCategories = (diagnostics, categories) => categories.size === 0 ? [...diagnostics] : diagnostics.filter((diagnostic) => categories.has(diagnostic.category));
49623
+ //#endregion
49624
+ //#region src/cli/utils/supports-hyperlinks.ts
49625
+ const HYPERLINK_CAPABLE_TERM_PROGRAMS = new Set([
49626
+ "iTerm.app",
49627
+ "WezTerm",
49628
+ "vscode",
49629
+ "Hyper",
49630
+ "ghostty",
49631
+ "Tabby",
49632
+ "rio"
49633
+ ]);
49634
+ const parseVteVersion = (raw) => {
49635
+ const parsed = Number.parseInt(raw ?? "", 10);
49636
+ return Number.isNaN(parsed) ? 0 : parsed;
49637
+ };
49638
+ /**
49639
+ * Whether `stream` is a terminal that renders OSC 8 hyperlinks. Auto-detected
49640
+ * from terminal-identity env vars; the de-facto `FORCE_HYPERLINK` env var
49641
+ * overrides detection (`FORCE_HYPERLINK=0`/`false` forces off, any other value
49642
+ * forces on), mirroring how the ecosystem's terminal libraries gate the same
49643
+ * feature. Off for non-TTYs, `TERM=dumb`, and CI (whose log viewers render the
49644
+ * raw escape rather than a link). Unknown terminals default to off.
49645
+ */
49646
+ const supportsHyperlinks = (stream = process.stdout, env = process.env) => {
49647
+ const forced = env.FORCE_HYPERLINK;
49648
+ if (forced !== void 0 && forced !== "") return forced !== "0" && forced.toLowerCase() !== "false";
49649
+ if (stream.isTTY !== true) return false;
49650
+ if (env.TERM === "dumb") return false;
49651
+ if (isCiEnvironment(env)) return false;
49652
+ if (env.WT_SESSION) return true;
49653
+ if (env.KITTY_WINDOW_ID || env.TERM === "xterm-kitty") return true;
49654
+ if (parseVteVersion(env.VTE_VERSION) >= 5e3) return true;
49655
+ return Boolean(env.TERM_PROGRAM && HYPERLINK_CAPABLE_TERM_PROGRAMS.has(env.TERM_PROGRAM));
49656
+ };
49657
+ //#endregion
49658
+ //#region src/cli/utils/should-render-hyperlinks.ts
49659
+ /**
49660
+ * Whether to emit OSC 8 clickable `file:line` locations for this run: a
49661
+ * hyperlink-capable terminal AND not a coding agent (whose output parsers
49662
+ * would choke on the escape sequences).
49663
+ */
49664
+ const shouldRenderHyperlinks = (stream = process.stdout) => supportsHyperlinks(stream) && !isCodingAgentEnvironment();
48581
49665
  const FORCE_ONBOARDING_ENV_VAR = "REACT_DOCTOR_FORCE_ONBOARDING";
48582
49666
  const FALSY_FLAG_VALUES = new Set([
48583
49667
  "",
@@ -48596,26 +49680,182 @@ const canAnimateOnboarding = (stream = process.stdout) => {
48596
49680
  return !isGitHookEnvironment() && !isCiEnvironment();
48597
49681
  };
48598
49682
  //#endregion
48599
- //#region src/cli/utils/onboarding-state.ts
48600
- const GLOBAL_CONFIG_PROJECT_NAME$2 = "react-doctor";
48601
- const ONBOARDED_AT_KEY = "onboardedAt";
48602
- const getOnboardingStore = (options = {}) => new Conf({
48603
- projectName: GLOBAL_CONFIG_PROJECT_NAME$2,
48604
- cwd: options.cwd
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({
49739
+ projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
49740
+ cwd: resolveConfigDir(options)
48605
49741
  });
48606
- 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 = {}) => {
48607
49748
  try {
48608
- return typeof getOnboardingStore(options).get(ONBOARDED_AT_KEY) === "string";
49749
+ return select(openMigratedStore(options).store);
48609
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);
48610
49758
  return true;
49759
+ } catch {
49760
+ return false;
48611
49761
  }
48612
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);
48613
49857
  const markOnboardingComplete = (options = {}) => {
48614
- try {
48615
- const store = getOnboardingStore(options);
48616
- if (typeof store.get(ONBOARDED_AT_KEY) === "string") return;
48617
- store.set(ONBOARDED_AT_KEY, (/* @__PURE__ */ new Date()).toISOString());
48618
- } catch {}
49858
+ if (isGatePending(ONBOARDING_GATE, {}, options)) recordGate(ONBOARDING_GATE, {}, options);
48619
49859
  };
48620
49860
  //#endregion
48621
49861
  //#region src/cli/utils/render-project-detection.ts
@@ -49056,6 +50296,78 @@ const resolveCliCategories = (categoryFlag) => {
49056
50296
  return resolvedCategories.length > 0 ? resolvedCategories : void 0;
49057
50297
  };
49058
50298
  //#endregion
50299
+ //#region src/cli/utils/git-hook-shared.ts
50300
+ const HOOK_FILE_NAME = "pre-commit";
50301
+ const HOOK_RELATIVE_PATH = "hooks/pre-commit";
50302
+ const LEGACY_HOOK_RUNNER_RELATIVE_PATH = ".react-doctor/hooks/pre-commit";
50303
+ const HUSKY_HOOKS_PATH = ".husky";
50304
+ const VITE_PLUS_HOOKS_PATH = ".vite-hooks";
50305
+ const LEFTHOOK_CONFIG_FILES = ["lefthook.yml", "lefthook.yaml"];
50306
+ const PRE_COMMIT_CONFIG_FILE = ".pre-commit-config.yaml";
50307
+ const OVERCOMMIT_CONFIG_FILE = ".overcommit.yml";
50308
+ const REACT_DOCTOR_COMMAND = "react-doctor --staged --blocking warning";
50309
+ const NON_BLOCKING_REACT_DOCTOR_COMMAND = [
50310
+ "react_doctor_output=$(mktemp \"${TMPDIR:-/tmp}/react-doctor-hook.XXXXXX\");",
50311
+ `if ${REACT_DOCTOR_COMMAND} > "$react_doctor_output" 2>&1; then`,
50312
+ "rm -f \"$react_doctor_output\";",
50313
+ "else",
50314
+ "rm -f \"$react_doctor_output\";",
50315
+ `printf "%s\\n" "React Doctor found staged regressions." "Run ${REACT_DOCTOR_COMMAND} to inspect." "Want them fixed? Ask your agent to run that command and resolve the findings." >&2;`,
50316
+ "fi"
50317
+ ].join(" ");
50318
+ const PACKAGE_JSON_FILE_NAME = "package.json";
50319
+ const runGit = (projectRoot, args) => {
50320
+ try {
50321
+ return execFileSync("git", [...args], {
50322
+ cwd: projectRoot,
50323
+ encoding: "utf8",
50324
+ stdio: [
50325
+ "ignore",
50326
+ "pipe",
50327
+ "ignore"
50328
+ ]
50329
+ }).trim();
50330
+ } catch {
50331
+ return null;
50332
+ }
50333
+ };
50334
+ const resolveGitPath = (baseDirectory, value) => Path.isAbsolute(value) ? value : Path.resolve(baseDirectory, value);
50335
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
50336
+ const getPackageJsonPath = (projectRoot) => Path.join(projectRoot, PACKAGE_JSON_FILE_NAME);
50337
+ const readPackageJson = (projectRoot) => {
50338
+ try {
50339
+ return JSON.parse(NFS.readFileSync(getPackageJsonPath(projectRoot), "utf8"));
50340
+ } catch {
50341
+ return null;
50342
+ }
50343
+ };
50344
+ const writeJsonFile$1 = (filePath, value) => {
50345
+ NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
50346
+ };
50347
+ const packageHasDependency = (projectRoot, dependencyName) => {
50348
+ const packageJson = readPackageJson(projectRoot);
50349
+ if (!isRecord$1(packageJson)) return false;
50350
+ return [
50351
+ "dependencies",
50352
+ "devDependencies",
50353
+ "optionalDependencies"
50354
+ ].some((fieldName) => {
50355
+ const dependencies = packageJson[fieldName];
50356
+ return isRecord$1(dependencies) && typeof dependencies[dependencyName] === "string";
50357
+ });
50358
+ };
50359
+ const packageHasRecordKey = (projectRoot, key) => {
50360
+ const packageJson = readPackageJson(projectRoot);
50361
+ return isRecord$1(packageJson) && isRecord$1(packageJson[key]);
50362
+ };
50363
+ const packageHasNestedRecordKey = (projectRoot, key, nestedKey) => {
50364
+ const packageJson = readPackageJson(projectRoot);
50365
+ if (!isRecord$1(packageJson)) return false;
50366
+ const value = packageJson[key];
50367
+ return isRecord$1(value) && isRecord$1(value[nestedKey]);
50368
+ };
50369
+ const ensureTrailingNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
50370
+ //#endregion
49059
50371
  //#region src/cli/utils/scan-result-cache.ts
49060
50372
  const CACHE_DISABLED_VALUES = new Set(["1", "true"]);
49061
50373
  const TOOLCHAIN_PACKAGE_SPECIFIERS = [
@@ -49066,7 +50378,7 @@ const TOOLCHAIN_PACKAGE_SPECIFIERS = [
49066
50378
  "eslint-plugin-react-hooks/package.json"
49067
50379
  ];
49068
50380
  const bundledRequire = createRequire(import.meta.url);
49069
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
50381
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
49070
50382
  const normalizeForStableJson = (value) => {
49071
50383
  if (value === null) return null;
49072
50384
  if (value === void 0) return void 0;
@@ -49095,24 +50407,9 @@ const stringifyStableJson = (value) => {
49095
50407
  }
49096
50408
  };
49097
50409
  const hashString = (value) => crypto.createHash("sha1").update(value).digest("hex");
49098
- const runGit$1 = (directory, args) => {
49099
- try {
49100
- return execFileSync("git", [...args], {
49101
- cwd: directory,
49102
- encoding: "utf8",
49103
- stdio: [
49104
- "ignore",
49105
- "pipe",
49106
- "ignore"
49107
- ]
49108
- }).trim();
49109
- } catch {
49110
- return null;
49111
- }
49112
- };
49113
- const readHeadSha = (projectDirectory) => runGit$1(projectDirectory, ["rev-parse", "HEAD"]);
50410
+ const readHeadSha = (projectDirectory) => runGit(projectDirectory, ["rev-parse", "HEAD"]);
49114
50411
  const isWorktreeClean = (projectDirectory) => {
49115
- const status = runGit$1(projectDirectory, [
50412
+ const status = runGit(projectDirectory, [
49116
50413
  "status",
49117
50414
  "--porcelain=v1",
49118
50415
  "--untracked-files=normal"
@@ -49120,7 +50417,7 @@ const isWorktreeClean = (projectDirectory) => {
49120
50417
  return status !== null && status.length === 0;
49121
50418
  };
49122
50419
  const hasHiddenTrackedFileState = (projectDirectory) => {
49123
- const output = runGit$1(projectDirectory, ["ls-files", "-v"]);
50420
+ const output = runGit(projectDirectory, ["ls-files", "-v"]);
49124
50421
  if (output === null) return true;
49125
50422
  return output.split("\n").some((line) => line.length > 0 && line[0] !== "H");
49126
50423
  };
@@ -49133,27 +50430,27 @@ const resolveCacheFilePath = (projectDirectory) => {
49133
50430
  const readPersistedCache = (cacheFilePath) => {
49134
50431
  try {
49135
50432
  const parsed = JSON.parse(fs.readFileSync(cacheFilePath, "utf8"));
49136
- if (!isRecord$1(parsed) || parsed.version !== 1) return {
49137
- version: 1,
50433
+ if (!isRecord(parsed) || parsed.version !== 2) return {
50434
+ version: 2,
49138
50435
  entries: []
49139
50436
  };
49140
50437
  if (!Array.isArray(parsed.entries)) return {
49141
- version: 1,
50438
+ version: 2,
49142
50439
  entries: []
49143
50440
  };
49144
50441
  const entries = [];
49145
50442
  for (const entry of parsed.entries) {
49146
- if (!isRecord$1(entry) || typeof entry.key !== "string" || typeof entry.createdAtMs !== "number") continue;
49147
- if (!isRecord$1(entry.payload) || !Array.isArray(entry.payload.diagnostics)) continue;
50443
+ if (!isRecord(entry) || typeof entry.key !== "string" || typeof entry.createdAtMs !== "number") continue;
50444
+ if (!isRecord(entry.payload) || !Array.isArray(entry.payload.diagnostics)) continue;
49148
50445
  entries.push(entry);
49149
50446
  }
49150
50447
  return {
49151
- version: 1,
50448
+ version: 2,
49152
50449
  entries
49153
50450
  };
49154
50451
  } catch {
49155
50452
  return {
49156
- version: 1,
50453
+ version: 2,
49157
50454
  entries: []
49158
50455
  };
49159
50456
  }
@@ -49206,7 +50503,7 @@ const buildScanResultCacheKey = (input) => {
49206
50503
  if (headSha === null) return null;
49207
50504
  if (stringifyStableJson(input.userConfig) === null) return null;
49208
50505
  const cacheKeyJson = stringifyStableJson({
49209
- schemaVersion: 1,
50506
+ schemaVersion: 2,
49210
50507
  projectIdentity: resolveProjectIdentity(input.projectDirectory),
49211
50508
  headSha,
49212
50509
  reactDoctorVersion: input.version,
@@ -49226,6 +50523,7 @@ const buildScanResultCacheKey = (input) => {
49226
50523
  adoptExistingLintConfig: input.options.adoptExistingLintConfig,
49227
50524
  ignoredTags: [...input.options.ignoredTags].sort(),
49228
50525
  concurrency: input.options.concurrency,
50526
+ lintBatchOrdering: resolveLintBatchOrdering(),
49229
50527
  baselineRef: input.options.baseline?.ref,
49230
50528
  changedLineRanges: input.options.changedLineRanges ?? void 0,
49231
50529
  noScore: input.options.noScore,
@@ -49243,7 +50541,7 @@ const createScanResultCache = (projectDirectory) => {
49243
50541
  for (const entry of persistedCache.entries) entries.set(entry.key, entry);
49244
50542
  const persist = () => {
49245
50543
  writePersistedCache(cacheFilePath, {
49246
- version: 1,
50544
+ version: 2,
49247
50545
  entries: [...entries.values()].sort((firstEntry, secondEntry) => secondEntry.createdAtMs - firstEntry.createdAtMs).slice(0, 20)
49248
50546
  });
49249
50547
  };
@@ -49259,7 +50557,7 @@ const createScanResultCache = (projectDirectory) => {
49259
50557
  }
49260
50558
  };
49261
50559
  };
49262
- 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;
49263
50561
  //#endregion
49264
50562
  //#region src/inspect.ts
49265
50563
  const silentConsole = makeNoopConsole();
@@ -49323,21 +50621,24 @@ const deriveScope = (options) => {
49323
50621
  if (options.changedLineRanges !== null) return "lines";
49324
50622
  return options.includePaths.length > 0 ? "files" : "full";
49325
50623
  };
49326
- const buildRunEventConfig = (options, userConfig, hasCustomConfig) => ({
49327
- scope: deriveScope(options),
49328
- parallel: options.concurrency !== void 0,
49329
- workerCount: options.concurrency,
49330
- lint: options.lint,
49331
- deadCode: options.deadCode,
49332
- scoreOnly: options.scoreOnly,
49333
- noScore: options.noScore,
49334
- respectInlineDisables: options.respectInlineDisables,
49335
- showWarnings: options.warnings,
49336
- usedOutputDir: options.outputDirectory !== null,
49337
- ignoredTagCount: options.ignoredTags.size,
49338
- hasCustomConfig,
49339
- userConfig
49340
- });
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
+ };
49341
50642
  const inspect = async (directory, inputOptions = {}) => {
49342
50643
  const startTime = performance$1.now();
49343
50644
  const isConcurrentScan = inputOptions.concurrentScan === true;
@@ -49429,7 +50730,7 @@ const runBaselineComparison = async (params) => {
49429
50730
  resolveLocalGithubViewerPermission: false,
49430
50731
  suppressScanSummary: true,
49431
50732
  supplyChainManifestChanged: params.options.supplyChainManifestChanged
49432
- }, {}).pipe(provide(baseLayers), provideService(Console, silentConsole))));
50733
+ }, {}).pipe(provide(baseLayers), provideService$2(Console, silentConsole))));
49433
50734
  if (baseOutput.didLintFail) return null;
49434
50735
  const delta = computeDiagnosticDelta({
49435
50736
  headDiagnostics: params.headDiagnostics,
@@ -49514,7 +50815,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49514
50815
  runId: getRunId(),
49515
50816
  resolveLocalGithubViewerPermission: !options.noScore,
49516
50817
  suppressScanSummary: options.suppressRendering,
49517
- supplyChainManifestChanged: options.supplyChainManifestChanged
50818
+ supplyChainManifestChanged: options.supplyChainManifestChanged,
50819
+ concurrentScan: options.concurrentScan
49518
50820
  }, { beforeLint: (projectInfo, lintIncludePaths) => gen(function* () {
49519
50821
  recordSentryProjectContext(projectInfo, rootSentrySpan, { concurrentScan: options.concurrentScan });
49520
50822
  recordCount(METRIC.projectDetected, 1);
@@ -49528,7 +50830,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49528
50830
  lintSourceFileCount
49529
50831
  });
49530
50832
  }) });
49531
- 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)));
49532
50834
  const didLintFail = lintBindingMissing || output.didLintFail;
49533
50835
  const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
49534
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`)));
@@ -49565,12 +50867,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49565
50867
  lintPartialFailures: output.lintPartialFailures,
49566
50868
  didDeadCodeFail: output.didDeadCodeFail,
49567
50869
  deadCodeFailureReason: output.deadCodeFailureReason,
50870
+ deadCodeOverlapped: output.deadCodeOverlapped,
49568
50871
  directory: output.resolvedDirectory,
49569
50872
  scannedFileCount: output.scannedFileCount,
49570
50873
  scannedFilePaths: output.scannedFilePaths,
49571
50874
  scanElapsedMilliseconds: output.scanElapsedMilliseconds,
50875
+ scanConcurrency: output.scanConcurrency,
49572
50876
  baselineDelta,
49573
- lintFailureReasonKind: lintBindingMissing ? "native-binding-missing" : output.lintFailureReasonKind
50877
+ lintFailureReasonKind: lintBindingMissing ? "native-binding-missing" : output.lintFailureReasonKind,
50878
+ supplyChainOverlapTimedOut: output.supplyChainOverlapTimedOut
49574
50879
  };
49575
50880
  if (cacheKey !== null && scanResultCache !== null && shouldStoreScanPayload(payload)) scanResultCache.store(cacheKey, payload);
49576
50881
  const result = await renderAndRecordScan({
@@ -49581,12 +50886,14 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
49581
50886
  startTime,
49582
50887
  rootSentrySpan,
49583
50888
  scanMode: baselineDelta ? "baseline" : isDiffMode ? "diff" : "full",
49584
- baselineDegraded
50889
+ baselineDegraded,
50890
+ lintCacheHitFileCount: output.lintCacheHitFileCount,
50891
+ lintCacheTotalFileCount: output.lintCacheTotalFileCount
49585
50892
  });
49586
50893
  recordOnboardingCompletion(options);
49587
50894
  return result;
49588
50895
  };
49589
- const runMaybeSilent = (effect, silent) => silent ? effect.pipe(provideService(Console, silentConsole)) : effect;
50896
+ const runMaybeSilent = (effect, silent) => silent ? effect.pipe(provideService$2(Console, silentConsole)) : effect;
49590
50897
  const renderCachedProjectDetection = async (input) => {
49591
50898
  if (input.options.scoreOnly || input.options.suppressRendering) return;
49592
50899
  await runPromise(runMaybeSilent(printProjectDetection({
@@ -49614,14 +50921,17 @@ const renderAndRecordScan = async (input) => {
49614
50921
  scannedFileCount: input.payload.scannedFileCount,
49615
50922
  scannedFilePaths: input.payload.scannedFilePaths,
49616
50923
  scanElapsedMilliseconds: input.payload.scanElapsedMilliseconds,
50924
+ lintCacheHitFileCount: input.lintCacheHitFileCount ?? null,
50925
+ lintCacheTotalFileCount: input.lintCacheTotalFileCount ?? null,
49617
50926
  baselineDelta: input.payload.baselineDelta
49618
50927
  }), input.options.silent));
50928
+ const { workerCount: resolvedWorkerCount, parallel } = resolveWorkerTelemetry(input.payload.scanConcurrency, input.options.concurrency);
49619
50929
  recordScanMetrics({
49620
50930
  result,
49621
50931
  mode: input.scanMode,
49622
50932
  baselineDegraded: input.baselineDegraded,
49623
- parallel: input.options.concurrency !== void 0,
49624
- workerCount: input.options.concurrency,
50933
+ parallel,
50934
+ workerCount: resolvedWorkerCount,
49625
50935
  lint: input.options.lint,
49626
50936
  deadCode: input.options.deadCode,
49627
50937
  scoreOnly: input.options.scoreOnly,
@@ -49631,19 +50941,22 @@ const renderAndRecordScan = async (input) => {
49631
50941
  didDeadCodeFail: input.payload.didDeadCodeFail
49632
50942
  });
49633
50943
  recordRunEvent(input.rootSentrySpan, {
49634
- ...buildRunEventConfig(input.options, input.userConfig, input.hasCustomConfig),
50944
+ ...buildRunEventConfig(input.options, input.userConfig, input.hasCustomConfig, resolvedWorkerCount),
49635
50945
  result,
49636
50946
  mode: input.scanMode,
49637
50947
  gateExempt: input.baselineDegraded,
49638
50948
  didLintFail: input.payload.didLintFail,
49639
50949
  lintFailureReasonKind: input.payload.lintFailureReasonKind,
49640
50950
  lintPartialFailureCount: input.payload.lintPartialFailures.length,
49641
- 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
49642
50955
  });
49643
50956
  return result;
49644
50957
  };
49645
50958
  const finalizeAndRender = (input) => gen(function* () {
49646
- 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;
49647
50960
  const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
49648
50961
  didLintFail,
49649
50962
  lintFailureReason,
@@ -49663,6 +50976,10 @@ const finalizeAndRender = (input) => gen(function* () {
49663
50976
  scannedFileCount,
49664
50977
  scannedFilePaths,
49665
50978
  scanElapsedMilliseconds,
50979
+ ...lintCacheTotalFileCount !== null ? {
50980
+ lintCacheHitFileCount,
50981
+ lintCacheTotalFileCount
50982
+ } : {},
49666
50983
  ...baselineDelta ? { baselineDelta } : {}
49667
50984
  });
49668
50985
  if (options.suppressRendering) return buildResult();
@@ -49676,6 +50993,7 @@ const finalizeAndRender = (input) => gen(function* () {
49676
50993
  }
49677
50994
  const animateRender = !options.silent && !options.verbose && canAnimateOnboarding(process.stdout);
49678
50995
  const pause = onboardingSectionPause(animateRender);
50996
+ const useHyperlinks = shouldRenderHyperlinks(process.stdout);
49679
50997
  const demotedDiagnosticCount = diagnostics.length - surfaceDiagnostics.length;
49680
50998
  const lintSourceFileCount = options.includePaths.length > 0 ? options.includePaths.length : project.sourceFileCount;
49681
50999
  if (printedDiagnostics.length === 0) {
@@ -49701,7 +51019,7 @@ const finalizeAndRender = (input) => gen(function* () {
49701
51019
  yield* printDiagnostics([...printedDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment(), {
49702
51020
  sectionPause: pause,
49703
51021
  animateCountUp: animateRender
49704
- });
51022
+ }, useHyperlinks);
49705
51023
  if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
49706
51024
  if (options.categoryFilters.size === 0 && demotedDiagnosticCount > 0) {
49707
51025
  yield* log(highlighter.gray(` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`));
@@ -49890,24 +51208,18 @@ const isExpectedUserError = (error) => error instanceof CliInputError || isProje
49890
51208
  //#region src/cli/utils/build-handoff-payload.ts
49891
51209
  const buildHandoffPayload = (input) => {
49892
51210
  const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
51211
+ const migrationScaleBuckets = new Map(findMigrationScaleBuckets(input.diagnostics).map((bucket) => [bucket.ruleKey, bucket]));
49893
51212
  let outputDirectory = null;
49894
51213
  try {
49895
51214
  outputDirectory = writeDiagnosticsDirectory([...input.diagnostics], input.outputDirectory);
49896
51215
  } catch {}
49897
- const lines = [
49898
- "Before fixing anything, first ask me whether I'd like to add React Doctor to CI so every pull request is scanned automatically. Explain that:",
49899
- `- It's used by teams at ${CI_TRUST_COMPANIES}.`,
49900
- "- I don't have to fix everything at once; CI keeps new PRs clean while I incrementally pay down the existing issues.",
49901
- `- Setup guide: ${CI_URL}`,
49902
- "If I say yes, run `npx react-doctor install` (or add the GitHub Actions workflow). Either way, then continue with the fixes below.",
49903
- "",
49904
- `Fix the top ${topGroups.length} React Doctor ${topGroups.length === 1 ? "issue" : "issues"} in ${input.projectName} on this pass — leave the rest for a follow-up.`,
49905
- ""
49906
- ];
51216
+ const lines = [`Fix the top ${topGroups.length} React Doctor ${topGroups.length === 1 ? "issue" : "issues"} in ${input.projectName} on this pass — leave the rest for a follow-up.`, ""];
49907
51217
  topGroups.forEach(([ruleKey, ruleDiagnostics], index) => {
49908
51218
  const representative = ruleDiagnostics[0];
49909
51219
  const severityLabel = representative.severity === "error" ? "ERROR" : "WARN";
49910
- lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (×${ruleDiagnostics.length})`, ` ${representative.message}`);
51220
+ const sharedFixSiteCount = getSharedFixSiteCount(ruleDiagnostics);
51221
+ const countBadge = sharedFixSiteCount > 0 ? `one fix · ${sharedFixSiteCount} sites` : `×${ruleDiagnostics.length}`;
51222
+ lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (${countBadge})`, ` ${representative.message}`);
49911
51223
  const fixRecipeLine = formatFixRecipeLine(representative);
49912
51224
  if (fixRecipeLine) lines.push(` ${fixRecipeLine}`);
49913
51225
  const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
@@ -49917,10 +51229,19 @@ const buildHandoffPayload = (input) => {
49917
51229
  }
49918
51230
  const remainingFiles = uniqueFiles.length - 3;
49919
51231
  if (remainingFiles > 0) lines.push(` - +${remainingFiles} more files`);
51232
+ const migrationBucket = migrationScaleBuckets.get(ruleKey);
51233
+ if (migrationBucket) lines.push(` Migration-scale (${migrationBucket.fileCount} files): fix a representative sample, confirm the recipe holds, and get the code owner's sign-off before changing the rest in one pass.`);
49920
51234
  });
49921
51235
  lines.push("");
49922
51236
  if (outputDirectory) lines.push(`Full results for all ${input.diagnostics.length} issues (diagnostics.json + a .txt per rule): ${outputDirectory}`, "");
49923
- lines.push("Read each file and fix the root cause — don't suppress or silence the rule.", "", "Verify against the real thing, don't assume: confirm each change matches the canonical fix recipe you fetched for that rule, then re-run `npx react-doctor@latest --verbose` and check the issue is actually gone against the real tool before moving on.", "", "Teach me as you go: for every issue you touch, explain it in plain language (no jargon) — what the problem is, why it's a problem, and how serious it is in human terms. Describe the real-world impact and severity concretely (e.g. \"this crashes the page for users on Safari\" vs. \"this is a minor cleanup with no user impact\") so I understand why it matters, not just what changed.", "", "Then work through the rest from the full results above.");
51237
+ lines.push("Read each file and fix the root cause — don't suppress or silence the rule.", "", "Findings that share a `fixGroupId` (in diagnostics.json) are one root cause — a single fix clears all of them, so treat each `fixGroupId` as ONE task, not one per site.", "", "Verify against the real thing, don't assume: confirm each change matches the canonical fix recipe you fetched for that rule, then re-run `npx react-doctor@latest --verbose` and check the issue is actually gone against the real tool before moving on.", "", "Teach me as you go: for every issue you touch, explain it in plain language (no jargon) — what the problem is, why it's a problem, and how serious it is in human terms. Describe the real-world impact and severity concretely (e.g. \"this crashes the page for users on Safari\" vs. \"this is a minor cleanup with no user impact\") so I understand why it matters, not just what changed.", "");
51238
+ const shownRuleKeys = new Set(topGroups.map(([ruleKey]) => ruleKey));
51239
+ const deferredMigrationBuckets = [...migrationScaleBuckets.values()].filter((bucket) => !shownRuleKeys.has(bucket.ruleKey));
51240
+ if (deferredMigrationBuckets.length > 0) {
51241
+ const ruleSummaries = deferredMigrationBuckets.map((bucket) => `${bucket.title} (${bucket.fileCount} files)`).join(", ");
51242
+ lines.push(`Some of the rest are migration-scale (span dozens of files): ${ruleSummaries}. For each, fix a representative sample, confirm the recipe holds, and get the code owner's sign-off before changing the rest in one pass.`, "");
51243
+ }
51244
+ lines.push("Then work through the rest from the full results above.");
49924
51245
  return lines.join("\n");
49925
51246
  };
49926
51247
  //#endregion
@@ -49964,78 +51285,6 @@ const detectAvailableAgents = async () => {
49964
51285
  return getSkillAgentTypes().filter((agent) => agent !== "universal" && detected.has(agent));
49965
51286
  };
49966
51287
  //#endregion
49967
- //#region src/cli/utils/git-hook-shared.ts
49968
- const HOOK_FILE_NAME = "pre-commit";
49969
- const HOOK_RELATIVE_PATH = "hooks/pre-commit";
49970
- const LEGACY_HOOK_RUNNER_RELATIVE_PATH = ".react-doctor/hooks/pre-commit";
49971
- const HUSKY_HOOKS_PATH = ".husky";
49972
- const VITE_PLUS_HOOKS_PATH = ".vite-hooks";
49973
- const LEFTHOOK_CONFIG_FILES = ["lefthook.yml", "lefthook.yaml"];
49974
- const PRE_COMMIT_CONFIG_FILE = ".pre-commit-config.yaml";
49975
- const OVERCOMMIT_CONFIG_FILE = ".overcommit.yml";
49976
- const REACT_DOCTOR_COMMAND = "react-doctor --staged --blocking warning";
49977
- const NON_BLOCKING_REACT_DOCTOR_COMMAND = [
49978
- "react_doctor_output=$(mktemp \"${TMPDIR:-/tmp}/react-doctor-hook.XXXXXX\");",
49979
- `if ${REACT_DOCTOR_COMMAND} > "$react_doctor_output" 2>&1; then`,
49980
- "rm -f \"$react_doctor_output\";",
49981
- "else",
49982
- "rm -f \"$react_doctor_output\";",
49983
- `printf "%s\\n" "React Doctor found staged regressions." "Run ${REACT_DOCTOR_COMMAND} to inspect." "Want them fixed? Ask your agent to run that command and resolve the findings." >&2;`,
49984
- "fi"
49985
- ].join(" ");
49986
- const PACKAGE_JSON_FILE_NAME = "package.json";
49987
- const runGit = (projectRoot, args) => {
49988
- try {
49989
- return execFileSync("git", [...args], {
49990
- cwd: projectRoot,
49991
- encoding: "utf8",
49992
- stdio: [
49993
- "ignore",
49994
- "pipe",
49995
- "ignore"
49996
- ]
49997
- }).trim();
49998
- } catch {
49999
- return null;
50000
- }
50001
- };
50002
- const resolveGitPath = (baseDirectory, value) => Path.isAbsolute(value) ? value : Path.resolve(baseDirectory, value);
50003
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
50004
- const getPackageJsonPath = (projectRoot) => Path.join(projectRoot, PACKAGE_JSON_FILE_NAME);
50005
- const readPackageJson = (projectRoot) => {
50006
- try {
50007
- return JSON.parse(NFS.readFileSync(getPackageJsonPath(projectRoot), "utf8"));
50008
- } catch {
50009
- return null;
50010
- }
50011
- };
50012
- const writeJsonFile$1 = (filePath, value) => {
50013
- NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
50014
- };
50015
- const packageHasDependency = (projectRoot, dependencyName) => {
50016
- const packageJson = readPackageJson(projectRoot);
50017
- if (!isRecord(packageJson)) return false;
50018
- return [
50019
- "dependencies",
50020
- "devDependencies",
50021
- "optionalDependencies"
50022
- ].some((fieldName) => {
50023
- const dependencies = packageJson[fieldName];
50024
- return isRecord(dependencies) && typeof dependencies[dependencyName] === "string";
50025
- });
50026
- };
50027
- const packageHasRecordKey = (projectRoot, key) => {
50028
- const packageJson = readPackageJson(projectRoot);
50029
- return isRecord(packageJson) && isRecord(packageJson[key]);
50030
- };
50031
- const packageHasNestedRecordKey = (projectRoot, key, nestedKey) => {
50032
- const packageJson = readPackageJson(projectRoot);
50033
- if (!isRecord(packageJson)) return false;
50034
- const value = packageJson[key];
50035
- return isRecord(value) && isRecord(value[nestedKey]);
50036
- };
50037
- const ensureTrailingNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
50038
- //#endregion
50039
51288
  //#region src/cli/utils/install-doctor-script.ts
50040
51289
  const DOCTOR_SCRIPT_NAME = "doctor";
50041
51290
  const FALLBACK_DOCTOR_SCRIPT_NAME = "react-doctor";
@@ -50061,31 +51310,31 @@ const findNearestPackageDirectory = (startDirectory, stopDirectory) => {
50061
51310
  };
50062
51311
  const hasDoctorScript = (projectRoot) => {
50063
51312
  const packageJson = readPackageJson(findNearestPackageDirectory(projectRoot) ?? projectRoot);
50064
- if (!isRecord(packageJson)) return false;
51313
+ if (!isRecord$1(packageJson)) return false;
50065
51314
  const scripts = packageJson.scripts;
50066
- if (!isRecord(scripts)) return false;
51315
+ if (!isRecord$1(scripts)) return false;
50067
51316
  return isReactDoctorScriptCommand(scripts[DOCTOR_SCRIPT_NAME]) || isReactDoctorScriptCommand(scripts[FALLBACK_DOCTOR_SCRIPT_NAME]);
50068
51317
  };
50069
51318
  const hasDoctorDependency = (packageJson) => DEPENDENCY_FIELD_NAMES.some((fieldName) => {
50070
51319
  const dependencies = packageJson[fieldName];
50071
- return isRecord(dependencies) && Object.hasOwn(dependencies, "react-doctor");
51320
+ return isRecord$1(dependencies) && Object.hasOwn(dependencies, "react-doctor");
50072
51321
  });
50073
51322
  const installDoctorScript = (options) => {
50074
51323
  const packageDirectory = findNearestPackageDirectory(options.projectRoot) ?? options.projectRoot;
50075
51324
  const packageJsonPath = getPackageJsonPath(packageDirectory);
50076
51325
  const packageJson = readPackageJson(packageDirectory);
50077
- if (!isRecord(packageJson)) return {
51326
+ if (!isRecord$1(packageJson)) return {
50078
51327
  packageJsonPath,
50079
51328
  scriptStatus: "skipped",
50080
51329
  scriptReason: "missing-or-invalid-package-json"
50081
51330
  };
50082
51331
  const scripts = packageJson.scripts;
50083
51332
  const scriptTarget = (() => {
50084
- if (scripts !== void 0 && !isRecord(scripts)) return {
51333
+ if (scripts !== void 0 && !isRecord$1(scripts)) return {
50085
51334
  status: "skipped",
50086
51335
  reason: "invalid-scripts"
50087
51336
  };
50088
- const scriptRecord = isRecord(scripts) ? scripts : {};
51337
+ const scriptRecord = isRecord$1(scripts) ? scripts : {};
50089
51338
  if (isReactDoctorScriptCommand(scriptRecord[DOCTOR_SCRIPT_NAME])) return {
50090
51339
  scriptName: DOCTOR_SCRIPT_NAME,
50091
51340
  status: "existing"
@@ -50119,7 +51368,7 @@ const installDoctorScript = (options) => {
50119
51368
  if (scriptStatus === "created") writeJsonFile$1(packageJsonPath, {
50120
51369
  ...packageJson,
50121
51370
  scripts: {
50122
- ...isRecord(scripts) ? scripts : {},
51371
+ ...isRecord$1(scripts) ? scripts : {},
50123
51372
  [scriptTarget.scriptName ?? DOCTOR_SCRIPT_NAME]: DOCTOR_SCRIPT_COMMAND
50124
51373
  }
50125
51374
  });
@@ -50270,40 +51519,27 @@ const upgradeReactDoctorWorkflowInPlace = (projectRoot) => {
50270
51519
  }
50271
51520
  };
50272
51521
  //#endregion
50273
- //#region src/cli/utils/hash-project-root.ts
50274
- const hashProjectRoot = (projectRoot) => createHash("sha256").update(Path.resolve(projectRoot)).digest("hex");
50275
- //#endregion
50276
51522
  //#region src/cli/utils/action-upgrade-prompt.ts
50277
- const GLOBAL_CONFIG_PROJECT_NAME$1 = "react-doctor";
50278
- const getActionUpgradeStore = (options = {}) => new Conf({
50279
- projectName: GLOBAL_CONFIG_PROJECT_NAME$1,
50280
- cwd: options.cwd
50281
- });
50282
- const hasHandledActionUpgrade = (projectRoot, storeOptions = {}) => {
50283
- try {
50284
- const upgrades = getActionUpgradeStore(storeOptions).get("actionUpgrades", {});
50285
- return Boolean(upgrades[hashProjectRoot(projectRoot)]);
50286
- } catch {
50287
- return true;
50288
- }
50289
- };
50290
- const recordActionUpgradeDecision = (projectRoot, outcome, storeOptions = {}) => {
50291
- try {
50292
- const store = getActionUpgradeStore(storeOptions);
50293
- const upgrades = store.get("actionUpgrades", {});
50294
- store.set("actionUpgrades", {
50295
- ...upgrades,
50296
- [hashProjectRoot(projectRoot)]: {
50297
- rootDirectory: Path.resolve(projectRoot),
50298
- outcome,
50299
- at: (/* @__PURE__ */ new Date()).toISOString()
50300
- }
50301
- });
50302
- return true;
50303
- } catch {
50304
- return false;
50305
- }
50306
- };
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);
51532
+ //#endregion
51533
+ //#region src/cli/utils/ci-prompt-decision.ts
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);
50307
51543
  //#endregion
50308
51544
  //#region src/cli/utils/open-url.ts
50309
51545
  const resolveOpenCommand = (url) => {
@@ -50469,39 +51705,80 @@ const DEFAULT_PR_TITLE = "Add React Doctor to GitHub Actions";
50469
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.
50470
51706
 
50471
51707
  Docs: https://www.react.doctor/ci`;
50472
- const findUniqueBranchName = async (cwd) => {
50473
- if (!(await runCommand("git", [
51708
+ const findUniqueBranchName = async (cwd, run) => {
51709
+ if (!(await run("git", [
50474
51710
  "rev-parse",
50475
51711
  "--verify",
50476
51712
  NEW_BRANCH_PREFIX
50477
51713
  ], cwd)).success) return NEW_BRANCH_PREFIX;
50478
51714
  return `${NEW_BRANCH_PREFIX}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace(/[-:T]/g, "")}`;
50479
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
+ };
50480
51745
  const openWorkflowPullRequest = async (params) => {
50481
51746
  const workflowPath = Path.resolve(params.workflowPath);
50482
51747
  const commitMessage = params.commitMessage ?? DEFAULT_COMMIT_MESSAGE;
50483
51748
  const prTitle = params.prTitle ?? DEFAULT_PR_TITLE;
50484
51749
  const prBody = params.prBody ?? DEFAULT_PR_BODY;
50485
- 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));
50486
51753
  if (!repoRootProbe.success) return {
50487
51754
  status: "not-attempted",
50488
51755
  reason: "not-a-git-repo"
50489
51756
  };
50490
51757
  const cwd = repoRootProbe.stdout;
50491
- if (!isCommandAvailable("gh")) return {
51758
+ const workflowRelative = toForwardSlashes(Path.relative(cwd, workflowPath));
51759
+ if (!checkCommandAvailable("gh")) return {
50492
51760
  status: "not-attempted",
50493
51761
  reason: "gh-not-installed"
50494
51762
  };
50495
- if (!(await runCommand("gh", ["auth", "status"], cwd)).success) return {
51763
+ if (!(await run("gh", ["auth", "status"], cwd)).success) return {
50496
51764
  status: "not-attempted",
50497
51765
  reason: "gh-not-authenticated"
50498
51766
  };
50499
- 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);
50500
51777
  if (!defaultBranch) return {
50501
51778
  status: "not-attempted",
50502
51779
  reason: "no-default-branch"
50503
51780
  };
50504
- const previousBranchProbe = await runCommand("git", [
51781
+ const previousBranchProbe = await run("git", [
50505
51782
  "rev-parse",
50506
51783
  "--abbrev-ref",
50507
51784
  "HEAD"
@@ -50511,13 +51788,13 @@ const openWorkflowPullRequest = async (params) => {
50511
51788
  reason: "detached-head"
50512
51789
  };
50513
51790
  const previousBranch = previousBranchProbe.stdout;
50514
- await runCommand("git", [
51791
+ await run("git", [
50515
51792
  "fetch",
50516
51793
  "origin",
50517
51794
  defaultBranch
50518
51795
  ], cwd);
50519
- const newBranch = await findUniqueBranchName(cwd);
50520
- if (!(await runCommand("git", [
51796
+ const newBranch = await findUniqueBranchName(cwd, run);
51797
+ if (!(await run("git", [
50521
51798
  "checkout",
50522
51799
  "-b",
50523
51800
  newBranch,
@@ -50527,17 +51804,17 @@ const openWorkflowPullRequest = async (params) => {
50527
51804
  reason: "checkout-failed"
50528
51805
  };
50529
51806
  const restoreToPreviousBranch = async (deleteNewBranch) => {
50530
- await runCommand("git", ["checkout", previousBranch], cwd);
50531
- if (deleteNewBranch) await runCommand("git", [
51807
+ await run("git", ["checkout", previousBranch], cwd);
51808
+ if (deleteNewBranch) await run("git", [
50532
51809
  "branch",
50533
51810
  "-D",
50534
51811
  newBranch
50535
51812
  ], cwd);
50536
51813
  };
50537
- if (!(await runCommand("git", [
51814
+ if (!(await run("git", [
50538
51815
  "add",
50539
51816
  "--",
50540
- Path.relative(cwd, workflowPath)
51817
+ workflowRelative
50541
51818
  ], cwd)).success) {
50542
51819
  await restoreToPreviousBranch(true);
50543
51820
  return {
@@ -50545,7 +51822,7 @@ const openWorkflowPullRequest = async (params) => {
50545
51822
  reason: "git-add-failed"
50546
51823
  };
50547
51824
  }
50548
- if (!(await runCommand("git", [
51825
+ if (!(await run("git", [
50549
51826
  "commit",
50550
51827
  "-m",
50551
51828
  commitMessage
@@ -50556,7 +51833,7 @@ const openWorkflowPullRequest = async (params) => {
50556
51833
  reason: "git-commit-failed"
50557
51834
  };
50558
51835
  }
50559
- if (!(await runCommand("git", [
51836
+ if (!(await run("git", [
50560
51837
  "push",
50561
51838
  "-u",
50562
51839
  "origin",
@@ -50568,7 +51845,7 @@ const openWorkflowPullRequest = async (params) => {
50568
51845
  reason: "git-push-failed"
50569
51846
  };
50570
51847
  }
50571
- const prCreate = await runCommand("gh", [
51848
+ const prCreate = await run("gh", [
50572
51849
  "pr",
50573
51850
  "create",
50574
51851
  "--title",
@@ -50592,12 +51869,13 @@ const openWorkflowPullRequest = async (params) => {
50592
51869
  };
50593
51870
  const stageWorkflowFile = async (params) => {
50594
51871
  const workflowPath = Path.resolve(params.workflowPath);
50595
- 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));
50596
51874
  if (!repoRootProbe.success) return false;
50597
- return (await runCommand("git", [
51875
+ return (await run("git", [
50598
51876
  "add",
50599
51877
  "--",
50600
- Path.relative(repoRootProbe.stdout, workflowPath)
51878
+ toForwardSlashes(Path.relative(repoRootProbe.stdout, workflowPath))
50601
51879
  ], repoRootProbe.stdout)).success;
50602
51880
  };
50603
51881
  //#endregion
@@ -50638,6 +51916,7 @@ const setUpGitHubActions = async (options) => {
50638
51916
  baseBranch: defaultBranch
50639
51917
  });
50640
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)}`);
50641
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}`);
50642
51921
  else {
50643
51922
  pullRequestSpinner.stop();
@@ -50760,22 +52039,22 @@ const buildAgentHookScript = () => [
50760
52039
  "",
50761
52040
  "run_react_doctor() {",
50762
52041
  " if [ -x ./node_modules/.bin/react-doctor ]; then",
50763
- " ./node_modules/.bin/react-doctor --verbose --diff --blocking warning --no-score",
52042
+ " ./node_modules/.bin/react-doctor --verbose --scope changed --blocking warning --no-score",
50764
52043
  " return",
50765
52044
  " fi",
50766
52045
  "",
50767
52046
  " if command -v react-doctor >/dev/null 2>&1; then",
50768
- " react-doctor --verbose --diff --blocking warning --no-score",
52047
+ " react-doctor --verbose --scope changed --blocking warning --no-score",
50769
52048
  " return",
50770
52049
  " fi",
50771
52050
  "",
50772
52051
  " if command -v pnpm >/dev/null 2>&1; then",
50773
- " pnpm dlx react-doctor@latest --verbose --diff --blocking warning --no-score",
52052
+ " pnpm dlx react-doctor@latest --verbose --scope changed --blocking warning --no-score",
50774
52053
  " return",
50775
52054
  " fi",
50776
52055
  "",
50777
52056
  " if command -v npx >/dev/null 2>&1; then",
50778
- " npx --yes react-doctor@latest --verbose --diff --blocking warning --no-score",
52057
+ " npx --yes react-doctor@latest --verbose --scope changed --blocking warning --no-score",
50779
52058
  " return",
50780
52059
  " fi",
50781
52060
  "",
@@ -50933,13 +52212,13 @@ const installPackageJsonHook = (options, strategy) => {
50933
52212
  const packageJsonPath = getPackageJsonPath(options.projectRoot);
50934
52213
  const didHookExist = NFS.existsSync(packageJsonPath);
50935
52214
  const packageJson = readPackageJson(options.projectRoot);
50936
- const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
52215
+ const nextPackageJson = isRecord$1(packageJson) ? { ...packageJson } : {};
50937
52216
  const parentKeys = strategy.path.slice(0, -1);
50938
52217
  const leafKey = strategy.path[strategy.path.length - 1];
50939
52218
  let parent = nextPackageJson;
50940
52219
  for (const key of parentKeys) {
50941
52220
  const existing = parent[key];
50942
- const cloned = isRecord(existing) ? { ...existing } : {};
52221
+ const cloned = isRecord$1(existing) ? { ...existing } : {};
50943
52222
  parent[key] = cloned;
50944
52223
  parent = cloned;
50945
52224
  }
@@ -51110,7 +52389,7 @@ const isHuskyProject = (projectRoot) => NFS.existsSync(Path.join(projectRoot, ".
51110
52389
  const isVitePlusProject = (projectRoot) => packageHasDependency(projectRoot, "vite-plus");
51111
52390
  const isSimpleGitHooksProject = (projectRoot) => {
51112
52391
  const packageJson = readPackageJson(projectRoot);
51113
- return isRecord(packageJson) && isRecord(packageJson["simple-git-hooks"]) || packageHasDependency(projectRoot, "simple-git-hooks") || NFS.existsSync(Path.join(projectRoot, ".simple-git-hooks.cjs"));
52392
+ return isRecord$1(packageJson) && isRecord$1(packageJson["simple-git-hooks"]) || packageHasDependency(projectRoot, "simple-git-hooks") || NFS.existsSync(Path.join(projectRoot, ".simple-git-hooks.cjs"));
51114
52393
  };
51115
52394
  const getLefthookConfigPath = (projectRoot) => {
51116
52395
  for (const fileName of LEFTHOOK_CONFIG_FILES) {
@@ -51276,7 +52555,7 @@ const detectPackageManager = (projectRoot) => {
51276
52555
  let currentDirectory = Path.resolve(projectRoot);
51277
52556
  while (true) {
51278
52557
  const packageJson = readPackageJson(currentDirectory);
51279
- if (isRecord(packageJson) && typeof packageJson.packageManager === "string") {
52558
+ if (isRecord$1(packageJson) && typeof packageJson.packageManager === "string") {
51280
52559
  const packageManagerName = packageJson.packageManager.split("@")[0];
51281
52560
  if (packageManagerName === "pnpm" || packageManagerName === "yarn" || packageManagerName === "bun" || packageManagerName === "npm") return packageManagerName;
51282
52561
  }
@@ -51352,12 +52631,12 @@ const isSupplyChainTrustError = (error) => {
51352
52631
  const formatInstallCommand = (input) => [input.command, ...input.args].join(" ");
51353
52632
  const installReactDoctorDependency = async (options) => {
51354
52633
  const packageJson = readPackageJson(options.projectRoot);
51355
- if (!isRecord(packageJson)) return {
52634
+ if (!isRecord$1(packageJson)) return {
51356
52635
  dependencyStatus: "skipped",
51357
52636
  dependencyReason: "missing-or-invalid-package-json"
51358
52637
  };
51359
52638
  if (hasDoctorDependency(packageJson)) return { dependencyStatus: "existing" };
51360
- if (packageJson.devDependencies !== void 0 && !isRecord(packageJson.devDependencies)) return {
52639
+ if (packageJson.devDependencies !== void 0 && !isRecord$1(packageJson.devDependencies)) return {
51361
52640
  dependencyStatus: "skipped",
51362
52641
  dependencyReason: "invalid-dev-dependencies"
51363
52642
  };
@@ -51521,10 +52800,12 @@ const runInstallReactDoctor = async (options = {}) => {
51521
52800
  const existingWorkflow = readReactDoctorWorkflow(projectRoot);
51522
52801
  const canInstallWorkflow = !NFS.existsSync(workflowTargetPath);
51523
52802
  const canUpgradeWorkflow = existingWorkflow !== null && workflowUsesV1Action(existingWorkflow.content) && !hasHandledActionUpgrade(projectRoot);
51524
- const shouldInstallWorkflow = canInstallWorkflow && (Boolean(options.yes) || !skipPrompts && await askAddToGitHubActions(prompt) === "yes");
52803
+ const ciPromptOutcome = canInstallWorkflow && !options.yes && !skipPrompts && !hasHandledCiPrompt(projectRoot) ? await askAddToGitHubActions(prompt) : null;
52804
+ const shouldInstallWorkflow = canInstallWorkflow && (Boolean(options.yes) || ciPromptOutcome === "yes");
51525
52805
  const upgradePromptOutcome = canUpgradeWorkflow && !options.yes && !skipPrompts ? await askUpgradeActionVersion(prompt) : null;
51526
52806
  const shouldUpgradeWorkflow = canUpgradeWorkflow && (Boolean(options.yes) || upgradePromptOutcome === "yes");
51527
52807
  if (upgradePromptOutcome === "no" && !options.dryRun) recordActionUpgradeDecision(projectRoot, "declined");
52808
+ if ((ciPromptOutcome === "yes" || ciPromptOutcome === "no") && !options.dryRun) recordCiPromptDecision(projectRoot, ciPromptOutcome === "yes" ? "accepted" : "declined");
51528
52809
  const selectedAgents = skipPrompts ? detectedAgents : (await prompt({
51529
52810
  type: "multiselect",
51530
52811
  name: "agents",
@@ -51736,7 +53017,12 @@ const upgradeGitHubActionsWorkflow = async (workflow) => {
51736
53017
  prBody: UPGRADE_PR_BODY
51737
53018
  });
51738
53019
  if (pullRequestResult.status === "pr-opened") upgradeSpinner.succeed(`Opened pull request for review: ${highlighter.info(pullRequestResult.url)}`);
51739
- 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}`);
51740
53026
  else {
51741
53027
  upgradeSpinner.stop();
51742
53028
  try {
@@ -51771,18 +53057,24 @@ const handoffToAgent = async (input) => {
51771
53057
  if (!input.interactive || input.diagnostics.length === 0) return;
51772
53058
  cliLogger.break();
51773
53059
  const projectRootForCi = findNearestPackageDirectory(input.rootDirectory) ?? input.rootDirectory;
51774
- if (!isReactDoctorWorkflowInstalled(projectRootForCi)) {
53060
+ const isGitHubActionsConfigured = isReactDoctorWorkflowInstalled(projectRootForCi);
53061
+ if (!isGitHubActionsConfigured && !hasHandledCiPrompt(projectRootForCi)) {
51775
53062
  const ciOutcome = await askAddToGitHubActions();
51776
53063
  recordCount(METRIC.agentHandoff, 1, {
51777
53064
  outcome: `ci-${ciOutcome}`,
51778
53065
  diagnosticsCount: input.diagnostics.length
51779
53066
  });
51780
53067
  if (ciOutcome === "cancel") return;
53068
+ recordCiPromptDecision(projectRootForCi, ciOutcome === "yes" ? "accepted" : "declined");
51781
53069
  if (ciOutcome === "yes") {
51782
53070
  await setUpGitHubActions({ rootDirectory: input.rootDirectory });
51783
53071
  cliLogger.break();
51784
53072
  }
51785
- } else await maybeOfferActionUpgrade(projectRootForCi);
53073
+ } else if (isGitHubActionsConfigured) await maybeOfferActionUpgrade(projectRootForCi);
53074
+ else recordCount(METRIC.agentHandoff, 1, {
53075
+ outcome: "ci-suppressed",
53076
+ diagnosticsCount: input.diagnostics.length
53077
+ });
51786
53078
  const { handoffTarget } = await prompts({
51787
53079
  type: "select",
51788
53080
  name: "handoffTarget",
@@ -51844,6 +53136,47 @@ const handoffToAgent = async (input) => {
51844
53136
  }
51845
53137
  };
51846
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
51847
53180
  //#region src/cli/utils/read-object-file.ts
51848
53181
  /**
51849
53182
  * Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
@@ -51908,6 +53241,35 @@ export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
51908
53241
  NFS.rmSync(legacy.legacyFilePath, { force: true });
51909
53242
  return targetPath;
51910
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);
51911
53273
  //#endregion
51912
53274
  //#region src/cli/utils/print-branded-header.ts
51913
53275
  /**
@@ -52005,6 +53367,7 @@ const reportErrorToSentry = async (error) => {
52005
53367
  sampled: runTrace.sampled,
52006
53368
  sampleRand: Math.random()
52007
53369
  });
53370
+ recordRunTraceId(scope.getPropagationContext().traceId);
52008
53371
  return Sentry.captureException(error);
52009
53372
  });
52010
53373
  await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
@@ -52088,7 +53451,7 @@ const printMultiProjectSummary = (input) => gen(function* () {
52088
53451
  yield* log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalElapsedMilliseconds)}`);
52089
53452
  if (displayDiagnostics.length > 0) {
52090
53453
  yield* log("");
52091
- yield* printDiagnostics(displayDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment(), { animateCountUp: animateRender });
53454
+ yield* printDiagnostics(displayDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment(), { animateCountUp: animateRender }, shouldRenderHyperlinks(process.stdout));
52092
53455
  }
52093
53456
  const lowestScoredScan = findLowestScoredScan(completedScans);
52094
53457
  const aggregateScore = lowestScoredScan?.result.score ?? null;
@@ -52126,19 +53489,16 @@ const printMultiProjectSummary = (input) => gen(function* () {
52126
53489
  });
52127
53490
  //#endregion
52128
53491
  //#region src/cli/utils/prompt-install-setup.ts
52129
- const GLOBAL_CONFIG_PROJECT_NAME = "react-doctor";
52130
- const getSetupPromptStore = (options = {}) => new Conf({
52131
- projectName: GLOBAL_CONFIG_PROJECT_NAME,
52132
- cwd: options.cwd
52133
- });
52134
- const getSetupPromptProjectKey = (projectRoot) => hashProjectRoot(projectRoot);
52135
- const hasDisabledSetupPrompt = (projectRoot, storeOptions = {}) => {
52136
- try {
52137
- return getSetupPromptStore(storeOptions).get("projects", {})[getSetupPromptProjectKey(projectRoot)]?.setupPrompt === false;
52138
- } catch {
52139
- return false;
52140
- }
52141
- };
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);
52142
53502
  const resolveInstallSetupProjectRoot = (options) => {
52143
53503
  if (options.scanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
52144
53504
  const packageDirectories = /* @__PURE__ */ new Set();
@@ -52545,6 +53905,14 @@ const runExplain = async (fileLineArgument, context) => {
52545
53905
  const colorizedRule = colorizeRuleByDiagnostic(ruleIdentifier, diagnostic.severity);
52546
53906
  const severityLabel = colorizeRuleByDiagnostic(diagnostic.severity, diagnostic.severity);
52547
53907
  cliLogger.log(`${severitySymbol} ${colorizedRule} ${highlighter.dim(`(${severityLabel})`)} — ${diagnostic.message}`);
53908
+ const codeFrame = buildCodeFrame({
53909
+ filePath: diagnostic.filePath,
53910
+ line: diagnostic.line,
53911
+ column: diagnostic.column,
53912
+ endLine: diagnostic.endLine,
53913
+ rootDirectory: targetDirectory
53914
+ });
53915
+ if (codeFrame) cliLogger.log(indentMultilineText(codeFrame, " "));
52548
53916
  if (diagnostic.category) cliLogger.dim(` Category: ${diagnostic.category}`);
52549
53917
  if (diagnostic.help) cliLogger.dim(` ${diagnostic.help}`);
52550
53918
  cliLogger.dim(` If this needs follow-up or looks like a false positive, open: ${buildDiagnosticIssueUrl({
@@ -52594,6 +53962,10 @@ const validateModeFlags = (flags) => {
52594
53962
  if (flags.staged && (flags.scope === "full" || flags.scope === "changed")) throw new CliInputError(`Cannot combine --staged with --scope ${flags.scope}; use --scope files or --scope lines, or drop --scope.`);
52595
53963
  if (flags.score && flags.json) throw new CliInputError("Cannot combine --score and --json; pick one output mode.");
52596
53964
  if (flags.score && flags.telemetry === false) throw new CliInputError("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
53965
+ if (flags.debug && (flags.score === false || flags.telemetry === false)) {
53966
+ const disablingFlag = flags.score === false ? "--no-score" : "--no-telemetry";
53967
+ throw new CliInputError(`Cannot combine --debug with ${disablingFlag}; ${disablingFlag} disables the Sentry reporting --debug needs to capture a trace.`);
53968
+ }
52597
53969
  };
52598
53970
  //#endregion
52599
53971
  //#region src/cli/commands/inspect.ts
@@ -52654,15 +54026,9 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
52654
54026
  * are left untouched — the loader still reads the legacy file as a deprecated
52655
54027
  * fallback and warns — so a scan never mutates the repo unattended.
52656
54028
  */
52657
- const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
54029
+ const maybeMigrateLegacyConfig = async (requestedDirectory, { isQuiet, isStaged }) => {
52658
54030
  if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
52659
- const legacyConfig = findLegacyConfig(requestedDirectory);
52660
- if (!legacyConfig) return;
52661
- const migratedPath = migrateLegacyConfig(legacyConfig);
52662
- if (!migratedPath) return;
52663
- cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
52664
- cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
52665
- cliLogger.break();
54031
+ await runProjectMigrations(requestedDirectory);
52666
54032
  };
52667
54033
  const inspectAction = async (directory, flags) => {
52668
54034
  const isScoreOnly = Boolean(flags.score);
@@ -52677,7 +54043,7 @@ const inspectAction = async (directory, flags) => {
52677
54043
  recordCount(METRIC.cliInvoked, 1, { command: "inspect" });
52678
54044
  try {
52679
54045
  validateModeFlags(flags);
52680
- maybeMigrateLegacyConfig(requestedDirectory, {
54046
+ await maybeMigrateLegacyConfig(requestedDirectory, {
52681
54047
  isQuiet,
52682
54048
  isStaged: Boolean(flags.staged)
52683
54049
  });
@@ -52941,11 +54307,13 @@ const inspectAction = async (directory, flags) => {
52941
54307
  })) {
52942
54308
  printAgentInstallHint();
52943
54309
  recordCount(METRIC.agentInstallHintShown, 1);
54310
+ disableSetupPrompt(setupProjectRoot);
52944
54311
  }
52945
54312
  }
52946
54313
  } catch (error) {
52947
54314
  const isUserError = isExpectedUserError(error);
52948
54315
  const sentryEventId = isUserError ? void 0 : await reportErrorToSentry(error);
54316
+ if (isDebugFlagEnabled()) await flushSentry();
52949
54317
  if (isJsonMode) {
52950
54318
  writeJsonErrorReport(error, sentryEventId);
52951
54319
  process.exitCode = 1;
@@ -53668,6 +55036,33 @@ const normalizeHelpInvocation = (argv, knownCommands) => {
53668
55036
  return [...nodeArguments, "--help"];
53669
55037
  };
53670
55038
  //#endregion
55039
+ //#region src/cli/utils/print-debug-trace.ts
55040
+ /**
55041
+ * The `--debug` end-of-run line, pure so it's testable without the Sentry SDK.
55042
+ * Mirrors the crash-reference phrasing in `handle-error.ts` ("mention this when
55043
+ * reporting") so users learn one habit for both paths. A `null` trace says why,
55044
+ * so `--debug` never silently does nothing.
55045
+ */
55046
+ const buildDebugTraceMessage = (traceId) => traceId === null ? "Sentry trace unavailable for this run (no trace was recorded)." : `Sentry trace (mention this when reporting): ${traceId}`;
55047
+ /**
55048
+ * Prints the run's Sentry trace id to stderr at the end of a `--debug` run, so
55049
+ * maintainers can pull the full trace from a pasted id. Runs from the process
55050
+ * `exit` handler, so it's the last line on both the success path and the error
55051
+ * funnels (which `process.exit()` before the promise chain could resume).
55052
+ *
55053
+ * Writes straight to `process.stderr` (not `Console`) for three reasons: the
55054
+ * exit handler is synchronous, JSON mode patches the global console to no-ops —
55055
+ * a diagnostic the user explicitly asked for must survive that — and stderr
55056
+ * keeps `--json` / `--score` stdout machine-clean. The write is wrapped because
55057
+ * a diagnostic must never throw out of an exit handler.
55058
+ */
55059
+ const printDebugTrace = () => {
55060
+ if (!Sentry.isInitialized()) return;
55061
+ try {
55062
+ process.stderr.write(`${highlighter.dim(buildDebugTraceMessage(getLastRunTraceId()))}\n`);
55063
+ } catch {}
55064
+ };
55065
+ //#endregion
53671
55066
  //#region src/cli/utils/removed-cli-flags.ts
53672
55067
  const REMOVED_FLAGS = new Map([
53673
55068
  ["--full", "use `--diff false` to force a full scan"],
@@ -53694,6 +55089,7 @@ const ROOT_FLAG_SPEC = {
53694
55089
  longOptionsWithoutValues: new Set([
53695
55090
  "--color",
53696
55091
  "--dead-code",
55092
+ "--debug",
53697
55093
  "--help",
53698
55094
  "--json",
53699
55095
  "--json-compact",
@@ -53861,6 +55257,9 @@ const stripUnknownCliFlags = (argv) => {
53861
55257
  initializeSentry();
53862
55258
  process.on("SIGINT", exitGracefully);
53863
55259
  process.on("SIGTERM", exitGracefully);
55260
+ process.on("exit", () => {
55261
+ if (isDebugFlagEnabled()) printDebugTrace();
55262
+ });
53864
55263
  unrefStdin();
53865
55264
  guardStdin();
53866
55265
  const formatExampleLines = (examples) => {
@@ -53872,7 +55271,7 @@ ${highlighter.dim("Examples:")}
53872
55271
  ${formatExampleLines([
53873
55272
  ["react-doctor", "scan the current project"],
53874
55273
  ["react-doctor ./apps/web", "scan a specific directory"],
53875
- ["react-doctor --diff main", "scan only files changed vs. main"],
55274
+ ["react-doctor --scope changed --base main", "scan only new issues vs. main"],
53876
55275
  ["react-doctor --project modules/a,modules/b", "score each module separately (names or paths)"],
53877
55276
  ["react-doctor --staged", "scan staged files (pre-commit hook)"],
53878
55277
  ["react-doctor --category Security", "show only one diagnostic category"],
@@ -53905,7 +55304,7 @@ ${highlighter.dim("Learn more:")}
53905
55304
  ${highlighter.info(CANONICAL_GITHUB_URL)}
53906
55305
  `;
53907
55306
  const collectCategoryOption = (value, previousValues) => [...previousValues ?? [], value];
53908
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead-code analysis (default)").option("--no-dead-code", "skip dead-code analysis (unused files / exports / dependencies, circular imports)").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--output-dir <dir>", "directory for the full diagnostics dump (default: a temp folder)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--no-parallel", "lint serially with one worker (default: parallel across CPU cores; set the worker count with REACT_DOCTOR_PARALLEL)").option("--project <name>", "select projects: workspace names or directory paths (comma-separated for multiple); overrides the `projects` config field").option("--scope <value>", "how much to scan/report: full (default), files, changed (only new issues vs base), or lines (only changed lines)").option("--base <ref>", "base git ref for files/changed/lines scope (auto-detected when omitted)").addOption(new Option("--diff [base]", "[deprecated] alias for --scope changed (pass `false` to force a full scan)").hideHelp()).addOption(new Option("--changed-files-from <file>", "scan source files listed in a newline-delimited changed-files file").hideHelp()).option("--no-score", "skip the score API, the share URL, and crash reporting").addOption(new Option("--category <category>", "only show diagnostics in a category (repeatable; e.g. Security)").argParser(collectCategoryOption)).option("--no-telemetry", "alias for --no-score (skip the score API, share URL, and crash reporting)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--blocking <level>", "severity that fails CI: error (default), warning, or none (advisory)").addOption(new Option("--fail-on <level>", "[deprecated] alias for --blocking <level>").hideHelp()).option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").option("--warnings", "show warning-severity diagnostics (default)").option("--no-warnings", "hide warning-severity diagnostics (errors only)").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderRootHelpEpilog);
55307
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead-code analysis (default)").option("--no-dead-code", "skip dead-code analysis (unused files / exports / dependencies, circular imports)").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--debug", "force a Sentry trace and print its id at the end (paste it into a bug report)").option("--output-dir <dir>", "directory for the full diagnostics dump (default: a temp folder)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--no-parallel", "lint serially with one worker (default: parallel across CPU cores; set the worker count with REACT_DOCTOR_PARALLEL)").option("--project <name>", "select projects: workspace names or directory paths (comma-separated for multiple); overrides the `projects` config field").option("--scope <value>", "how much to scan/report: full (default), files, changed (only new issues vs base), or lines (only changed lines)").option("--base <ref>", "base git ref for files/changed/lines scope (auto-detected when omitted)").addOption(new Option("--diff [base]", "[deprecated] alias for --scope changed (pass `false` to force a full scan)").hideHelp()).addOption(new Option("--changed-files-from <file>", "scan source files listed in a newline-delimited changed-files file").hideHelp()).option("--no-score", "skip the score API, the share URL, and crash reporting").addOption(new Option("--category <category>", "only show diagnostics in a category (repeatable; e.g. Security)").argParser(collectCategoryOption)).option("--no-telemetry", "alias for --no-score (skip the score API, share URL, and crash reporting)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--blocking <level>", "severity that fails CI: error (default), warning, or none (advisory)").addOption(new Option("--fail-on <level>", "[deprecated] alias for --blocking <level>").hideHelp()).option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").option("--warnings", "show warning-severity diagnostics (default)").option("--no-warnings", "hide warning-severity diagnostics (errors only)").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderRootHelpEpilog);
53909
55308
  program.action(inspectAction);
53910
55309
  program.command("why <location>").description("Explain why a rule fired (or why a suppression didn't apply) at a file:line").option("--project <name>", "select projects: workspace names or directory paths (comma-separated for multiple)").option("-c, --cwd <cwd>", "working directory", process.cwd()).option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").action((location, options) => whyAction(location, options));
53911
55310
  program.command("install").alias("setup").description("Install the react-doctor skill into your coding agents and optional git hook").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor").option("-c, --cwd <cwd>", "working directory", process.cwd()).option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderInstallHelpEpilog).action(installAction);
@@ -53948,4 +55347,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
53948
55347
  export {};
53949
55348
 
53950
55349
  //# sourceMappingURL=cli.js.map
53951
- //# debugId=79c9f922-1713-5894-a0aa-142d0cd9c7ba
55350
+ //# debugId=fc85ab90-476c-5c45-bfa1-cfead22c445d