react-doctor 0.5.6-dev.f45cb29 → 0.5.7-dev.242bf69

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]="bb93aaad-ea85-5f1d-b2db-584f48671f66")}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]="4aa7cab0-c8a8-5513-bde6-3f7a0a1bdaa7")}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";
@@ -14,7 +14,7 @@ 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";
@@ -22358,7 +22358,8 @@ var Diagnostic = class extends Class("Diagnostic")({
22358
22358
  category: String$1,
22359
22359
  fileContext: optional(Literals(["test", "story"])),
22360
22360
  suppressionHint: optional(String$1),
22361
- relatedLocations: optional(ArraySchema(DiagnosticRelatedLocation))
22361
+ relatedLocations: optional(ArraySchema(DiagnosticRelatedLocation)),
22362
+ fixGroupId: optional(String$1)
22362
22363
  }) {};
22363
22364
  const JsonReportMode = Literals([
22364
22365
  "full",
@@ -22400,6 +22401,7 @@ var JsonReportProjectEntry = class extends Class("JsonReportProjectEntry")({
22400
22401
  score: Unknown,
22401
22402
  skippedChecks: ArraySchema(String$1),
22402
22403
  skippedCheckReasons: optional(Record$1(String$1, String$1)),
22404
+ scannedFileCount: optional(Number$1),
22403
22405
  elapsedMilliseconds: Number$1
22404
22406
  }) {};
22405
22407
  /**
@@ -35893,6 +35895,7 @@ const isLargeMinifiedFile = (absolutePath) => {
35893
35895
  if (sizeBytes < 2e4) return false;
35894
35896
  return isMinifiedSource(absolutePath);
35895
35897
  };
35898
+ const isErrnoException = (error) => error instanceof Error && "code" in error;
35896
35899
  const IGNORABLE_READDIR_ERROR_CODES = new Set([
35897
35900
  "EACCES",
35898
35901
  "EPERM",
@@ -35902,11 +35905,7 @@ const IGNORABLE_READDIR_ERROR_CODES = new Set([
35902
35905
  "ELOOP",
35903
35906
  "ENAMETOOLONG"
35904
35907
  ]);
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
- };
35908
+ const isIgnorableReaddirError = (error) => isErrnoException(error) && typeof error.code === "string" && IGNORABLE_READDIR_ERROR_CODES.has(error.code);
35910
35909
  const readDirectoryEntries = (directoryPath) => {
35911
35910
  try {
35912
35911
  return NFS.readdirSync(directoryPath, { withFileTypes: true });
@@ -35953,7 +35952,7 @@ const readPackageJsonUncached = (packageJsonPath) => {
35953
35952
  return JSON.parse(NFS.readFileSync(packageJsonPath, "utf-8"));
35954
35953
  } catch (error) {
35955
35954
  if (error instanceof SyntaxError) return {};
35956
- if (error instanceof Error && "code" in error) {
35955
+ if (isErrnoException(error)) {
35957
35956
  const { code } = error;
35958
35957
  if (code === "EISDIR" || code === "EACCES" || code === "EPERM" || code === "ENOENT") return {};
35959
35958
  }
@@ -36678,17 +36677,13 @@ const isPackageJsonReactNativeAware = (packageJson) => {
36678
36677
  return false;
36679
36678
  };
36680
36679
  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;
36680
+ const getDependencySpec = (packageJson, packageName) => {
36681
+ const spec = packageJson.dependencies?.[packageName] ?? packageJson.devDependencies?.[packageName] ?? packageJson.peerDependencies?.[packageName] ?? packageJson.optionalDependencies?.[packageName];
36683
36682
  return typeof spec === "string" ? spec : null;
36684
36683
  };
36685
- const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, getExpoDependencySpec);
36684
+ const findExpoVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => getDependencySpec(packageJson, "expo"));
36686
36685
  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);
36686
+ const findShopifyFlashListVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => getDependencySpec(packageJson, SHOPIFY_FLASH_LIST_PACKAGE_NAME));
36692
36687
  const resolveCatalogBackedDependencyVersion = ({ rootDirectory, rootPackageJson, packageName, version }) => {
36693
36688
  if (version === null || !isCatalogReference(version)) return version;
36694
36689
  const catalogName = extractCatalogName(version);
@@ -36700,11 +36695,7 @@ const resolveCatalogBackedDependencyVersion = ({ rootDirectory, rootPackageJson,
36700
36695
  if (!isFile(monorepoPackageJsonPath)) return version;
36701
36696
  return resolveCatalogVersion(readPackageJson$1(monorepoPackageJsonPath), packageName, monorepoRoot, catalogName) ?? version;
36702
36697
  };
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);
36698
+ const findNextjsVersion = (rootDirectory, rootPackageJson) => findInWorkspacePackageJsons(rootDirectory, rootPackageJson, (packageJson) => getDependencySpec(packageJson, "next"));
36708
36699
  const getPreactVersion = (packageJson) => {
36709
36700
  return {
36710
36701
  ...packageJson.peerDependencies,
@@ -36793,6 +36784,11 @@ const ES_TARGET_YEAR_BY_NAME = {
36793
36784
  esnext: 9999
36794
36785
  };
36795
36786
  /**
36787
+ * tsconfig filenames probed when resolving a project's TypeScript
36788
+ * compiler options — the root config first, then a monorepo base config.
36789
+ */
36790
+ const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
36791
+ /**
36796
36792
  * Project-config files that `StagedFiles.materialize` copies into
36797
36793
  * the temp directory alongside staged sources so oxlint resolves
36798
36794
  * `tsconfig` / `package.json` / lint configs the same way it would
@@ -36865,6 +36861,13 @@ const APP_ONLY_RULE_KEYS = new Set([
36865
36861
  ]);
36866
36862
  const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
36867
36863
  const COMPILER_CLEANUP_RULE_KEYS = new Set(["react-doctor/react-compiler-no-manual-memoization"]);
36864
+ const ROOT_CAUSE_GROUPABLE_RULE_KEYS = new Set([
36865
+ "react-doctor/no-derived-state",
36866
+ "react-doctor/no-derived-state-effect",
36867
+ "react-doctor/no-derived-useState",
36868
+ "react-doctor/no-adjust-state-on-prop-change",
36869
+ "react-doctor/no-reset-all-state-on-prop-change"
36870
+ ]);
36868
36871
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
36869
36872
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
36870
36873
  const SOCKET_FREE_PURL_API_BASE = "https://firewall-api.socket.dev/purl";
@@ -37314,6 +37317,7 @@ const isTailwindAtLeast = (detected, required) => {
37314
37317
  if (detected.major !== required.major) return detected.major > required.major;
37315
37318
  return detected.minor >= required.minor;
37316
37319
  };
37320
+ const messageFromUnknown = (error) => error instanceof Error ? error.message : String(error);
37317
37321
  var InvalidGlobPatternError = class extends Error {
37318
37322
  pattern;
37319
37323
  reason;
@@ -37342,7 +37346,7 @@ const compileGlobPattern = (rawPattern) => {
37342
37346
  try {
37343
37347
  return import_picomatch.default.makeRe(normalizeGlobPattern(rawPattern), PICOMATCH_OPTIONS);
37344
37348
  } catch (caughtError) {
37345
- throw new InvalidGlobPatternError(rawPattern, caughtError instanceof Error ? caughtError.message : String(caughtError));
37349
+ throw new InvalidGlobPatternError(rawPattern, messageFromUnknown(caughtError));
37346
37350
  }
37347
37351
  };
37348
37352
  const compileGlobPatternsLenient = (patterns, onInvalid) => {
@@ -37438,115 +37442,6 @@ const buildRuleSeverityControls = (config) => {
37438
37442
  ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
37439
37443
  };
37440
37444
  };
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
37445
  const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
37551
37446
  "effect/no-adjust-state-on-prop-change": "react-doctor/no-adjust-state-on-prop-change",
37552
37447
  "effect/no-chain-state-updates": "react-doctor/no-chain-state-updates",
@@ -37671,7 +37566,13 @@ for (const [legacyRuleKey, nativeRuleKey] of Object.entries(LEGACY_RULE_KEY_TO_N
37671
37566
  }
37672
37567
  const getLegacyRuleKeysForNative = (ruleKey) => NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.get(ruleKey) ?? [];
37673
37568
  const canonicalizeRuleKey = (ruleKey) => LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey] ?? ruleKey;
37674
- const isSameRuleKey = (candidateRuleKey, targetRuleKey) => canonicalizeRuleKey(candidateRuleKey) === canonicalizeRuleKey(targetRuleKey);
37569
+ const isReactDoctorShortIdOf = (bareRuleKey, qualifiedRuleKey) => !bareRuleKey.includes("/") && qualifiedRuleKey === `react-doctor/${bareRuleKey}`;
37570
+ const isSameRuleKey = (candidateRuleKey, targetRuleKey) => {
37571
+ const canonicalCandidate = canonicalizeRuleKey(candidateRuleKey);
37572
+ const canonicalTarget = canonicalizeRuleKey(targetRuleKey);
37573
+ if (canonicalCandidate === canonicalTarget) return true;
37574
+ return isReactDoctorShortIdOf(canonicalCandidate, canonicalTarget) || isReactDoctorShortIdOf(canonicalTarget, canonicalCandidate);
37575
+ };
37675
37576
  const getEquivalentRuleKeys = (ruleKey) => {
37676
37577
  const nativeRuleKey = canonicalizeRuleKey(ruleKey);
37677
37578
  return [nativeRuleKey, ...getLegacyRuleKeysForNative(nativeRuleKey)];
@@ -37681,12 +37582,182 @@ const stripDescriptionTail = (ruleList) => {
37681
37582
  if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
37682
37583
  return ruleList.slice(0, descriptionMatch.index);
37683
37584
  };
37684
- const isRuleListedInComment = (ruleList, ruleId) => {
37585
+ const tokenizeRuleList = (ruleList) => {
37685
37586
  const trimmed = ruleList?.trim();
37686
- if (!trimmed) return true;
37587
+ if (!trimmed) return [];
37687
37588
  const ruleSection = stripDescriptionTail(trimmed).trim();
37688
- if (!ruleSection) return true;
37689
- return ruleSection.split(/[,\s]+/).some((token) => isSameRuleKey(token.trim(), ruleId));
37589
+ if (!ruleSection) return [];
37590
+ return ruleSection.split(/[,\s]+/).map((token) => token.trim()).filter(Boolean);
37591
+ };
37592
+ const FOREIGN_INLINE_DISABLE_PATTERN = /(?:\/\/|\/\*)[ \t]*(eslint|oxlint)-disable-(next-line|line)(?![\w-])([^\r\n]*)/;
37593
+ const FOREIGN_BLOCK_DISABLE_PATTERN = /\/\*[ \t]*(eslint|oxlint)-disable(?![\w-])([^*\r\n]*)/;
37594
+ const FOREIGN_BLOCK_ENABLE_PATTERN = /\/\*[ \t]*(?:eslint|oxlint)-enable(?![\w-])([^*\r\n]*)/;
37595
+ 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}\`.`;
37596
+ const tokenMisnamesRule = (token, ruleId) => token !== ruleId && isSameRuleKey(token, ruleId);
37597
+ const detectInlineNearMiss = (lines, diagnosticLineIndex, ruleId) => {
37598
+ const candidates = [{
37599
+ line: lines[diagnosticLineIndex],
37600
+ requiredScope: "line"
37601
+ }, {
37602
+ line: lines[diagnosticLineIndex - 1],
37603
+ requiredScope: "next-line"
37604
+ }];
37605
+ for (const { line, requiredScope } of candidates) {
37606
+ const match = line?.match(FOREIGN_INLINE_DISABLE_PATTERN);
37607
+ if (!match) continue;
37608
+ const [, tool, scope, ruleList] = match;
37609
+ if (scope !== requiredScope) continue;
37610
+ const tokens = tokenizeRuleList(ruleList);
37611
+ if (tokens.includes(ruleId)) continue;
37612
+ for (const token of tokens) if (tokenMisnamesRule(token, ruleId)) return buildHint(tool, token, ruleId);
37613
+ }
37614
+ return null;
37615
+ };
37616
+ const detectBlockNearMiss = (lines, diagnosticLineIndex, ruleId) => {
37617
+ let openMisname = null;
37618
+ const lastLineIndex = Math.min(diagnosticLineIndex, lines.length - 1);
37619
+ for (let lineIndex = 0; lineIndex <= lastLineIndex; lineIndex++) {
37620
+ const line = lines[lineIndex];
37621
+ if (line === void 0 || !line.includes("-disable") && !line.includes("-enable")) continue;
37622
+ const disableMatch = line.match(FOREIGN_BLOCK_DISABLE_PATTERN);
37623
+ if (disableMatch) {
37624
+ const [, tool, ruleList] = disableMatch;
37625
+ const tokens = tokenizeRuleList(ruleList);
37626
+ if (tokens.includes(ruleId)) openMisname = null;
37627
+ else {
37628
+ const misnamed = tokens.find((token) => tokenMisnamesRule(token, ruleId));
37629
+ if (misnamed) openMisname = {
37630
+ tool,
37631
+ token: misnamed
37632
+ };
37633
+ }
37634
+ continue;
37635
+ }
37636
+ const enableMatch = line.match(FOREIGN_BLOCK_ENABLE_PATTERN);
37637
+ if (enableMatch) {
37638
+ const enabledRules = tokenizeRuleList(enableMatch[1]);
37639
+ if (enabledRules.length === 0 || enabledRules.some((rule) => isSameRuleKey(rule, ruleId))) openMisname = null;
37640
+ }
37641
+ }
37642
+ return openMisname ? buildHint(openMisname.tool, openMisname.token, ruleId) : null;
37643
+ };
37644
+ const detectForeignDisableNearMiss = (lines, diagnosticLineIndex, ruleId) => {
37645
+ if (!ruleId.startsWith("react-doctor/")) return null;
37646
+ return detectInlineNearMiss(lines, diagnosticLineIndex, ruleId) ?? detectBlockNearMiss(lines, diagnosticLineIndex, ruleId);
37647
+ };
37648
+ const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
37649
+ const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
37650
+ const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
37651
+ let stringDelimiter = null;
37652
+ for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
37653
+ const character = line[charIndex];
37654
+ if (stringDelimiter !== null) {
37655
+ if (character === "\\") {
37656
+ charIndex++;
37657
+ continue;
37658
+ }
37659
+ if (character === stringDelimiter) stringDelimiter = null;
37660
+ continue;
37661
+ }
37662
+ if (character === "\"" || character === "'" || character === "`") {
37663
+ stringDelimiter = character;
37664
+ continue;
37665
+ }
37666
+ if (character === "/" && line[charIndex + 1] === "/") return true;
37667
+ }
37668
+ return false;
37669
+ };
37670
+ const findOpenerTagOnLine = (line) => {
37671
+ for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
37672
+ if (match.index === void 0) continue;
37673
+ if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
37674
+ }
37675
+ return null;
37676
+ };
37677
+ const findJsxOpenerSpan = (lines, openerLineIndex) => {
37678
+ const openerLine = lines[openerLineIndex];
37679
+ if (openerLine === void 0) return null;
37680
+ const opener = findOpenerTagOnLine(openerLine);
37681
+ if (!opener) return null;
37682
+ const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
37683
+ let braceDepth = 0;
37684
+ let innerAngleDepth = 0;
37685
+ let stringDelimiter = null;
37686
+ for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
37687
+ const currentLine = lines[lineIndex];
37688
+ const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
37689
+ for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
37690
+ const character = currentLine[charIndex];
37691
+ if (stringDelimiter !== null) {
37692
+ if (character === "\\") {
37693
+ charIndex++;
37694
+ continue;
37695
+ }
37696
+ if (character === stringDelimiter) stringDelimiter = null;
37697
+ continue;
37698
+ }
37699
+ if (character === "\"" || character === "'" || character === "`") {
37700
+ stringDelimiter = character;
37701
+ continue;
37702
+ }
37703
+ if (character === "{") {
37704
+ braceDepth++;
37705
+ continue;
37706
+ }
37707
+ if (character === "}") {
37708
+ braceDepth--;
37709
+ continue;
37710
+ }
37711
+ if (braceDepth !== 0) continue;
37712
+ if (character === "<") {
37713
+ const followCharacter = currentLine[charIndex + 1];
37714
+ if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
37715
+ continue;
37716
+ }
37717
+ if (character !== ">") continue;
37718
+ const previousCharacter = currentLine[charIndex - 1];
37719
+ const nextCharacter = currentLine[charIndex + 1];
37720
+ if (previousCharacter === "=" || nextCharacter === "=") continue;
37721
+ if (innerAngleDepth > 0) {
37722
+ innerAngleDepth--;
37723
+ continue;
37724
+ }
37725
+ return lineIndex;
37726
+ }
37727
+ }
37728
+ return null;
37729
+ };
37730
+ const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
37731
+ for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
37732
+ const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
37733
+ if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
37734
+ }
37735
+ return null;
37736
+ };
37737
+ const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
37738
+ const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
37739
+ const collected = [];
37740
+ let isStillInChain = true;
37741
+ for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
37742
+ const candidateLine = lines[candidateIndex];
37743
+ if (candidateLine === void 0) break;
37744
+ const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
37745
+ if (match) {
37746
+ collected.push({
37747
+ commentLineIndex: candidateIndex,
37748
+ ruleList: match[1],
37749
+ isInChain: isStillInChain
37750
+ });
37751
+ continue;
37752
+ }
37753
+ isStillInChain = false;
37754
+ }
37755
+ return collected;
37756
+ };
37757
+ const isRuleListedInComment = (ruleList, ruleId) => {
37758
+ const tokens = tokenizeRuleList(ruleList);
37759
+ if (tokens.length === 0) return true;
37760
+ return tokens.some((token) => isSameRuleKey(token, ruleId));
37690
37761
  };
37691
37762
  const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
37692
37763
  const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
@@ -37730,7 +37801,7 @@ const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
37730
37801
  };
37731
37802
  return {
37732
37803
  isSuppressed: false,
37733
- nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
37804
+ nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId) ?? detectForeignDisableNearMiss(lines, diagnosticLineIndex, ruleId)
37734
37805
  };
37735
37806
  };
37736
37807
  /**
@@ -38520,7 +38591,6 @@ const PACKAGE_JSON_FILENAME = "package.json";
38520
38591
  const PACKAGE_JSON_CONFIG_KEY$1 = "reactDoctor";
38521
38592
  const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
38522
38593
  const jiti = createJiti(import.meta.url);
38523
- const formatError = (error) => error instanceof Error ? error.message : String(error);
38524
38594
  const importDefaultExport = async (jitiInstance, filePath) => {
38525
38595
  const imported = await jitiInstance.import(filePath);
38526
38596
  return imported?.default ?? imported;
@@ -38552,7 +38622,7 @@ const loadModuleConfig = async (filePath) => {
38552
38622
  try {
38553
38623
  return await importDefaultExport(aliasJiti, filePath);
38554
38624
  } 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 });
38625
+ 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
38626
  }
38557
38627
  }
38558
38628
  };
@@ -38601,7 +38671,7 @@ const loadLegacyConfig = (directory) => {
38601
38671
  }
38602
38672
  warn(`${LEGACY_CONFIG_FILENAME} must contain an object, ignoring.`);
38603
38673
  } catch (error) {
38604
- warn(`Failed to load ${LEGACY_CONFIG_FILENAME}: ${formatError(error)}`);
38674
+ warn(`Failed to load ${LEGACY_CONFIG_FILENAME}: ${messageFromUnknown(error)}`);
38605
38675
  }
38606
38676
  return {
38607
38677
  status: "invalid",
@@ -38628,7 +38698,7 @@ const loadConfigFromDirectory = async (directory) => {
38628
38698
  warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
38629
38699
  sawBrokenConfigFile = true;
38630
38700
  } catch (error) {
38631
- warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
38701
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${messageFromUnknown(error)}`);
38632
38702
  sawBrokenConfigFile = true;
38633
38703
  }
38634
38704
  }
@@ -38758,6 +38828,29 @@ const resolveScanTarget = async (requestedDirectory, options = {}) => {
38758
38828
  didRedirectViaRootDir: redirectedDirectory !== null
38759
38829
  };
38760
38830
  };
38831
+ const buildFixGroupId = (diagnostic) => createHash("sha1").update(JSON.stringify([
38832
+ diagnostic.filePath,
38833
+ `${diagnostic.plugin}/${diagnostic.rule}`,
38834
+ diagnostic.message
38835
+ ])).digest("hex").slice(0, 16);
38836
+ const isGroupableRule = (diagnostic) => ROOT_CAUSE_GROUPABLE_RULE_KEYS.has(`${diagnostic.plugin}/${diagnostic.rule}`);
38837
+ const assignFixGroups = (diagnostics) => {
38838
+ const siteCountByGroupId = /* @__PURE__ */ new Map();
38839
+ for (const diagnostic of diagnostics) {
38840
+ if (!isGroupableRule(diagnostic)) continue;
38841
+ const groupId = buildFixGroupId(diagnostic);
38842
+ siteCountByGroupId.set(groupId, (siteCountByGroupId.get(groupId) ?? 0) + 1);
38843
+ }
38844
+ return diagnostics.map((diagnostic) => {
38845
+ if (!isGroupableRule(diagnostic)) return diagnostic;
38846
+ const groupId = buildFixGroupId(diagnostic);
38847
+ if ((siteCountByGroupId.get(groupId) ?? 0) < 2) return diagnostic;
38848
+ return {
38849
+ ...diagnostic,
38850
+ fixGroupId: groupId
38851
+ };
38852
+ });
38853
+ };
38761
38854
  const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
38762
38855
  const buildExpoCheckContext = (rootDirectory, expoVersion) => {
38763
38856
  const packageJson = readPackageJson$1(Path.join(rootDirectory, "package.json"));
@@ -39903,7 +39996,7 @@ const readIgnoreFile = (filePath) => {
39903
39996
  try {
39904
39997
  content = NFS.readFileSync(filePath, "utf-8");
39905
39998
  } catch (error) {
39906
- const errnoCode = error?.code;
39999
+ const errnoCode = isErrnoException(error) ? error.code : void 0;
39907
40000
  if (errnoCode && errnoCode !== "ENOENT") runSync(warn$1(`Could not read ignore file ${filePath}: ${errnoCode}`));
39908
40001
  return [];
39909
40002
  }
@@ -39941,8 +40034,8 @@ const collectIgnorePatterns = (rootDirectory) => {
39941
40034
  cachedPatternsByRoot.set(rootDirectory, patterns);
39942
40035
  return patterns;
39943
40036
  };
40037
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
39944
40038
  const KNIP_JSON_FILENAME = "knip.json";
39945
- const isRecord$1$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
39946
40039
  const readJsonFileSafe = (filePath) => {
39947
40040
  let rawContents;
39948
40041
  try {
@@ -39958,10 +40051,10 @@ const readJsonFileSafe = (filePath) => {
39958
40051
  };
39959
40052
  const readKnipConfig = (rootDirectory) => {
39960
40053
  const knipJson = readJsonFileSafe(path.join(rootDirectory, KNIP_JSON_FILENAME));
39961
- if (isRecord$1$1(knipJson)) return knipJson;
40054
+ if (isRecord$2(knipJson)) return knipJson;
39962
40055
  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;
40056
+ const packageKnipConfig = isRecord$2(packageJson) ? packageJson.knip : null;
40057
+ return isRecord$2(packageKnipConfig) ? packageKnipConfig : null;
39965
40058
  };
39966
40059
  const normalizePatternList = (value) => {
39967
40060
  if (typeof value === "string" && value.length > 0) return [value];
@@ -39973,10 +40066,10 @@ const prefixWorkspacePatterns = (workspacePattern, patterns) => {
39973
40066
  return patterns.map((pattern) => pattern.startsWith("!") ? `!${normalizedWorkspacePattern}/${pattern.slice(1)}` : `${normalizedWorkspacePattern}/${pattern}`);
39974
40067
  };
39975
40068
  const collectKnipWorkspacePatterns = (workspaces, settingName) => {
39976
- if (!isRecord$1$1(workspaces)) return [];
40069
+ if (!isRecord$2(workspaces)) return [];
39977
40070
  const patterns = [];
39978
40071
  for (const [workspacePattern, workspaceConfig] of Object.entries(workspaces)) {
39979
- if (!isRecord$1$1(workspaceConfig)) continue;
40072
+ if (!isRecord$2(workspaceConfig)) continue;
39980
40073
  patterns.push(...prefixWorkspacePatterns(workspacePattern, normalizePatternList(workspaceConfig[settingName])));
39981
40074
  }
39982
40075
  return patterns;
@@ -40021,8 +40114,6 @@ const toCanonicalPath = (filePath) => {
40021
40114
  };
40022
40115
  const DEAD_CODE_PLUGIN = "deslop";
40023
40116
  const DEAD_CODE_CATEGORY = "Maintainability";
40024
- const TSCONFIG_FILENAMES$1 = ["tsconfig.json", "tsconfig.base.json"];
40025
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
40026
40117
  const DEAD_CODE_WORKER_SCRIPT = `
40027
40118
  const inputChunks = [];
40028
40119
  process.stdin.on("data", (chunk) => inputChunks.push(chunk));
@@ -40080,7 +40171,7 @@ process.stdin.on("end", () => {
40080
40171
  });
40081
40172
  `;
40082
40173
  const resolveTsConfigPath = (rootDirectory) => {
40083
- for (const filename of TSCONFIG_FILENAMES$1) {
40174
+ for (const filename of TSCONFIG_FILENAMES) {
40084
40175
  const candidate = Path.join(rootDirectory, filename);
40085
40176
  if (NFS.existsSync(candidate)) return candidate;
40086
40177
  }
@@ -40461,15 +40552,13 @@ var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
40461
40552
  })()) }));
40462
40553
  static layerOf = (diagnostics) => succeed$3(DeadCode, DeadCode.of({ run: () => fromIterable$1(diagnostics) }));
40463
40554
  };
40464
- const createNodeReadFileLinesSync = (rootDirectory) => {
40465
- return (filePath) => {
40466
- const absolutePath = Path.isAbsolute(filePath) ? filePath : Path.join(rootDirectory, filePath);
40467
- try {
40468
- return NFS.readFileSync(absolutePath, "utf-8").split("\n");
40469
- } catch {
40470
- return null;
40471
- }
40472
- };
40555
+ const createNodeReadFileLinesSync = (rootDirectory) => (filePath) => {
40556
+ const absolutePath = Path.isAbsolute(filePath) ? filePath : Path.join(rootDirectory, filePath);
40557
+ try {
40558
+ return NFS.readFileSync(absolutePath, "utf-8").split("\n");
40559
+ } catch {
40560
+ return null;
40561
+ }
40473
40562
  };
40474
40563
  var Files = class Files extends Service()("react-doctor/Files") {
40475
40564
  static layerNode = succeed$3(Files, Files.of({
@@ -40680,7 +40769,10 @@ var Git = class Git extends Service()("react-doctor/Git") {
40680
40769
  directory: input.directory,
40681
40770
  cause
40682
40771
  }) });
40683
- }));
40772
+ }), withSpan("git.exec", { attributes: {
40773
+ "git.command": input.command,
40774
+ "git.subcommand": input.args[0] ?? ""
40775
+ } }));
40684
40776
  const runGit = (directory, args) => runCommand({
40685
40777
  command: "git",
40686
40778
  args,
@@ -40708,7 +40800,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40708
40800
  ]);
40709
40801
  if (candidates.status !== 0) return null;
40710
40802
  return trimOrNull(candidates.stdout.split("\n")[0] ?? "");
40711
- });
40803
+ }).pipe(withSpan("Git.defaultBranch"));
40712
40804
  const branchExists = (directory, branch) => runGit(directory, [
40713
40805
  "rev-parse",
40714
40806
  "--verify",
@@ -40755,7 +40847,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40755
40847
  const result = resultOption.value;
40756
40848
  if (result.status !== 0) return null;
40757
40849
  return parseGithubViewerPermission(result.stdout);
40758
- }).pipe(catch_$1(() => succeed$2(null)));
40850
+ }).pipe(catch_$1(() => succeed$2(null)), withSpan("Git.githubViewerPermission"));
40759
40851
  /**
40760
40852
  * Resolves a `--diff A..B` / `A...B` commit range into a changed-file
40761
40853
  * selection. Each endpoint is validated with `isSafeGitRevision`
@@ -40869,7 +40961,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40869
40961
  changedFiles: splitNullSeparated(diff.stdout),
40870
40962
  isCurrentChanges: false
40871
40963
  };
40872
- }),
40964
+ }).pipe(withSpan("Git.diffSelection")),
40873
40965
  stagedFilePaths: (directory) => runGit(directory, [
40874
40966
  "diff",
40875
40967
  "--cached",
@@ -40911,7 +41003,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40911
41003
  status: result.status,
40912
41004
  stdout: result.stdout
40913
41005
  };
40914
- }),
41006
+ }).pipe(withSpan("Git.grep")),
40915
41007
  changedLineRanges: ({ directory, baseRef, cached, files }) => gen(function* () {
40916
41008
  if (files.length === 0) return [];
40917
41009
  if (baseRef !== void 0 && !isSafeGitRevision(baseRef)) return null;
@@ -40927,7 +41019,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40927
41019
  ]);
40928
41020
  if (result.status !== 0) return null;
40929
41021
  return parseChangedLineRanges(result.stdout);
40930
- })
41022
+ }).pipe(withSpan("Git.changedLineRanges"))
40931
41023
  });
40932
41024
  })).pipe(provide$2(layer$3.pipe(provide$2(mergeAll$1(layer$2, layer$1)))));
40933
41025
  /**
@@ -41142,7 +41234,7 @@ const neutralizeDisableDirectives = async (rootDirectory, includePaths) => {
41142
41234
  for (const [absolutePath, originalContent] of originalContents) try {
41143
41235
  NFS.writeFileSync(absolutePath, originalContent);
41144
41236
  } catch (error) {
41145
- 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`);
41237
+ process.stderr.write(`[react-doctor] Failed to restore inline disable directives in ${absolutePath}: ${messageFromUnknown(error)}\n[react-doctor] Run: git checkout -- ${absolutePath}\n`);
41146
41238
  }
41147
41239
  };
41148
41240
  const onExit = () => restore();
@@ -41248,7 +41340,7 @@ const resolveUserPlugin = (spec, configSourceDirectory) => {
41248
41340
  try {
41249
41341
  resolvedSpecifier = isRelative ? Path.resolve(configSourceDirectory, spec) : candidateRequire.resolve(spec);
41250
41342
  } catch (error) {
41251
- warnConfigIssue(`config.plugins entry "${spec}" could not be resolved from ${configSourceDirectory}: ${error instanceof Error ? error.message : String(error)}`);
41343
+ warnConfigIssue(`config.plugins entry "${spec}" could not be resolved from ${configSourceDirectory}: ${messageFromUnknown(error)}`);
41252
41344
  return null;
41253
41345
  }
41254
41346
  const { name, ruleNames } = readPluginShape(resolvedSpecifier, (target) => candidateRequire(target));
@@ -41320,8 +41412,8 @@ const buildUserPluginRules = (userPlugin, severityControls) => {
41320
41412
  }
41321
41413
  return enabled;
41322
41414
  };
41323
- const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [] }) => {
41324
- const reactHooksJsPlugin = resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
41415
+ const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set(), serverAuthFunctionNames, severityControls, userPlugins = [], disableReactHooksJsPlugin = false }) => {
41416
+ const reactHooksJsPlugin = disableReactHooksJsPlugin ? null : resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
41325
41417
  const reactCompilerRules = reactHooksJsPlugin ? applyRuleSeverityControls(filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames), severityControls) : {};
41326
41418
  const jsPlugins = [];
41327
41419
  if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
@@ -41381,7 +41473,6 @@ const resolveOxlintBinary = () => {
41381
41473
  return Path.join(oxlintPackageDirectory, "bin", "oxlint");
41382
41474
  };
41383
41475
  const resolvePluginPath = () => esmRequire.resolve("oxlint-plugin-react-doctor");
41384
- const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
41385
41476
  const resolveTsConfigRelativePath = (rootDirectory) => {
41386
41477
  for (const filename of TSCONFIG_FILENAMES) if (NFS.existsSync(Path.join(rootDirectory, filename))) return `./${filename}`;
41387
41478
  return null;
@@ -41753,7 +41844,7 @@ const scopeContainsNonImportBinding = (node, scopeNode, identifierName) => {
41753
41844
  const isIdentifierShadowedByLocalBinding = (identifier, sourceFile) => {
41754
41845
  let currentNode = identifier.parent;
41755
41846
  while (currentNode) {
41756
- if (isScopeNode(currentNode)) {
41847
+ if (isScopeBoundary(currentNode)) {
41757
41848
  if (scopeContainsNonImportBinding(currentNode, currentNode, identifier.text)) return true;
41758
41849
  }
41759
41850
  if (currentNode === sourceFile) return false;
@@ -41844,11 +41935,10 @@ const findResolutionInScope = (scopeNode, identifierName, reactImportBindings, s
41844
41935
  });
41845
41936
  return resolution;
41846
41937
  };
41847
- const isScopeNode = isScopeBoundary;
41848
41938
  const resolveIdentifierBinding = (identifier, reactImportBindings, sourceFile, visitedDeclarations = /* @__PURE__ */ new Set()) => {
41849
41939
  let currentNode = identifier.parent;
41850
41940
  while (currentNode) {
41851
- if (isScopeNode(currentNode)) {
41941
+ if (isScopeBoundary(currentNode)) {
41852
41942
  const resolution = findResolutionInScope(currentNode, identifier.text, reactImportBindings, sourceFile, visitedDeclarations);
41853
41943
  if (resolution) return resolution;
41854
41944
  }
@@ -42018,9 +42108,9 @@ const parseOxlintOutput = (stdout, project, rootDirectory) => {
42018
42108
  try {
42019
42109
  parsed = JSON.parse(sanitizedStdout);
42020
42110
  } catch {
42021
- throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
42111
+ throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 600) }) });
42022
42112
  }
42023
- if (!isOxlintOutput(parsed)) throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 200) }) });
42113
+ if (!isOxlintOutput(parsed)) throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ preview: stdout.slice(0, 600) }) });
42024
42114
  const minifiedFileCache = /* @__PURE__ */ new Map();
42025
42115
  const isMinifiedDiagnosticFile = (filename) => {
42026
42116
  const absolutePath = Path.isAbsolute(filename) ? filename : Path.resolve(rootDirectory || ".", filename);
@@ -42096,7 +42186,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42096
42186
  child.kill("SIGKILL");
42097
42187
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42098
42188
  kind: "timeout",
42099
- detail: `${spawnTimeoutMs / 1e3}s budget exceeded`
42189
+ detail: `${spawnTimeoutMs / MILLISECONDS_PER_SECOND}s budget exceeded`
42100
42190
  }) }));
42101
42191
  }, spawnTimeoutMs);
42102
42192
  timeoutHandle.unref?.();
@@ -42311,6 +42401,28 @@ const writeOxlintConfig = (configPath, configToWrite) => {
42311
42401
  NFS.closeSync(fileHandle);
42312
42402
  }
42313
42403
  };
42404
+ const REACT_HOOKS_JS_DROP_PREFIX = "React Compiler rules (react-hooks-js/*) skipped — eslint-plugin-react-hooks failed to load in this environment";
42405
+ /**
42406
+ * Detects an oxlint config-load crash caused by the optional
42407
+ * `react-hooks-js` (eslint-plugin-react-hooks) React Compiler plugin and
42408
+ * builds the partial-failure note for it; returns `null` when the failure
42409
+ * was anything else.
42410
+ *
42411
+ * oxlint prints a framed error to stdout (not stderr) and exits non-zero
42412
+ * when a `jsPlugins` entry can't be imported; that non-JSON stdout
42413
+ * surfaces as `OxlintOutputUnparseable`. Because oxlint fails the WHOLE
42414
+ * config load on it, leaving the plugin in would drop every curated
42415
+ * react-doctor diagnostic too — so the caller retries with the plugin
42416
+ * stripped (issue #833). Both markers sit at the start of oxlint's
42417
+ * message, so they survive the `preview` slice even for deep pnpm paths.
42418
+ */
42419
+ const reactHooksJsPluginDropNote = (error) => {
42420
+ if (!(error instanceof ReactDoctorError) || error.reason._tag !== "OxlintOutputUnparseable") return null;
42421
+ const { preview } = error.reason;
42422
+ if (!preview.includes("Failed to load JS plugin") || !preview.includes("eslint-plugin-react-hooks")) return null;
42423
+ const underlyingReason = preview.match(/Error:[^\n]*/)?.[0]?.trim();
42424
+ return `${REACT_HOOKS_JS_DROP_PREFIX}${underlyingReason ? `: ${underlyingReason}` : ""}. Other rules ran normally.`;
42425
+ };
42314
42426
  /**
42315
42427
  * The oxlint runner. Composed of three pieces in `runners/oxlint/`:
42316
42428
  *
@@ -42338,15 +42450,16 @@ const runOxlint = async (options) => {
42338
42450
  const pluginPath = resolvePluginPath();
42339
42451
  const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
42340
42452
  const userPlugins = resolveUserPlugins(userConfig?.plugins, configSourceDirectory);
42341
- const buildConfig = (extendsForThisAttempt) => createOxlintConfig({
42453
+ const buildConfig = (overrides) => createOxlintConfig({
42342
42454
  pluginPath,
42343
42455
  project,
42344
42456
  customRulesOnly,
42345
- extendsPaths: extendsForThisAttempt,
42457
+ extendsPaths: overrides.extendsPaths,
42346
42458
  ignoredTags,
42347
42459
  serverAuthFunctionNames,
42348
42460
  severityControls,
42349
- userPlugins
42461
+ userPlugins,
42462
+ disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
42350
42463
  });
42351
42464
  const restoreDisableDirectives = respectInlineDisables ? () => {} : await neutralizeDisableDirectives(rootDirectory, includePaths);
42352
42465
  const configDirectory = NFS.mkdtempSync(Path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
@@ -42382,12 +42495,22 @@ const runOxlint = async (options) => {
42382
42495
  outputMaxBytes,
42383
42496
  concurrency: options.concurrency
42384
42497
  });
42385
- writeOxlintConfig(configPath, buildConfig(extendsPaths));
42498
+ writeOxlintConfig(configPath, buildConfig({ extendsPaths }));
42386
42499
  try {
42387
42500
  return await runBatches();
42388
42501
  } catch (error) {
42502
+ const reactHooksJsDropNote = reactHooksJsPluginDropNote(error);
42503
+ if (reactHooksJsDropNote !== null) {
42504
+ writeOxlintConfig(configPath, buildConfig({
42505
+ extendsPaths,
42506
+ disableReactHooksJsPlugin: true
42507
+ }));
42508
+ const diagnostics = await runBatches();
42509
+ onPartialFailure?.(reactHooksJsDropNote);
42510
+ return diagnostics;
42511
+ }
42389
42512
  if (extendsPaths.length === 0) throw error;
42390
- writeOxlintConfig(configPath, buildConfig([]));
42513
+ writeOxlintConfig(configPath, buildConfig({ extendsPaths: [] }));
42391
42514
  return await runBatches();
42392
42515
  }
42393
42516
  } finally {
@@ -43185,17 +43308,17 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43185
43308
  }))))))));
43186
43309
  const deadCodeFailureState = yield* get$2(deadCodeFailure);
43187
43310
  const scanElapsedMilliseconds = Date.now() - scanStartTime;
43188
- const scanElapsedSeconds = (scanElapsedMilliseconds / 1e3).toFixed(1);
43311
+ const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1);
43189
43312
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
43190
43313
  else if (input.suppressScanSummary) yield* scanProgress.stop();
43191
43314
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
43192
43315
  yield* reporterService.finalize;
43193
- const finalDiagnostics = [
43316
+ const finalDiagnostics = assignFixGroups([
43194
43317
  ...envCollected,
43195
43318
  ...supplyChainCollected,
43196
43319
  ...lintCollected,
43197
43320
  ...deadCodeCollected
43198
- ];
43321
+ ]);
43199
43322
  const githubViewerPermission = yield* join(githubViewerPermissionFiber);
43200
43323
  const scoreMetadata = {
43201
43324
  ...repo !== null ? { repo } : {},
@@ -43422,7 +43545,7 @@ var StagedFiles = class StagedFiles extends Service()("react-doctor/StagedFiles"
43422
43545
  static layerNode = effect(StagedFiles, gen(function* () {
43423
43546
  const git = yield* Git;
43424
43547
  return StagedFiles.of({
43425
- discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(map$3((entries) => entries.filter(isLintableSourceFile))),
43548
+ discoverSourceFiles: (directory) => git.stagedFilePaths(directory).pipe(map$3((entries) => entries.filter(isLintableSourceFile)), withSpan("StagedFiles.discoverSourceFiles")),
43426
43549
  materialize: ({ directory, stagedFiles, tempDirectory }) => materializeSourceTree({
43427
43550
  directory,
43428
43551
  files: stagedFiles,
@@ -43432,7 +43555,7 @@ var StagedFiles = class StagedFiles extends Service()("react-doctor/StagedFiles"
43432
43555
  tempDirectory: tree.tempDirectory,
43433
43556
  stagedFiles: tree.materializedFiles,
43434
43557
  cleanup: tree.cleanup
43435
- })))
43558
+ })), withSpan("StagedFiles.materialize"))
43436
43559
  });
43437
43560
  }));
43438
43561
  /**
@@ -43565,6 +43688,7 @@ const buildJsonReport = (input) => {
43565
43688
  score: result.score,
43566
43689
  skippedChecks: result.skippedChecks,
43567
43690
  ...result.skippedCheckReasons ? { skippedCheckReasons: result.skippedCheckReasons } : {},
43691
+ ...typeof result.scannedFileCount === "number" ? { scannedFileCount: result.scannedFileCount } : {},
43568
43692
  elapsedMilliseconds: result.elapsedMilliseconds
43569
43693
  }));
43570
43694
  const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
@@ -43839,7 +43963,7 @@ const FALSY_CI_FLAG_VALUES = new Set([
43839
43963
  "false"
43840
43964
  ]);
43841
43965
  const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
43842
- const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
43966
+ const isCiEnvironment = (env = process.env) => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(env[environmentVariable])) || isCiFlagSet(env.CI);
43843
43967
  const detectCiProvider = () => {
43844
43968
  for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
43845
43969
  return isCiFlagSet(process.env.CI) ? "unknown" : null;
@@ -43864,6 +43988,53 @@ const detectCodingAgent = () => {
43864
43988
  const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
43865
43989
  const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
43866
43990
  //#endregion
43991
+ //#region src/cli/utils/detect-terminal-kind.ts
43992
+ const TERMINAL_BY_TERM_PROGRAM = [
43993
+ ["vscode", "vscode"],
43994
+ ["iTerm.app", "iterm"],
43995
+ ["Apple_Terminal", "apple-terminal"],
43996
+ ["WezTerm", "wezterm"],
43997
+ ["ghostty", "ghostty"],
43998
+ ["Hyper", "hyper"],
43999
+ ["Tabby", "tabby"],
44000
+ ["rio", "rio"]
44001
+ ];
44002
+ /**
44003
+ * Best-effort label for the terminal emulator / editor hosting the CLI,
44004
+ * derived from terminal-identity env vars. Recorded as the `terminalKind` run
44005
+ * tag so we can see where React Doctor is actually run (nvim, VS Code, iTerm,
44006
+ * …) — the split Sentry can't otherwise see. Low-cardinality and free of any
44007
+ * username/path/secret, so it's safe as a tag. Editor terminals (nvim/vim)
44008
+ * win over the outer emulator because that's the surface a user is reading in;
44009
+ * "ci" marks a run with no interactive terminal; "unknown" when nothing matches.
44010
+ */
44011
+ const detectTerminalKind = (env = process.env) => {
44012
+ if (env.NVIM) return "neovim";
44013
+ if (env.VIM_TERMINAL) return "vim";
44014
+ const termProgram = env.TERM_PROGRAM;
44015
+ if (termProgram) {
44016
+ for (const [marker, label] of TERMINAL_BY_TERM_PROGRAM) if (termProgram === marker) return label;
44017
+ }
44018
+ if (env.KITTY_WINDOW_ID || env.TERM === "xterm-kitty") return "kitty";
44019
+ if (env.WT_SESSION) return "windows-terminal";
44020
+ if (env.ALACRITTY_WINDOW_ID || env.TERM === "alacritty") return "alacritty";
44021
+ if (env.VTE_VERSION) return "vte";
44022
+ if (env.TMUX) return "tmux";
44023
+ if (isCiEnvironment(env)) return "ci";
44024
+ return "unknown";
44025
+ };
44026
+ //#endregion
44027
+ //#region src/cli/utils/is-debug-flag.ts
44028
+ /**
44029
+ * Whether the user passed `--debug` (surface the run's Sentry trace id, and
44030
+ * force performance tracing on so there's a trace to surface). Read straight
44031
+ * from argv rather than Commander's parsed flags because `initializeSentry()`
44032
+ * runs before Commander parses — the same reason `shouldEnableSentry()` reads
44033
+ * `--no-score` from argv. Sharing this one reader keeps the init-time sampling
44034
+ * override and the end-of-run print in agreement.
44035
+ */
44036
+ const isDebugFlagEnabled = (argv = process.argv) => argv.includes("--debug");
44037
+ //#endregion
43867
44038
  //#region src/cli/utils/is-git-hook-environment.ts
43868
44039
  const isGitHookEnvironment = () => Boolean(process.env.GIT_DIR);
43869
44040
  //#endregion
@@ -43886,6 +44057,7 @@ const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
43886
44057
  const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
43887
44058
  //#endregion
43888
44059
  //#region src/cli/utils/constants.ts
44060
+ const REACT_DOCTOR_CONFIG_PROJECT_NAME = "react-doctor";
43889
44061
  const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
43890
44062
  const BASELINE_FILES_TEMP_DIR_PREFIX = "react-doctor-baseline-";
43891
44063
  const GH_DEFAULT_BRANCH_PROBE_TIMEOUT_MS = 5e3;
@@ -43970,7 +44142,7 @@ const makeNoopConsole = () => ({
43970
44142
  });
43971
44143
  //#endregion
43972
44144
  //#region src/cli/utils/version.ts
43973
- const VERSION = "0.5.6-dev.f45cb29";
44145
+ const VERSION = "0.5.7-dev.242bf69";
43974
44146
  //#endregion
43975
44147
  //#region src/cli/utils/json-mode.ts
43976
44148
  let context = null;
@@ -44120,7 +44292,9 @@ const buildRunContext = () => {
44120
44292
  viaAction: isOfficialGithubAction(),
44121
44293
  codingAgent: detectCodingAgent(),
44122
44294
  interactive: !isNonInteractiveEnvironment(),
44295
+ terminalKind: detectTerminalKind(),
44123
44296
  jsonMode: isJsonModeActive(),
44297
+ debug: isDebugFlagEnabled(),
44124
44298
  invokedVia: detectInvokedVia()
44125
44299
  };
44126
44300
  };
@@ -44190,7 +44364,9 @@ const buildSentryScope = (runContext = buildRunContext()) => {
44190
44364
  viaAction: runContext.viaAction,
44191
44365
  codingAgent: runContext.codingAgent,
44192
44366
  interactive: runContext.interactive,
44367
+ terminalKind: runContext.terminalKind,
44193
44368
  jsonMode: runContext.jsonMode,
44369
+ debug: runContext.debug,
44194
44370
  invokedVia: runContext.invokedVia,
44195
44371
  nodeMajor: runContext.nodeMajor
44196
44372
  };
@@ -44328,13 +44504,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
44328
44504
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
44329
44505
  * standard `SENTRY_RELEASE` override.
44330
44506
  */
44331
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.6-dev.f45cb29`;
44507
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.7-dev.242bf69`;
44332
44508
  /**
44333
44509
  * Deployment environment shown in Sentry's environment filter. Defaults to
44334
44510
  * `production` for tagged releases and `development` for dev/unbuilt versions,
44335
44511
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
44336
44512
  */
44337
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.6-dev.f45cb29") ? "development" : "production");
44513
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.7-dev.242bf69") ? "development" : "production");
44338
44514
  /**
44339
44515
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
44340
44516
  * (set to `0` to disable tracing) and falls back to
@@ -44398,7 +44574,7 @@ const flushSentry = async () => {
44398
44574
  const initializeSentry = () => {
44399
44575
  if (isInitialized || !shouldEnableSentry()) return;
44400
44576
  isInitialized = true;
44401
- resolvedTracesSampleRate = resolveTracesSampleRate();
44577
+ resolvedTracesSampleRate = isDebugFlagEnabled() ? 1 : resolveTracesSampleRate();
44402
44578
  const { tags, contexts } = buildSentryScope();
44403
44579
  Sentry.init({
44404
44580
  dsn: process.env.SENTRY_DSN || "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920",
@@ -47614,6 +47790,11 @@ const setActiveRunTrace = (trace) => {
47614
47790
  activeRunTrace = trace;
47615
47791
  };
47616
47792
  const getActiveRunTrace = () => activeRunTrace;
47793
+ let lastRunTraceId = null;
47794
+ const recordRunTraceId = (traceId) => {
47795
+ lastRunTraceId = traceId;
47796
+ };
47797
+ const getLastRunTraceId = () => lastRunTraceId;
47617
47798
  //#endregion
47618
47799
  //#region src/cli/utils/to-span-attributes.ts
47619
47800
  /**
@@ -47676,14 +47857,13 @@ const withSentryRunSpan = (run, options = {}) => {
47676
47857
  op: "cli.inspect",
47677
47858
  attributes: toSpanAttributes(tags)
47678
47859
  }, (rootSpan) => {
47679
- if (options.concurrentScan !== true) {
47680
- const spanContext = rootSpan.spanContext();
47681
- setActiveRunTrace({
47682
- traceId: spanContext.traceId,
47683
- spanId: spanContext.spanId,
47684
- sampled: (spanContext.traceFlags & 1) === 1
47685
- });
47686
- }
47860
+ const spanContext = rootSpan.spanContext();
47861
+ recordRunTraceId(spanContext.traceId);
47862
+ if (options.concurrentScan !== true) setActiveRunTrace({
47863
+ traceId: spanContext.traceId,
47864
+ spanId: spanContext.spanId,
47865
+ sampled: (spanContext.traceFlags & 1) === 1
47866
+ });
47687
47867
  return run(rootSpan);
47688
47868
  });
47689
47869
  };
@@ -47823,6 +48003,42 @@ const recordScanMetrics = (input) => {
47823
48003
  });
47824
48004
  };
47825
48005
  //#endregion
48006
+ //#region src/cli/utils/diagnostic-grouping.ts
48007
+ const buildRulePriorityMap = (scores) => {
48008
+ const rulePriority = /* @__PURE__ */ new Map();
48009
+ for (const score of scores) {
48010
+ if (!score?.rules) continue;
48011
+ for (const [ruleKey, info] of Object.entries(score.rules)) if (typeof info.priority === "number") rulePriority.set(ruleKey, info.priority);
48012
+ }
48013
+ return rulePriority;
48014
+ };
48015
+ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
48016
+ const priorityA = rulePriority?.get(ruleKeyA);
48017
+ const priorityB = rulePriority?.get(ruleKeyB);
48018
+ if (priorityA === void 0 && priorityB === void 0) return 0;
48019
+ if (priorityA === void 0) return 1;
48020
+ if (priorityB === void 0) return -1;
48021
+ return priorityB - priorityA;
48022
+ };
48023
+ const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
48024
+ const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
48025
+ const getSharedFixSiteCount = (diagnostics) => {
48026
+ if (diagnostics.length < 2) return 0;
48027
+ const firstFixGroupId = diagnostics[0]?.fixGroupId;
48028
+ if (!firstFixGroupId) return 0;
48029
+ return diagnostics.every((diagnostic) => diagnostic.fixGroupId === firstFixGroupId) ? diagnostics.length : 0;
48030
+ };
48031
+ const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
48032
+ const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48033
+ const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48034
+ const buildRuleBlastRadii = (diagnostics) => buildSortedRuleGroups(diagnostics).map(([ruleKey, ruleDiagnostics]) => ({
48035
+ ruleKey,
48036
+ title: ruleDiagnostics[0].title ?? ruleKey,
48037
+ siteCount: ruleDiagnostics.length,
48038
+ fileCount: new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath)).size
48039
+ })).toSorted((left, right) => right.fileCount - left.fileCount);
48040
+ const findMigrationScaleBuckets = (diagnostics) => buildRuleBlastRadii(diagnostics).filter((bucket) => bucket.fileCount >= 40);
48041
+ //#endregion
47826
48042
  //#region src/cli/utils/cli-logger.ts
47827
48043
  /**
47828
48044
  * Thin synchronous façade over Effect's `Console` module. Used by
@@ -47939,12 +48155,17 @@ const buildOutcomeAttributes = (input) => {
47939
48155
  topRule = rule;
47940
48156
  topRuleCount = count;
47941
48157
  }
48158
+ const largestRuleBucket = buildRuleBlastRadii(result.diagnostics)[0] ?? null;
47942
48159
  let diagnosticsInTestFiles = 0;
47943
48160
  let diagnosticsInStoryFiles = 0;
48161
+ const findingsPerFixGroup = /* @__PURE__ */ new Map();
47944
48162
  for (const diagnostic of result.diagnostics) {
47945
48163
  if (diagnostic.fileContext === "test") diagnosticsInTestFiles += 1;
47946
48164
  if (diagnostic.fileContext === "story") diagnosticsInStoryFiles += 1;
48165
+ if (diagnostic.fixGroupId) findingsPerFixGroup.set(diagnostic.fixGroupId, (findingsPerFixGroup.get(diagnostic.fixGroupId) ?? 0) + 1);
47947
48166
  }
48167
+ let fixGroupedFindings = 0;
48168
+ for (const count of findingsPerFixGroup.values()) fixGroupedFindings += count;
47948
48169
  const attributes = {
47949
48170
  outcome,
47950
48171
  exitCode: wouldBlock ? 1 : 0,
@@ -47958,7 +48179,12 @@ const buildOutcomeAttributes = (input) => {
47958
48179
  diagnosticsInTestFiles,
47959
48180
  diagnosticsInStoryFiles,
47960
48181
  distinctRulesFired: countByRule.size,
48182
+ "diag.fixGroups": findingsPerFixGroup.size,
48183
+ "diag.fixGroupedFindings": fixGroupedFindings,
47961
48184
  topRule,
48185
+ "migration.largestRuleBucketFiles": largestRuleBucket ? largestRuleBucket.fileCount : null,
48186
+ "migration.largestRuleBucketSites": largestRuleBucket ? largestRuleBucket.siteCount : null,
48187
+ "migration.largestRuleBucketRule": largestRuleBucket ? largestRuleBucket.ruleKey : null,
47962
48188
  scannedFileCount: result.scannedFileCount ?? null,
47963
48189
  elapsedMs: result.elapsedMilliseconds,
47964
48190
  scanPhaseMs: result.scanElapsedMilliseconds ?? null,
@@ -48116,9 +48342,10 @@ const AGENT_GUIDANCE_LINES = [
48116
48342
  "Investigate deeply where relevant: race conditions, security-sensitive flows, state propagation, multi-file refactors, and downstream dependency chains.",
48117
48343
  "Ignore pure style preferences, theoretical issues without real impact, missing features, and unrelated pre-existing code.",
48118
48344
  "Start with high-confidence fixes that preserve behavior. Leave low-confidence or product-dependent changes as notes.",
48119
- "Run `npx react-doctor@latest --verbose --diff` before and after changes, plus relevant tests after each focused batch.",
48345
+ "Run `npx react-doctor@latest --verbose --scope changed` before and after changes, plus relevant tests after each focused batch.",
48120
48346
  "When available, spawn subagents or isolated worktrees for independent rule families, then review and merge only the best safe fixes.",
48121
48347
  "Split unrelated, broad, or behavior-changing work into separate PRs/branches instead of one large cleanup.",
48348
+ "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.",
48122
48349
  "For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.",
48123
48350
  "If a fix needs an API, UX, or architecture decision, stop and ask before editing."
48124
48351
  ];
@@ -48128,29 +48355,6 @@ const printAgentGuidance = () => gen(function* () {
48128
48355
  yield* log("");
48129
48356
  });
48130
48357
  //#endregion
48131
- //#region src/cli/utils/diagnostic-grouping.ts
48132
- const buildRulePriorityMap = (scores) => {
48133
- const rulePriority = /* @__PURE__ */ new Map();
48134
- for (const score of scores) {
48135
- if (!score?.rules) continue;
48136
- for (const [ruleKey, info] of Object.entries(score.rules)) if (typeof info.priority === "number") rulePriority.set(ruleKey, info.priority);
48137
- }
48138
- return rulePriority;
48139
- };
48140
- const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
48141
- const priorityA = rulePriority?.get(ruleKeyA);
48142
- const priorityB = rulePriority?.get(ruleKeyB);
48143
- if (priorityA === void 0 && priorityB === void 0) return 0;
48144
- if (priorityA === void 0) return 1;
48145
- if (priorityB === void 0) return -1;
48146
- return priorityB - priorityA;
48147
- };
48148
- const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
48149
- const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
48150
- const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
48151
- const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48152
- const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48153
- //#endregion
48154
48358
  //#region src/cli/utils/box-text.ts
48155
48359
  const ESCAPE = String.fromCharCode(27);
48156
48360
  const ANSI_ESCAPE_PATTERN = new RegExp(`${ESCAPE}\\[[0-9;]*m`, "g");
@@ -48191,6 +48395,15 @@ const boxText = (content, innerWidth) => {
48191
48395
  ].join("\n");
48192
48396
  };
48193
48397
  //#endregion
48398
+ //#region src/cli/utils/resolve-absolute-path.ts
48399
+ /**
48400
+ * Resolves a diagnostic's `filePath` (relative to its project root, or
48401
+ * already absolute) to an absolute path. Shared by the code-frame reader and
48402
+ * the terminal hyperlink builder so both turn a relative path into the same
48403
+ * on-disk location.
48404
+ */
48405
+ const resolveAbsolutePath = (filePath, rootDirectory) => Path.isAbsolute(filePath) ? filePath : Path.resolve(rootDirectory || ".", filePath);
48406
+ //#endregion
48194
48407
  //#region src/cli/utils/build-code-frame.ts
48195
48408
  /**
48196
48409
  * Renders a syntax-highlighted source excerpt around a diagnostic site
@@ -48201,7 +48414,7 @@ const boxText = (content, innerWidth) => {
48201
48414
  */
48202
48415
  const buildCodeFrame = (input) => {
48203
48416
  if (input.line <= 0) return null;
48204
- const absolutePath = Path.isAbsolute(input.filePath) ? input.filePath : Path.resolve(input.rootDirectory || ".", input.filePath);
48417
+ const absolutePath = resolveAbsolutePath(input.filePath, input.rootDirectory);
48205
48418
  let source;
48206
48419
  try {
48207
48420
  source = NFS.readFileSync(absolutePath, "utf8");
@@ -48241,6 +48454,16 @@ const resolveMeasureWidth = (reservedColumns = 0) => resolveClampedWidth({
48241
48454
  const DIVIDER_INDENT = " ";
48242
48455
  const buildSectionDivider = () => highlighter.dim(`${DIVIDER_INDENT}${"─".repeat(resolveMeasureWidth(2))}`);
48243
48456
  //#endregion
48457
+ //#region src/cli/utils/format-hyperlink.ts
48458
+ const OSC = "\x1B]";
48459
+ const ST = "\x1B\\";
48460
+ /**
48461
+ * Wraps `text` in an OSC 8 hyperlink pointing at `uri`. The visible characters
48462
+ * are exactly `text`; the link is carried in escape sequences a capable
48463
+ * terminal turns into a click target.
48464
+ */
48465
+ const formatHyperlink = (text, uri) => `${OSC}8;;${uri}${ST}${text}${OSC}8;;${ST}`;
48466
+ //#endregion
48244
48467
  //#region src/cli/utils/indent-multiline-text.ts
48245
48468
  const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
48246
48469
  //#endregion
@@ -48394,17 +48617,23 @@ const clusterNearbyDiagnostics = (diagnostics) => {
48394
48617
  }
48395
48618
  return clusters;
48396
48619
  };
48397
- const formatClusterLocation = (cluster) => {
48620
+ const formatClusterLocationText = (cluster) => {
48621
+ const { filePath } = cluster.diagnostics[0];
48622
+ if (cluster.startLine <= 0) return filePath;
48623
+ if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
48624
+ return `${filePath}:${cluster.startLine}`;
48625
+ };
48626
+ const formatClusterLocation = (cluster, resolveSourceRoot, hyperlinks) => {
48398
48627
  const lead = cluster.diagnostics[0];
48399
48628
  const contextTag = formatFileContextTag(lead);
48400
- if (cluster.startLine <= 0) return `${lead.filePath}${contextTag}`;
48401
- if (cluster.endLine > cluster.startLine) return `${lead.filePath}:${cluster.startLine}-${cluster.endLine}${contextTag}`;
48402
- return `${lead.filePath}:${cluster.startLine}${contextTag}`;
48629
+ const location = formatClusterLocationText(cluster);
48630
+ if (!hyperlinks) return `${location}${contextTag}`;
48631
+ return `${formatHyperlink(location, pathToFileURL(resolveAbsolutePath(lead.filePath, resolveSourceRoot(lead))).href)}${contextTag}`;
48403
48632
  };
48404
- const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
48633
+ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame, hyperlinks) => {
48405
48634
  const lead = cluster.diagnostics[0];
48406
48635
  const isMultiSite = cluster.diagnostics.length > 1;
48407
- const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
48636
+ const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster, resolveSourceRoot, hyperlinks)}`)];
48408
48637
  const codeFrame = renderCodeFrame ? buildCodeFrame({
48409
48638
  filePath: lead.filePath,
48410
48639
  line: cluster.startLine,
@@ -48423,7 +48652,7 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame
48423
48652
  }
48424
48653
  return lines;
48425
48654
  };
48426
- const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite, isAgentEnvironment) => {
48655
+ const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite, isAgentEnvironment, hyperlinks) => {
48427
48656
  const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
48428
48657
  const { severity } = representative;
48429
48658
  const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
@@ -48437,13 +48666,15 @@ const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, rende
48437
48666
  const impactMessages = isCollapsedWarningGroup ? [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.message))] : [representative.message];
48438
48667
  for (const impactMessage of impactMessages) for (const explanationLine of wrapTextToWidth(impactMessage, resolveMeasureWidth(4), { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
48439
48668
  if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, resolveMeasureWidth(4), { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
48669
+ const sharedFixSiteCount = getSharedFixSiteCount(ruleDiagnostics);
48670
+ if (sharedFixSiteCount > 0) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}↳ One fix clears all ${sharedFixSiteCount} findings.`));
48440
48671
  if (renderEverySite && isAgentEnvironment) {
48441
48672
  const fixRecipeLine = formatFixRecipeLine(representative);
48442
48673
  if (fixRecipeLine) lines.push(highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${fixRecipeLine}`));
48443
48674
  }
48444
48675
  const renderCodeFrame = severity === "error";
48445
48676
  const sites = renderEverySite ? ruleDiagnostics : [representative];
48446
- if (!(isCollapsedWarningGroup && representative.help.includes(representative.filePath))) for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
48677
+ if (!(isCollapsedWarningGroup && representative.help.includes(representative.filePath))) for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame, hyperlinks));
48447
48678
  return lines;
48448
48679
  };
48449
48680
  const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
@@ -48455,8 +48686,21 @@ const buildOverflowSummaryLine = (diagnostics, rulePriority) => {
48455
48686
  const command = highlighter.bold(highlighter.info("npx react-doctor@latest --verbose"));
48456
48687
  return ` ${highlighter.dim("Run")} ${command} ${highlighter.dim("to list every error and warning")}`;
48457
48688
  };
48689
+ const formatMigrationBucketLine = (bucket) => `${TOP_ERROR_DETAIL_INDENT}${bucket.title} ${highlighter.gray(`×${bucket.siteCount} across ${bucket.fileCount} files`)}`;
48690
+ const buildMigrationScaleAdvisoryLines = (diagnostics) => {
48691
+ const buckets = findMigrationScaleBuckets(diagnostics);
48692
+ if (buckets.length === 0) return [];
48693
+ const shownBuckets = buckets.slice(0, 3);
48694
+ const lines = [` ${highlighter.warn("⚠")} ${highlighter.bold("Migration-scale change")}${highlighter.dim(": sample before you sweep")}`, ...shownBuckets.map(formatMigrationBucketLine)];
48695
+ const remainingBuckets = buckets.length - shownBuckets.length;
48696
+ if (remainingBuckets > 0) lines.push(highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}+${remainingBuckets} more ${remainingBuckets === 1 ? "rule" : "rules"} at this scale`));
48697
+ 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}`));
48698
+ const command = highlighter.info("npx react-doctor@latest <path>");
48699
+ lines.push(`${TOP_ERROR_DETAIL_INDENT}${highlighter.dim("Scope it down one area at a time:")} ${command}`);
48700
+ return lines;
48701
+ };
48458
48702
  const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
48459
- const buildTopErrorsSection = (diagnostics, resolveSourceRoot, rulePriority) => {
48703
+ const buildTopErrorsSection = (diagnostics, resolveSourceRoot, hyperlinks, rulePriority) => {
48460
48704
  const topRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority).slice(0, 3);
48461
48705
  if (topRuleGroups.length === 0) return {
48462
48706
  lines: [],
@@ -48466,7 +48710,7 @@ const buildTopErrorsSection = (diagnostics, resolveSourceRoot, rulePriority) =>
48466
48710
  const blockOffsets = [];
48467
48711
  for (const [ruleKey, ruleDiagnostics] of topRuleGroups) {
48468
48712
  blockOffsets.push(lines.length);
48469
- lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false, false));
48713
+ lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false, false, hyperlinks));
48470
48714
  lines.push("");
48471
48715
  }
48472
48716
  return {
@@ -48504,24 +48748,24 @@ const buildOverviewHeaderLines = (diagnostics) => {
48504
48748
  * single Effect.forEach over Console.log so failures or fiber
48505
48749
  * interruption produce predictable partial output.
48506
48750
  */
48507
- const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false, onboarding = {}) => gen(function* () {
48751
+ const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false, onboarding = {}, hyperlinks = false) => gen(function* () {
48508
48752
  const sectionPause = onboarding.sectionPause ?? void_;
48509
48753
  const animateCountUp = onboarding.animateCountUp ?? false;
48510
48754
  const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
48511
48755
  let detailLines;
48512
48756
  let topErrorBlockOffsets = [];
48513
48757
  if (!isVerbose) {
48514
- const topErrors = buildTopErrorsSection(diagnostics, resolveSourceRoot, rulePriority);
48758
+ const topErrors = buildTopErrorsSection(diagnostics, resolveSourceRoot, hyperlinks, rulePriority);
48515
48759
  detailLines = topErrors.lines;
48516
48760
  topErrorBlockOffsets = topErrors.blockOffsets;
48517
48761
  } else detailLines = buildSortedRuleGroups(diagnostics, rulePriority).flatMap(([ruleKey, ruleDiagnostics]) => {
48518
- return [...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true, isAgentEnvironment), ""];
48762
+ return [...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true, isAgentEnvironment, hyperlinks), ""];
48519
48763
  });
48520
48764
  const overflowLine = isVerbose ? void 0 : buildOverflowSummaryLine(diagnostics, rulePriority);
48521
48765
  const categoryTallies = buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCategoryTally);
48522
48766
  const categoryLines = buildCategoryTallyLines(categoryTallies);
48523
48767
  const overviewDividerLines = detailLines.length > 0 && categoryLines.length > 0 ? [buildSectionDivider()] : [];
48524
- const { lines, sectionStarts } = joinSections(detailLines, overviewDividerLines, buildOverviewHeaderLines(diagnostics), categoryLines, overflowLine ? [overflowLine] : []);
48768
+ const { lines, sectionStarts } = joinSections(detailLines, overviewDividerLines, buildOverviewHeaderLines(diagnostics), categoryLines, overflowLine ? [overflowLine] : [], buildMigrationScaleAdvisoryLines(diagnostics));
48525
48769
  const [detailStart, , , categoryStart] = sectionStarts;
48526
48770
  const pauseBeforeLineIndices = detailStart == null ? /* @__PURE__ */ new Set() : new Set(topErrorBlockOffsets.map((offset) => detailStart + offset));
48527
48771
  let lineIndex = 0;
@@ -48576,6 +48820,48 @@ const computeProjectedScore = async (topErrorSource, rescoreSource, currentScore
48576
48820
  //#endregion
48577
48821
  //#region src/cli/utils/filter-diagnostics-by-categories.ts
48578
48822
  const filterDiagnosticsByCategories = (diagnostics, categories) => categories.size === 0 ? [...diagnostics] : diagnostics.filter((diagnostic) => categories.has(diagnostic.category));
48823
+ //#endregion
48824
+ //#region src/cli/utils/supports-hyperlinks.ts
48825
+ const HYPERLINK_CAPABLE_TERM_PROGRAMS = new Set([
48826
+ "iTerm.app",
48827
+ "WezTerm",
48828
+ "vscode",
48829
+ "Hyper",
48830
+ "ghostty",
48831
+ "Tabby",
48832
+ "rio"
48833
+ ]);
48834
+ const parseVteVersion = (raw) => {
48835
+ const parsed = Number.parseInt(raw ?? "", 10);
48836
+ return Number.isNaN(parsed) ? 0 : parsed;
48837
+ };
48838
+ /**
48839
+ * Whether `stream` is a terminal that renders OSC 8 hyperlinks. Auto-detected
48840
+ * from terminal-identity env vars; the de-facto `FORCE_HYPERLINK` env var
48841
+ * overrides detection (`FORCE_HYPERLINK=0`/`false` forces off, any other value
48842
+ * forces on), mirroring how the ecosystem's terminal libraries gate the same
48843
+ * feature. Off for non-TTYs, `TERM=dumb`, and CI (whose log viewers render the
48844
+ * raw escape rather than a link). Unknown terminals default to off.
48845
+ */
48846
+ const supportsHyperlinks = (stream = process.stdout, env = process.env) => {
48847
+ const forced = env.FORCE_HYPERLINK;
48848
+ if (forced !== void 0 && forced !== "") return forced !== "0" && forced.toLowerCase() !== "false";
48849
+ if (stream.isTTY !== true) return false;
48850
+ if (env.TERM === "dumb") return false;
48851
+ if (isCiEnvironment(env)) return false;
48852
+ if (env.WT_SESSION) return true;
48853
+ if (env.KITTY_WINDOW_ID || env.TERM === "xterm-kitty") return true;
48854
+ if (parseVteVersion(env.VTE_VERSION) >= 5e3) return true;
48855
+ return Boolean(env.TERM_PROGRAM && HYPERLINK_CAPABLE_TERM_PROGRAMS.has(env.TERM_PROGRAM));
48856
+ };
48857
+ //#endregion
48858
+ //#region src/cli/utils/should-render-hyperlinks.ts
48859
+ /**
48860
+ * Whether to emit OSC 8 clickable `file:line` locations for this run: a
48861
+ * hyperlink-capable terminal AND not a coding agent (whose output parsers
48862
+ * would choke on the escape sequences).
48863
+ */
48864
+ const shouldRenderHyperlinks = (stream = process.stdout) => supportsHyperlinks(stream) && !isCodingAgentEnvironment();
48579
48865
  const FORCE_ONBOARDING_ENV_VAR = "REACT_DOCTOR_FORCE_ONBOARDING";
48580
48866
  const FALSY_FLAG_VALUES = new Set([
48581
48867
  "",
@@ -48595,10 +48881,9 @@ const canAnimateOnboarding = (stream = process.stdout) => {
48595
48881
  };
48596
48882
  //#endregion
48597
48883
  //#region src/cli/utils/onboarding-state.ts
48598
- const GLOBAL_CONFIG_PROJECT_NAME$2 = "react-doctor";
48599
48884
  const ONBOARDED_AT_KEY = "onboardedAt";
48600
48885
  const getOnboardingStore = (options = {}) => new Conf({
48601
- projectName: GLOBAL_CONFIG_PROJECT_NAME$2,
48886
+ projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
48602
48887
  cwd: options.cwd
48603
48888
  });
48604
48889
  const hasCompletedOnboarding = (options = {}) => {
@@ -49054,6 +49339,78 @@ const resolveCliCategories = (categoryFlag) => {
49054
49339
  return resolvedCategories.length > 0 ? resolvedCategories : void 0;
49055
49340
  };
49056
49341
  //#endregion
49342
+ //#region src/cli/utils/git-hook-shared.ts
49343
+ const HOOK_FILE_NAME = "pre-commit";
49344
+ const HOOK_RELATIVE_PATH = "hooks/pre-commit";
49345
+ const LEGACY_HOOK_RUNNER_RELATIVE_PATH = ".react-doctor/hooks/pre-commit";
49346
+ const HUSKY_HOOKS_PATH = ".husky";
49347
+ const VITE_PLUS_HOOKS_PATH = ".vite-hooks";
49348
+ const LEFTHOOK_CONFIG_FILES = ["lefthook.yml", "lefthook.yaml"];
49349
+ const PRE_COMMIT_CONFIG_FILE = ".pre-commit-config.yaml";
49350
+ const OVERCOMMIT_CONFIG_FILE = ".overcommit.yml";
49351
+ const REACT_DOCTOR_COMMAND = "react-doctor --staged --blocking warning";
49352
+ const NON_BLOCKING_REACT_DOCTOR_COMMAND = [
49353
+ "react_doctor_output=$(mktemp \"${TMPDIR:-/tmp}/react-doctor-hook.XXXXXX\");",
49354
+ `if ${REACT_DOCTOR_COMMAND} > "$react_doctor_output" 2>&1; then`,
49355
+ "rm -f \"$react_doctor_output\";",
49356
+ "else",
49357
+ "rm -f \"$react_doctor_output\";",
49358
+ `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;`,
49359
+ "fi"
49360
+ ].join(" ");
49361
+ const PACKAGE_JSON_FILE_NAME = "package.json";
49362
+ const runGit = (projectRoot, args) => {
49363
+ try {
49364
+ return execFileSync("git", [...args], {
49365
+ cwd: projectRoot,
49366
+ encoding: "utf8",
49367
+ stdio: [
49368
+ "ignore",
49369
+ "pipe",
49370
+ "ignore"
49371
+ ]
49372
+ }).trim();
49373
+ } catch {
49374
+ return null;
49375
+ }
49376
+ };
49377
+ const resolveGitPath = (baseDirectory, value) => Path.isAbsolute(value) ? value : Path.resolve(baseDirectory, value);
49378
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
49379
+ const getPackageJsonPath = (projectRoot) => Path.join(projectRoot, PACKAGE_JSON_FILE_NAME);
49380
+ const readPackageJson = (projectRoot) => {
49381
+ try {
49382
+ return JSON.parse(NFS.readFileSync(getPackageJsonPath(projectRoot), "utf8"));
49383
+ } catch {
49384
+ return null;
49385
+ }
49386
+ };
49387
+ const writeJsonFile$1 = (filePath, value) => {
49388
+ NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
49389
+ };
49390
+ const packageHasDependency = (projectRoot, dependencyName) => {
49391
+ const packageJson = readPackageJson(projectRoot);
49392
+ if (!isRecord$1(packageJson)) return false;
49393
+ return [
49394
+ "dependencies",
49395
+ "devDependencies",
49396
+ "optionalDependencies"
49397
+ ].some((fieldName) => {
49398
+ const dependencies = packageJson[fieldName];
49399
+ return isRecord$1(dependencies) && typeof dependencies[dependencyName] === "string";
49400
+ });
49401
+ };
49402
+ const packageHasRecordKey = (projectRoot, key) => {
49403
+ const packageJson = readPackageJson(projectRoot);
49404
+ return isRecord$1(packageJson) && isRecord$1(packageJson[key]);
49405
+ };
49406
+ const packageHasNestedRecordKey = (projectRoot, key, nestedKey) => {
49407
+ const packageJson = readPackageJson(projectRoot);
49408
+ if (!isRecord$1(packageJson)) return false;
49409
+ const value = packageJson[key];
49410
+ return isRecord$1(value) && isRecord$1(value[nestedKey]);
49411
+ };
49412
+ const ensureTrailingNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
49413
+ //#endregion
49057
49414
  //#region src/cli/utils/scan-result-cache.ts
49058
49415
  const CACHE_DISABLED_VALUES = new Set(["1", "true"]);
49059
49416
  const TOOLCHAIN_PACKAGE_SPECIFIERS = [
@@ -49064,7 +49421,7 @@ const TOOLCHAIN_PACKAGE_SPECIFIERS = [
49064
49421
  "eslint-plugin-react-hooks/package.json"
49065
49422
  ];
49066
49423
  const bundledRequire = createRequire(import.meta.url);
49067
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
49424
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
49068
49425
  const normalizeForStableJson = (value) => {
49069
49426
  if (value === null) return null;
49070
49427
  if (value === void 0) return void 0;
@@ -49093,24 +49450,9 @@ const stringifyStableJson = (value) => {
49093
49450
  }
49094
49451
  };
49095
49452
  const hashString = (value) => crypto.createHash("sha1").update(value).digest("hex");
49096
- const runGit$1 = (directory, args) => {
49097
- try {
49098
- return execFileSync("git", [...args], {
49099
- cwd: directory,
49100
- encoding: "utf8",
49101
- stdio: [
49102
- "ignore",
49103
- "pipe",
49104
- "ignore"
49105
- ]
49106
- }).trim();
49107
- } catch {
49108
- return null;
49109
- }
49110
- };
49111
- const readHeadSha = (projectDirectory) => runGit$1(projectDirectory, ["rev-parse", "HEAD"]);
49453
+ const readHeadSha = (projectDirectory) => runGit(projectDirectory, ["rev-parse", "HEAD"]);
49112
49454
  const isWorktreeClean = (projectDirectory) => {
49113
- const status = runGit$1(projectDirectory, [
49455
+ const status = runGit(projectDirectory, [
49114
49456
  "status",
49115
49457
  "--porcelain=v1",
49116
49458
  "--untracked-files=normal"
@@ -49118,7 +49460,7 @@ const isWorktreeClean = (projectDirectory) => {
49118
49460
  return status !== null && status.length === 0;
49119
49461
  };
49120
49462
  const hasHiddenTrackedFileState = (projectDirectory) => {
49121
- const output = runGit$1(projectDirectory, ["ls-files", "-v"]);
49463
+ const output = runGit(projectDirectory, ["ls-files", "-v"]);
49122
49464
  if (output === null) return true;
49123
49465
  return output.split("\n").some((line) => line.length > 0 && line[0] !== "H");
49124
49466
  };
@@ -49131,7 +49473,7 @@ const resolveCacheFilePath = (projectDirectory) => {
49131
49473
  const readPersistedCache = (cacheFilePath) => {
49132
49474
  try {
49133
49475
  const parsed = JSON.parse(fs.readFileSync(cacheFilePath, "utf8"));
49134
- if (!isRecord$1(parsed) || parsed.version !== 1) return {
49476
+ if (!isRecord(parsed) || parsed.version !== 1) return {
49135
49477
  version: 1,
49136
49478
  entries: []
49137
49479
  };
@@ -49141,8 +49483,8 @@ const readPersistedCache = (cacheFilePath) => {
49141
49483
  };
49142
49484
  const entries = [];
49143
49485
  for (const entry of parsed.entries) {
49144
- if (!isRecord$1(entry) || typeof entry.key !== "string" || typeof entry.createdAtMs !== "number") continue;
49145
- if (!isRecord$1(entry.payload) || !Array.isArray(entry.payload.diagnostics)) continue;
49486
+ if (!isRecord(entry) || typeof entry.key !== "string" || typeof entry.createdAtMs !== "number") continue;
49487
+ if (!isRecord(entry.payload) || !Array.isArray(entry.payload.diagnostics)) continue;
49146
49488
  entries.push(entry);
49147
49489
  }
49148
49490
  return {
@@ -49674,6 +50016,7 @@ const finalizeAndRender = (input) => gen(function* () {
49674
50016
  }
49675
50017
  const animateRender = !options.silent && !options.verbose && canAnimateOnboarding(process.stdout);
49676
50018
  const pause = onboardingSectionPause(animateRender);
50019
+ const useHyperlinks = shouldRenderHyperlinks(process.stdout);
49677
50020
  const demotedDiagnosticCount = diagnostics.length - surfaceDiagnostics.length;
49678
50021
  const lintSourceFileCount = options.includePaths.length > 0 ? options.includePaths.length : project.sourceFileCount;
49679
50022
  if (printedDiagnostics.length === 0) {
@@ -49699,7 +50042,7 @@ const finalizeAndRender = (input) => gen(function* () {
49699
50042
  yield* printDiagnostics([...printedDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment(), {
49700
50043
  sectionPause: pause,
49701
50044
  animateCountUp: animateRender
49702
- });
50045
+ }, useHyperlinks);
49703
50046
  if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
49704
50047
  if (options.categoryFilters.size === 0 && demotedDiagnosticCount > 0) {
49705
50048
  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.`));
@@ -49888,24 +50231,18 @@ const isExpectedUserError = (error) => error instanceof CliInputError || isProje
49888
50231
  //#region src/cli/utils/build-handoff-payload.ts
49889
50232
  const buildHandoffPayload = (input) => {
49890
50233
  const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
50234
+ const migrationScaleBuckets = new Map(findMigrationScaleBuckets(input.diagnostics).map((bucket) => [bucket.ruleKey, bucket]));
49891
50235
  let outputDirectory = null;
49892
50236
  try {
49893
50237
  outputDirectory = writeDiagnosticsDirectory([...input.diagnostics], input.outputDirectory);
49894
50238
  } catch {}
49895
- const lines = [
49896
- "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:",
49897
- `- It's used by teams at ${CI_TRUST_COMPANIES}.`,
49898
- "- I don't have to fix everything at once; CI keeps new PRs clean while I incrementally pay down the existing issues.",
49899
- `- Setup guide: ${CI_URL}`,
49900
- "If I say yes, run `npx react-doctor install` (or add the GitHub Actions workflow). Either way, then continue with the fixes below.",
49901
- "",
49902
- `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.`,
49903
- ""
49904
- ];
50239
+ 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.`, ""];
49905
50240
  topGroups.forEach(([ruleKey, ruleDiagnostics], index) => {
49906
50241
  const representative = ruleDiagnostics[0];
49907
50242
  const severityLabel = representative.severity === "error" ? "ERROR" : "WARN";
49908
- lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (×${ruleDiagnostics.length})`, ` ${representative.message}`);
50243
+ const sharedFixSiteCount = getSharedFixSiteCount(ruleDiagnostics);
50244
+ const countBadge = sharedFixSiteCount > 0 ? `one fix · ${sharedFixSiteCount} sites` : `×${ruleDiagnostics.length}`;
50245
+ lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (${countBadge})`, ` ${representative.message}`);
49909
50246
  const fixRecipeLine = formatFixRecipeLine(representative);
49910
50247
  if (fixRecipeLine) lines.push(` ${fixRecipeLine}`);
49911
50248
  const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
@@ -49915,10 +50252,19 @@ const buildHandoffPayload = (input) => {
49915
50252
  }
49916
50253
  const remainingFiles = uniqueFiles.length - 3;
49917
50254
  if (remainingFiles > 0) lines.push(` - +${remainingFiles} more files`);
50255
+ const migrationBucket = migrationScaleBuckets.get(ruleKey);
50256
+ 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.`);
49918
50257
  });
49919
50258
  lines.push("");
49920
50259
  if (outputDirectory) lines.push(`Full results for all ${input.diagnostics.length} issues (diagnostics.json + a .txt per rule): ${outputDirectory}`, "");
49921
- 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.");
50260
+ 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.", "");
50261
+ const shownRuleKeys = new Set(topGroups.map(([ruleKey]) => ruleKey));
50262
+ const deferredMigrationBuckets = [...migrationScaleBuckets.values()].filter((bucket) => !shownRuleKeys.has(bucket.ruleKey));
50263
+ if (deferredMigrationBuckets.length > 0) {
50264
+ const ruleSummaries = deferredMigrationBuckets.map((bucket) => `${bucket.title} (${bucket.fileCount} files)`).join(", ");
50265
+ 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.`, "");
50266
+ }
50267
+ lines.push("Then work through the rest from the full results above.");
49922
50268
  return lines.join("\n");
49923
50269
  };
49924
50270
  //#endregion
@@ -49962,78 +50308,6 @@ const detectAvailableAgents = async () => {
49962
50308
  return getSkillAgentTypes().filter((agent) => agent !== "universal" && detected.has(agent));
49963
50309
  };
49964
50310
  //#endregion
49965
- //#region src/cli/utils/git-hook-shared.ts
49966
- const HOOK_FILE_NAME = "pre-commit";
49967
- const HOOK_RELATIVE_PATH = "hooks/pre-commit";
49968
- const LEGACY_HOOK_RUNNER_RELATIVE_PATH = ".react-doctor/hooks/pre-commit";
49969
- const HUSKY_HOOKS_PATH = ".husky";
49970
- const VITE_PLUS_HOOKS_PATH = ".vite-hooks";
49971
- const LEFTHOOK_CONFIG_FILES = ["lefthook.yml", "lefthook.yaml"];
49972
- const PRE_COMMIT_CONFIG_FILE = ".pre-commit-config.yaml";
49973
- const OVERCOMMIT_CONFIG_FILE = ".overcommit.yml";
49974
- const REACT_DOCTOR_COMMAND = "react-doctor --staged --blocking warning";
49975
- const NON_BLOCKING_REACT_DOCTOR_COMMAND = [
49976
- "react_doctor_output=$(mktemp \"${TMPDIR:-/tmp}/react-doctor-hook.XXXXXX\");",
49977
- `if ${REACT_DOCTOR_COMMAND} > "$react_doctor_output" 2>&1; then`,
49978
- "rm -f \"$react_doctor_output\";",
49979
- "else",
49980
- "rm -f \"$react_doctor_output\";",
49981
- `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;`,
49982
- "fi"
49983
- ].join(" ");
49984
- const PACKAGE_JSON_FILE_NAME = "package.json";
49985
- const runGit = (projectRoot, args) => {
49986
- try {
49987
- return execFileSync("git", [...args], {
49988
- cwd: projectRoot,
49989
- encoding: "utf8",
49990
- stdio: [
49991
- "ignore",
49992
- "pipe",
49993
- "ignore"
49994
- ]
49995
- }).trim();
49996
- } catch {
49997
- return null;
49998
- }
49999
- };
50000
- const resolveGitPath = (baseDirectory, value) => Path.isAbsolute(value) ? value : Path.resolve(baseDirectory, value);
50001
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
50002
- const getPackageJsonPath = (projectRoot) => Path.join(projectRoot, PACKAGE_JSON_FILE_NAME);
50003
- const readPackageJson = (projectRoot) => {
50004
- try {
50005
- return JSON.parse(NFS.readFileSync(getPackageJsonPath(projectRoot), "utf8"));
50006
- } catch {
50007
- return null;
50008
- }
50009
- };
50010
- const writeJsonFile$1 = (filePath, value) => {
50011
- NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
50012
- };
50013
- const packageHasDependency = (projectRoot, dependencyName) => {
50014
- const packageJson = readPackageJson(projectRoot);
50015
- if (!isRecord(packageJson)) return false;
50016
- return [
50017
- "dependencies",
50018
- "devDependencies",
50019
- "optionalDependencies"
50020
- ].some((fieldName) => {
50021
- const dependencies = packageJson[fieldName];
50022
- return isRecord(dependencies) && typeof dependencies[dependencyName] === "string";
50023
- });
50024
- };
50025
- const packageHasRecordKey = (projectRoot, key) => {
50026
- const packageJson = readPackageJson(projectRoot);
50027
- return isRecord(packageJson) && isRecord(packageJson[key]);
50028
- };
50029
- const packageHasNestedRecordKey = (projectRoot, key, nestedKey) => {
50030
- const packageJson = readPackageJson(projectRoot);
50031
- if (!isRecord(packageJson)) return false;
50032
- const value = packageJson[key];
50033
- return isRecord(value) && isRecord(value[nestedKey]);
50034
- };
50035
- const ensureTrailingNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
50036
- //#endregion
50037
50311
  //#region src/cli/utils/install-doctor-script.ts
50038
50312
  const DOCTOR_SCRIPT_NAME = "doctor";
50039
50313
  const FALLBACK_DOCTOR_SCRIPT_NAME = "react-doctor";
@@ -50059,31 +50333,31 @@ const findNearestPackageDirectory = (startDirectory, stopDirectory) => {
50059
50333
  };
50060
50334
  const hasDoctorScript = (projectRoot) => {
50061
50335
  const packageJson = readPackageJson(findNearestPackageDirectory(projectRoot) ?? projectRoot);
50062
- if (!isRecord(packageJson)) return false;
50336
+ if (!isRecord$1(packageJson)) return false;
50063
50337
  const scripts = packageJson.scripts;
50064
- if (!isRecord(scripts)) return false;
50338
+ if (!isRecord$1(scripts)) return false;
50065
50339
  return isReactDoctorScriptCommand(scripts[DOCTOR_SCRIPT_NAME]) || isReactDoctorScriptCommand(scripts[FALLBACK_DOCTOR_SCRIPT_NAME]);
50066
50340
  };
50067
50341
  const hasDoctorDependency = (packageJson) => DEPENDENCY_FIELD_NAMES.some((fieldName) => {
50068
50342
  const dependencies = packageJson[fieldName];
50069
- return isRecord(dependencies) && Object.hasOwn(dependencies, "react-doctor");
50343
+ return isRecord$1(dependencies) && Object.hasOwn(dependencies, "react-doctor");
50070
50344
  });
50071
50345
  const installDoctorScript = (options) => {
50072
50346
  const packageDirectory = findNearestPackageDirectory(options.projectRoot) ?? options.projectRoot;
50073
50347
  const packageJsonPath = getPackageJsonPath(packageDirectory);
50074
50348
  const packageJson = readPackageJson(packageDirectory);
50075
- if (!isRecord(packageJson)) return {
50349
+ if (!isRecord$1(packageJson)) return {
50076
50350
  packageJsonPath,
50077
50351
  scriptStatus: "skipped",
50078
50352
  scriptReason: "missing-or-invalid-package-json"
50079
50353
  };
50080
50354
  const scripts = packageJson.scripts;
50081
50355
  const scriptTarget = (() => {
50082
- if (scripts !== void 0 && !isRecord(scripts)) return {
50356
+ if (scripts !== void 0 && !isRecord$1(scripts)) return {
50083
50357
  status: "skipped",
50084
50358
  reason: "invalid-scripts"
50085
50359
  };
50086
- const scriptRecord = isRecord(scripts) ? scripts : {};
50360
+ const scriptRecord = isRecord$1(scripts) ? scripts : {};
50087
50361
  if (isReactDoctorScriptCommand(scriptRecord[DOCTOR_SCRIPT_NAME])) return {
50088
50362
  scriptName: DOCTOR_SCRIPT_NAME,
50089
50363
  status: "existing"
@@ -50117,7 +50391,7 @@ const installDoctorScript = (options) => {
50117
50391
  if (scriptStatus === "created") writeJsonFile$1(packageJsonPath, {
50118
50392
  ...packageJson,
50119
50393
  scripts: {
50120
- ...isRecord(scripts) ? scripts : {},
50394
+ ...isRecord$1(scripts) ? scripts : {},
50121
50395
  [scriptTarget.scriptName ?? DOCTOR_SCRIPT_NAME]: DOCTOR_SCRIPT_COMMAND
50122
50396
  }
50123
50397
  });
@@ -50271,38 +50545,52 @@ const upgradeReactDoctorWorkflowInPlace = (projectRoot) => {
50271
50545
  //#region src/cli/utils/hash-project-root.ts
50272
50546
  const hashProjectRoot = (projectRoot) => createHash("sha256").update(Path.resolve(projectRoot)).digest("hex");
50273
50547
  //#endregion
50274
- //#region src/cli/utils/action-upgrade-prompt.ts
50275
- const GLOBAL_CONFIG_PROJECT_NAME$1 = "react-doctor";
50276
- const getActionUpgradeStore = (options = {}) => new Conf({
50277
- projectName: GLOBAL_CONFIG_PROJECT_NAME$1,
50278
- cwd: options.cwd
50279
- });
50280
- const hasHandledActionUpgrade = (projectRoot, storeOptions = {}) => {
50281
- try {
50282
- const upgrades = getActionUpgradeStore(storeOptions).get("actionUpgrades", {});
50283
- return Boolean(upgrades[hashProjectRoot(projectRoot)]);
50284
- } catch {
50285
- return true;
50286
- }
50287
- };
50288
- const recordActionUpgradeDecision = (projectRoot, outcome, storeOptions = {}) => {
50289
- try {
50290
- const store = getActionUpgradeStore(storeOptions);
50291
- const upgrades = store.get("actionUpgrades", {});
50292
- store.set("actionUpgrades", {
50293
- ...upgrades,
50294
- [hashProjectRoot(projectRoot)]: {
50295
- rootDirectory: Path.resolve(projectRoot),
50296
- outcome,
50297
- at: (/* @__PURE__ */ new Date()).toISOString()
50548
+ //#region src/cli/utils/project-decision-store.ts
50549
+ const createProjectDecisionStore = (storeKey) => {
50550
+ const getStore = (options = {}) => new Conf({
50551
+ projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
50552
+ cwd: options.cwd
50553
+ });
50554
+ return {
50555
+ getConfigPath: (options = {}) => getStore(options).path,
50556
+ hasHandled: (projectRoot, options = {}) => {
50557
+ try {
50558
+ return Boolean(getStore(options).get(storeKey, {})[hashProjectRoot(projectRoot)]);
50559
+ } catch {
50560
+ return true;
50298
50561
  }
50299
- });
50300
- return true;
50301
- } catch {
50302
- return false;
50303
- }
50562
+ },
50563
+ record: (projectRoot, outcome, options = {}) => {
50564
+ try {
50565
+ const store = getStore(options);
50566
+ store.set(storeKey, {
50567
+ ...store.get(storeKey, {}),
50568
+ [hashProjectRoot(projectRoot)]: {
50569
+ rootDirectory: Path.resolve(projectRoot),
50570
+ outcome,
50571
+ at: (/* @__PURE__ */ new Date()).toISOString()
50572
+ }
50573
+ });
50574
+ return true;
50575
+ } catch {
50576
+ return false;
50577
+ }
50578
+ }
50579
+ };
50304
50580
  };
50305
50581
  //#endregion
50582
+ //#region src/cli/utils/action-upgrade-prompt.ts
50583
+ const store$1 = createProjectDecisionStore("actionUpgrades");
50584
+ store$1.getConfigPath;
50585
+ const hasHandledActionUpgrade = store$1.hasHandled;
50586
+ const recordActionUpgradeDecision = store$1.record;
50587
+ //#endregion
50588
+ //#region src/cli/utils/ci-prompt-decision.ts
50589
+ const store = createProjectDecisionStore("ciPrompts");
50590
+ store.getConfigPath;
50591
+ const hasHandledCiPrompt = store.hasHandled;
50592
+ const recordCiPromptDecision = store.record;
50593
+ //#endregion
50306
50594
  //#region src/cli/utils/open-url.ts
50307
50595
  const resolveOpenCommand = (url) => {
50308
50596
  if (process$1.platform === "darwin") return {
@@ -50758,22 +51046,22 @@ const buildAgentHookScript = () => [
50758
51046
  "",
50759
51047
  "run_react_doctor() {",
50760
51048
  " if [ -x ./node_modules/.bin/react-doctor ]; then",
50761
- " ./node_modules/.bin/react-doctor --verbose --diff --blocking warning --no-score",
51049
+ " ./node_modules/.bin/react-doctor --verbose --scope changed --blocking warning --no-score",
50762
51050
  " return",
50763
51051
  " fi",
50764
51052
  "",
50765
51053
  " if command -v react-doctor >/dev/null 2>&1; then",
50766
- " react-doctor --verbose --diff --blocking warning --no-score",
51054
+ " react-doctor --verbose --scope changed --blocking warning --no-score",
50767
51055
  " return",
50768
51056
  " fi",
50769
51057
  "",
50770
51058
  " if command -v pnpm >/dev/null 2>&1; then",
50771
- " pnpm dlx react-doctor@latest --verbose --diff --blocking warning --no-score",
51059
+ " pnpm dlx react-doctor@latest --verbose --scope changed --blocking warning --no-score",
50772
51060
  " return",
50773
51061
  " fi",
50774
51062
  "",
50775
51063
  " if command -v npx >/dev/null 2>&1; then",
50776
- " npx --yes react-doctor@latest --verbose --diff --blocking warning --no-score",
51064
+ " npx --yes react-doctor@latest --verbose --scope changed --blocking warning --no-score",
50777
51065
  " return",
50778
51066
  " fi",
50779
51067
  "",
@@ -50931,13 +51219,13 @@ const installPackageJsonHook = (options, strategy) => {
50931
51219
  const packageJsonPath = getPackageJsonPath(options.projectRoot);
50932
51220
  const didHookExist = NFS.existsSync(packageJsonPath);
50933
51221
  const packageJson = readPackageJson(options.projectRoot);
50934
- const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
51222
+ const nextPackageJson = isRecord$1(packageJson) ? { ...packageJson } : {};
50935
51223
  const parentKeys = strategy.path.slice(0, -1);
50936
51224
  const leafKey = strategy.path[strategy.path.length - 1];
50937
51225
  let parent = nextPackageJson;
50938
51226
  for (const key of parentKeys) {
50939
51227
  const existing = parent[key];
50940
- const cloned = isRecord(existing) ? { ...existing } : {};
51228
+ const cloned = isRecord$1(existing) ? { ...existing } : {};
50941
51229
  parent[key] = cloned;
50942
51230
  parent = cloned;
50943
51231
  }
@@ -51108,7 +51396,7 @@ const isHuskyProject = (projectRoot) => NFS.existsSync(Path.join(projectRoot, ".
51108
51396
  const isVitePlusProject = (projectRoot) => packageHasDependency(projectRoot, "vite-plus");
51109
51397
  const isSimpleGitHooksProject = (projectRoot) => {
51110
51398
  const packageJson = readPackageJson(projectRoot);
51111
- return isRecord(packageJson) && isRecord(packageJson["simple-git-hooks"]) || packageHasDependency(projectRoot, "simple-git-hooks") || NFS.existsSync(Path.join(projectRoot, ".simple-git-hooks.cjs"));
51399
+ return isRecord$1(packageJson) && isRecord$1(packageJson["simple-git-hooks"]) || packageHasDependency(projectRoot, "simple-git-hooks") || NFS.existsSync(Path.join(projectRoot, ".simple-git-hooks.cjs"));
51112
51400
  };
51113
51401
  const getLefthookConfigPath = (projectRoot) => {
51114
51402
  for (const fileName of LEFTHOOK_CONFIG_FILES) {
@@ -51274,7 +51562,7 @@ const detectPackageManager = (projectRoot) => {
51274
51562
  let currentDirectory = Path.resolve(projectRoot);
51275
51563
  while (true) {
51276
51564
  const packageJson = readPackageJson(currentDirectory);
51277
- if (isRecord(packageJson) && typeof packageJson.packageManager === "string") {
51565
+ if (isRecord$1(packageJson) && typeof packageJson.packageManager === "string") {
51278
51566
  const packageManagerName = packageJson.packageManager.split("@")[0];
51279
51567
  if (packageManagerName === "pnpm" || packageManagerName === "yarn" || packageManagerName === "bun" || packageManagerName === "npm") return packageManagerName;
51280
51568
  }
@@ -51350,12 +51638,12 @@ const isSupplyChainTrustError = (error) => {
51350
51638
  const formatInstallCommand = (input) => [input.command, ...input.args].join(" ");
51351
51639
  const installReactDoctorDependency = async (options) => {
51352
51640
  const packageJson = readPackageJson(options.projectRoot);
51353
- if (!isRecord(packageJson)) return {
51641
+ if (!isRecord$1(packageJson)) return {
51354
51642
  dependencyStatus: "skipped",
51355
51643
  dependencyReason: "missing-or-invalid-package-json"
51356
51644
  };
51357
51645
  if (hasDoctorDependency(packageJson)) return { dependencyStatus: "existing" };
51358
- if (packageJson.devDependencies !== void 0 && !isRecord(packageJson.devDependencies)) return {
51646
+ if (packageJson.devDependencies !== void 0 && !isRecord$1(packageJson.devDependencies)) return {
51359
51647
  dependencyStatus: "skipped",
51360
51648
  dependencyReason: "invalid-dev-dependencies"
51361
51649
  };
@@ -51519,10 +51807,12 @@ const runInstallReactDoctor = async (options = {}) => {
51519
51807
  const existingWorkflow = readReactDoctorWorkflow(projectRoot);
51520
51808
  const canInstallWorkflow = !NFS.existsSync(workflowTargetPath);
51521
51809
  const canUpgradeWorkflow = existingWorkflow !== null && workflowUsesV1Action(existingWorkflow.content) && !hasHandledActionUpgrade(projectRoot);
51522
- const shouldInstallWorkflow = canInstallWorkflow && (Boolean(options.yes) || !skipPrompts && await askAddToGitHubActions(prompt) === "yes");
51810
+ const ciPromptOutcome = canInstallWorkflow && !options.yes && !skipPrompts && !hasHandledCiPrompt(projectRoot) ? await askAddToGitHubActions(prompt) : null;
51811
+ const shouldInstallWorkflow = canInstallWorkflow && (Boolean(options.yes) || ciPromptOutcome === "yes");
51523
51812
  const upgradePromptOutcome = canUpgradeWorkflow && !options.yes && !skipPrompts ? await askUpgradeActionVersion(prompt) : null;
51524
51813
  const shouldUpgradeWorkflow = canUpgradeWorkflow && (Boolean(options.yes) || upgradePromptOutcome === "yes");
51525
51814
  if (upgradePromptOutcome === "no" && !options.dryRun) recordActionUpgradeDecision(projectRoot, "declined");
51815
+ if ((ciPromptOutcome === "yes" || ciPromptOutcome === "no") && !options.dryRun) recordCiPromptDecision(projectRoot, ciPromptOutcome === "yes" ? "accepted" : "declined");
51526
51816
  const selectedAgents = skipPrompts ? detectedAgents : (await prompt({
51527
51817
  type: "multiselect",
51528
51818
  name: "agents",
@@ -51769,18 +52059,24 @@ const handoffToAgent = async (input) => {
51769
52059
  if (!input.interactive || input.diagnostics.length === 0) return;
51770
52060
  cliLogger.break();
51771
52061
  const projectRootForCi = findNearestPackageDirectory(input.rootDirectory) ?? input.rootDirectory;
51772
- if (!isReactDoctorWorkflowInstalled(projectRootForCi)) {
52062
+ const isGitHubActionsConfigured = isReactDoctorWorkflowInstalled(projectRootForCi);
52063
+ if (!isGitHubActionsConfigured && !hasHandledCiPrompt(projectRootForCi)) {
51773
52064
  const ciOutcome = await askAddToGitHubActions();
51774
52065
  recordCount(METRIC.agentHandoff, 1, {
51775
52066
  outcome: `ci-${ciOutcome}`,
51776
52067
  diagnosticsCount: input.diagnostics.length
51777
52068
  });
51778
52069
  if (ciOutcome === "cancel") return;
52070
+ recordCiPromptDecision(projectRootForCi, ciOutcome === "yes" ? "accepted" : "declined");
51779
52071
  if (ciOutcome === "yes") {
51780
52072
  await setUpGitHubActions({ rootDirectory: input.rootDirectory });
51781
52073
  cliLogger.break();
51782
52074
  }
51783
- } else await maybeOfferActionUpgrade(projectRootForCi);
52075
+ } else if (isGitHubActionsConfigured) await maybeOfferActionUpgrade(projectRootForCi);
52076
+ else recordCount(METRIC.agentHandoff, 1, {
52077
+ outcome: "ci-suppressed",
52078
+ diagnosticsCount: input.diagnostics.length
52079
+ });
51784
52080
  const { handoffTarget } = await prompts({
51785
52081
  type: "select",
51786
52082
  name: "handoffTarget",
@@ -52003,6 +52299,7 @@ const reportErrorToSentry = async (error) => {
52003
52299
  sampled: runTrace.sampled,
52004
52300
  sampleRand: Math.random()
52005
52301
  });
52302
+ recordRunTraceId(scope.getPropagationContext().traceId);
52006
52303
  return Sentry.captureException(error);
52007
52304
  });
52008
52305
  await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
@@ -52086,7 +52383,7 @@ const printMultiProjectSummary = (input) => gen(function* () {
52086
52383
  yield* log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalElapsedMilliseconds)}`);
52087
52384
  if (displayDiagnostics.length > 0) {
52088
52385
  yield* log("");
52089
- yield* printDiagnostics(displayDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment(), { animateCountUp: animateRender });
52386
+ yield* printDiagnostics(displayDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment(), { animateCountUp: animateRender }, shouldRenderHyperlinks(process.stdout));
52090
52387
  }
52091
52388
  const lowestScoredScan = findLowestScoredScan(completedScans);
52092
52389
  const aggregateScore = lowestScoredScan?.result.score ?? null;
@@ -52124,9 +52421,8 @@ const printMultiProjectSummary = (input) => gen(function* () {
52124
52421
  });
52125
52422
  //#endregion
52126
52423
  //#region src/cli/utils/prompt-install-setup.ts
52127
- const GLOBAL_CONFIG_PROJECT_NAME = "react-doctor";
52128
52424
  const getSetupPromptStore = (options = {}) => new Conf({
52129
- projectName: GLOBAL_CONFIG_PROJECT_NAME,
52425
+ projectName: REACT_DOCTOR_CONFIG_PROJECT_NAME,
52130
52426
  cwd: options.cwd
52131
52427
  });
52132
52428
  const getSetupPromptProjectKey = (projectRoot) => hashProjectRoot(projectRoot);
@@ -52137,6 +52433,24 @@ const hasDisabledSetupPrompt = (projectRoot, storeOptions = {}) => {
52137
52433
  return false;
52138
52434
  }
52139
52435
  };
52436
+ const disableSetupPrompt = (projectRoot, storeOptions = {}) => {
52437
+ try {
52438
+ const store = getSetupPromptStore(storeOptions);
52439
+ const projects = store.get("projects", {});
52440
+ const projectKey = getSetupPromptProjectKey(projectRoot);
52441
+ store.set("projects", {
52442
+ ...projects,
52443
+ [projectKey]: {
52444
+ ...projects[projectKey] ?? {},
52445
+ rootDirectory: Path.resolve(projectRoot),
52446
+ setupPrompt: false
52447
+ }
52448
+ });
52449
+ return true;
52450
+ } catch {
52451
+ return false;
52452
+ }
52453
+ };
52140
52454
  const resolveInstallSetupProjectRoot = (options) => {
52141
52455
  if (options.scanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
52142
52456
  const packageDirectories = /* @__PURE__ */ new Set();
@@ -52543,6 +52857,14 @@ const runExplain = async (fileLineArgument, context) => {
52543
52857
  const colorizedRule = colorizeRuleByDiagnostic(ruleIdentifier, diagnostic.severity);
52544
52858
  const severityLabel = colorizeRuleByDiagnostic(diagnostic.severity, diagnostic.severity);
52545
52859
  cliLogger.log(`${severitySymbol} ${colorizedRule} ${highlighter.dim(`(${severityLabel})`)} — ${diagnostic.message}`);
52860
+ const codeFrame = buildCodeFrame({
52861
+ filePath: diagnostic.filePath,
52862
+ line: diagnostic.line,
52863
+ column: diagnostic.column,
52864
+ endLine: diagnostic.endLine,
52865
+ rootDirectory: targetDirectory
52866
+ });
52867
+ if (codeFrame) cliLogger.log(indentMultilineText(codeFrame, " "));
52546
52868
  if (diagnostic.category) cliLogger.dim(` Category: ${diagnostic.category}`);
52547
52869
  if (diagnostic.help) cliLogger.dim(` ${diagnostic.help}`);
52548
52870
  cliLogger.dim(` If this needs follow-up or looks like a false positive, open: ${buildDiagnosticIssueUrl({
@@ -52592,6 +52914,10 @@ const validateModeFlags = (flags) => {
52592
52914
  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.`);
52593
52915
  if (flags.score && flags.json) throw new CliInputError("Cannot combine --score and --json; pick one output mode.");
52594
52916
  if (flags.score && flags.telemetry === false) throw new CliInputError("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
52917
+ if (flags.debug && (flags.score === false || flags.telemetry === false)) {
52918
+ const disablingFlag = flags.score === false ? "--no-score" : "--no-telemetry";
52919
+ throw new CliInputError(`Cannot combine --debug with ${disablingFlag}; ${disablingFlag} disables the Sentry reporting --debug needs to capture a trace.`);
52920
+ }
52595
52921
  };
52596
52922
  //#endregion
52597
52923
  //#region src/cli/commands/inspect.ts
@@ -52939,11 +53265,13 @@ const inspectAction = async (directory, flags) => {
52939
53265
  })) {
52940
53266
  printAgentInstallHint();
52941
53267
  recordCount(METRIC.agentInstallHintShown, 1);
53268
+ disableSetupPrompt(setupProjectRoot);
52942
53269
  }
52943
53270
  }
52944
53271
  } catch (error) {
52945
53272
  const isUserError = isExpectedUserError(error);
52946
53273
  const sentryEventId = isUserError ? void 0 : await reportErrorToSentry(error);
53274
+ if (isDebugFlagEnabled()) await flushSentry();
52947
53275
  if (isJsonMode) {
52948
53276
  writeJsonErrorReport(error, sentryEventId);
52949
53277
  process.exitCode = 1;
@@ -53666,6 +53994,33 @@ const normalizeHelpInvocation = (argv, knownCommands) => {
53666
53994
  return [...nodeArguments, "--help"];
53667
53995
  };
53668
53996
  //#endregion
53997
+ //#region src/cli/utils/print-debug-trace.ts
53998
+ /**
53999
+ * The `--debug` end-of-run line, pure so it's testable without the Sentry SDK.
54000
+ * Mirrors the crash-reference phrasing in `handle-error.ts` ("mention this when
54001
+ * reporting") so users learn one habit for both paths. A `null` trace says why,
54002
+ * so `--debug` never silently does nothing.
54003
+ */
54004
+ const buildDebugTraceMessage = (traceId) => traceId === null ? "Sentry trace unavailable for this run (no trace was recorded)." : `Sentry trace (mention this when reporting): ${traceId}`;
54005
+ /**
54006
+ * Prints the run's Sentry trace id to stderr at the end of a `--debug` run, so
54007
+ * maintainers can pull the full trace from a pasted id. Runs from the process
54008
+ * `exit` handler, so it's the last line on both the success path and the error
54009
+ * funnels (which `process.exit()` before the promise chain could resume).
54010
+ *
54011
+ * Writes straight to `process.stderr` (not `Console`) for three reasons: the
54012
+ * exit handler is synchronous, JSON mode patches the global console to no-ops —
54013
+ * a diagnostic the user explicitly asked for must survive that — and stderr
54014
+ * keeps `--json` / `--score` stdout machine-clean. The write is wrapped because
54015
+ * a diagnostic must never throw out of an exit handler.
54016
+ */
54017
+ const printDebugTrace = () => {
54018
+ if (!Sentry.isInitialized()) return;
54019
+ try {
54020
+ process.stderr.write(`${highlighter.dim(buildDebugTraceMessage(getLastRunTraceId()))}\n`);
54021
+ } catch {}
54022
+ };
54023
+ //#endregion
53669
54024
  //#region src/cli/utils/removed-cli-flags.ts
53670
54025
  const REMOVED_FLAGS = new Map([
53671
54026
  ["--full", "use `--diff false` to force a full scan"],
@@ -53692,6 +54047,7 @@ const ROOT_FLAG_SPEC = {
53692
54047
  longOptionsWithoutValues: new Set([
53693
54048
  "--color",
53694
54049
  "--dead-code",
54050
+ "--debug",
53695
54051
  "--help",
53696
54052
  "--json",
53697
54053
  "--json-compact",
@@ -53859,6 +54215,9 @@ const stripUnknownCliFlags = (argv) => {
53859
54215
  initializeSentry();
53860
54216
  process.on("SIGINT", exitGracefully);
53861
54217
  process.on("SIGTERM", exitGracefully);
54218
+ process.on("exit", () => {
54219
+ if (isDebugFlagEnabled()) printDebugTrace();
54220
+ });
53862
54221
  unrefStdin();
53863
54222
  guardStdin();
53864
54223
  const formatExampleLines = (examples) => {
@@ -53870,7 +54229,7 @@ ${highlighter.dim("Examples:")}
53870
54229
  ${formatExampleLines([
53871
54230
  ["react-doctor", "scan the current project"],
53872
54231
  ["react-doctor ./apps/web", "scan a specific directory"],
53873
- ["react-doctor --diff main", "scan only files changed vs. main"],
54232
+ ["react-doctor --scope changed --base main", "scan only new issues vs. main"],
53874
54233
  ["react-doctor --project modules/a,modules/b", "score each module separately (names or paths)"],
53875
54234
  ["react-doctor --staged", "scan staged files (pre-commit hook)"],
53876
54235
  ["react-doctor --category Security", "show only one diagnostic category"],
@@ -53903,7 +54262,7 @@ ${highlighter.dim("Learn more:")}
53903
54262
  ${highlighter.info(CANONICAL_GITHUB_URL)}
53904
54263
  `;
53905
54264
  const collectCategoryOption = (value, previousValues) => [...previousValues ?? [], value];
53906
- 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);
54265
+ 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);
53907
54266
  program.action(inspectAction);
53908
54267
  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));
53909
54268
  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);
@@ -53946,4 +54305,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
53946
54305
  export {};
53947
54306
 
53948
54307
  //# sourceMappingURL=cli.js.map
53949
- //# debugId=bb93aaad-ea85-5f1d-b2db-584f48671f66
54308
+ //# debugId=4aa7cab0-c8a8-5513-bde6-3f7a0a1bdaa7