react-doctor 0.5.6-dev.93b796d → 0.5.6-dev.a9d2713

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]="a06f0514-b1fa-5452-9c19-0140438862f8")}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]="e4d06258-7975-53ee-88e5-490715798dd2")}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";
@@ -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,
@@ -36870,6 +36861,13 @@ const APP_ONLY_RULE_KEYS = new Set([
36870
36861
  ]);
36871
36862
  const COMPILER_CLEANUP_BUCKET = "compiler-cleanup";
36872
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
+ ]);
36873
36871
  const MAX_GLOB_PATTERN_LENGTH_CHARS = 1024;
36874
36872
  const CONFIG_CACHE_TTL_MS = 300 * 1e3;
36875
36873
  const SOCKET_FREE_PURL_API_BASE = "https://firewall-api.socket.dev/purl";
@@ -37319,6 +37317,7 @@ const isTailwindAtLeast = (detected, required) => {
37319
37317
  if (detected.major !== required.major) return detected.major > required.major;
37320
37318
  return detected.minor >= required.minor;
37321
37319
  };
37320
+ const messageFromUnknown = (error) => error instanceof Error ? error.message : String(error);
37322
37321
  var InvalidGlobPatternError = class extends Error {
37323
37322
  pattern;
37324
37323
  reason;
@@ -37347,7 +37346,7 @@ const compileGlobPattern = (rawPattern) => {
37347
37346
  try {
37348
37347
  return import_picomatch.default.makeRe(normalizeGlobPattern(rawPattern), PICOMATCH_OPTIONS);
37349
37348
  } catch (caughtError) {
37350
- throw new InvalidGlobPatternError(rawPattern, caughtError instanceof Error ? caughtError.message : String(caughtError));
37349
+ throw new InvalidGlobPatternError(rawPattern, messageFromUnknown(caughtError));
37351
37350
  }
37352
37351
  };
37353
37352
  const compileGlobPatternsLenient = (patterns, onInvalid) => {
@@ -37443,115 +37442,6 @@ const buildRuleSeverityControls = (config) => {
37443
37442
  ...config.buckets !== void 0 ? { buckets: config.buckets } : {}
37444
37443
  };
37445
37444
  };
37446
- const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
37447
- const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
37448
- const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
37449
- let stringDelimiter = null;
37450
- for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
37451
- const character = line[charIndex];
37452
- if (stringDelimiter !== null) {
37453
- if (character === "\\") {
37454
- charIndex++;
37455
- continue;
37456
- }
37457
- if (character === stringDelimiter) stringDelimiter = null;
37458
- continue;
37459
- }
37460
- if (character === "\"" || character === "'" || character === "`") {
37461
- stringDelimiter = character;
37462
- continue;
37463
- }
37464
- if (character === "/" && line[charIndex + 1] === "/") return true;
37465
- }
37466
- return false;
37467
- };
37468
- const findOpenerTagOnLine = (line) => {
37469
- for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
37470
- if (match.index === void 0) continue;
37471
- if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
37472
- }
37473
- return null;
37474
- };
37475
- const findJsxOpenerSpan = (lines, openerLineIndex) => {
37476
- const openerLine = lines[openerLineIndex];
37477
- if (openerLine === void 0) return null;
37478
- const opener = findOpenerTagOnLine(openerLine);
37479
- if (!opener) return null;
37480
- const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
37481
- let braceDepth = 0;
37482
- let innerAngleDepth = 0;
37483
- let stringDelimiter = null;
37484
- for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
37485
- const currentLine = lines[lineIndex];
37486
- const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
37487
- for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
37488
- const character = currentLine[charIndex];
37489
- if (stringDelimiter !== null) {
37490
- if (character === "\\") {
37491
- charIndex++;
37492
- continue;
37493
- }
37494
- if (character === stringDelimiter) stringDelimiter = null;
37495
- continue;
37496
- }
37497
- if (character === "\"" || character === "'" || character === "`") {
37498
- stringDelimiter = character;
37499
- continue;
37500
- }
37501
- if (character === "{") {
37502
- braceDepth++;
37503
- continue;
37504
- }
37505
- if (character === "}") {
37506
- braceDepth--;
37507
- continue;
37508
- }
37509
- if (braceDepth !== 0) continue;
37510
- if (character === "<") {
37511
- const followCharacter = currentLine[charIndex + 1];
37512
- if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
37513
- continue;
37514
- }
37515
- if (character !== ">") continue;
37516
- const previousCharacter = currentLine[charIndex - 1];
37517
- const nextCharacter = currentLine[charIndex + 1];
37518
- if (previousCharacter === "=" || nextCharacter === "=") continue;
37519
- if (innerAngleDepth > 0) {
37520
- innerAngleDepth--;
37521
- continue;
37522
- }
37523
- return lineIndex;
37524
- }
37525
- }
37526
- return null;
37527
- };
37528
- const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
37529
- for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
37530
- const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
37531
- if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
37532
- }
37533
- return null;
37534
- };
37535
- const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
37536
- const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
37537
- const collected = [];
37538
- let isStillInChain = true;
37539
- for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
37540
- const candidateLine = lines[candidateIndex];
37541
- if (candidateLine === void 0) break;
37542
- const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
37543
- if (match) {
37544
- collected.push({
37545
- commentLineIndex: candidateIndex,
37546
- ruleList: match[1],
37547
- isInChain: isStillInChain
37548
- });
37549
- continue;
37550
- }
37551
- isStillInChain = false;
37552
- }
37553
- return collected;
37554
- };
37555
37445
  const LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY = {
37556
37446
  "effect/no-adjust-state-on-prop-change": "react-doctor/no-adjust-state-on-prop-change",
37557
37447
  "effect/no-chain-state-updates": "react-doctor/no-chain-state-updates",
@@ -37676,7 +37566,13 @@ for (const [legacyRuleKey, nativeRuleKey] of Object.entries(LEGACY_RULE_KEY_TO_N
37676
37566
  }
37677
37567
  const getLegacyRuleKeysForNative = (ruleKey) => NATIVE_RULE_KEY_TO_LEGACY_RULE_KEYS.get(ruleKey) ?? [];
37678
37568
  const canonicalizeRuleKey = (ruleKey) => LEGACY_RULE_KEY_TO_NATIVE_RULE_KEY[ruleKey] ?? ruleKey;
37679
- 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
+ };
37680
37576
  const getEquivalentRuleKeys = (ruleKey) => {
37681
37577
  const nativeRuleKey = canonicalizeRuleKey(ruleKey);
37682
37578
  return [nativeRuleKey, ...getLegacyRuleKeysForNative(nativeRuleKey)];
@@ -37686,12 +37582,182 @@ const stripDescriptionTail = (ruleList) => {
37686
37582
  if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
37687
37583
  return ruleList.slice(0, descriptionMatch.index);
37688
37584
  };
37689
- const isRuleListedInComment = (ruleList, ruleId) => {
37585
+ const tokenizeRuleList = (ruleList) => {
37690
37586
  const trimmed = ruleList?.trim();
37691
- if (!trimmed) return true;
37587
+ if (!trimmed) return [];
37692
37588
  const ruleSection = stripDescriptionTail(trimmed).trim();
37693
- if (!ruleSection) return true;
37694
- 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));
37695
37761
  };
37696
37762
  const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
37697
37763
  const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
@@ -37735,7 +37801,7 @@ const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
37735
37801
  };
37736
37802
  return {
37737
37803
  isSuppressed: false,
37738
- nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
37804
+ nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId) ?? detectForeignDisableNearMiss(lines, diagnosticLineIndex, ruleId)
37739
37805
  };
37740
37806
  };
37741
37807
  /**
@@ -38525,7 +38591,6 @@ const PACKAGE_JSON_FILENAME = "package.json";
38525
38591
  const PACKAGE_JSON_CONFIG_KEY$1 = "reactDoctor";
38526
38592
  const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
38527
38593
  const jiti = createJiti(import.meta.url);
38528
- const formatError = (error) => error instanceof Error ? error.message : String(error);
38529
38594
  const importDefaultExport = async (jitiInstance, filePath) => {
38530
38595
  const imported = await jitiInstance.import(filePath);
38531
38596
  return imported?.default ?? imported;
@@ -38557,7 +38622,7 @@ const loadModuleConfig = async (filePath) => {
38557
38622
  try {
38558
38623
  return await importDefaultExport(aliasJiti, filePath);
38559
38624
  } catch (retryError) {
38560
- 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 });
38561
38626
  }
38562
38627
  }
38563
38628
  };
@@ -38606,7 +38671,7 @@ const loadLegacyConfig = (directory) => {
38606
38671
  }
38607
38672
  warn(`${LEGACY_CONFIG_FILENAME} must contain an object, ignoring.`);
38608
38673
  } catch (error) {
38609
- warn(`Failed to load ${LEGACY_CONFIG_FILENAME}: ${formatError(error)}`);
38674
+ warn(`Failed to load ${LEGACY_CONFIG_FILENAME}: ${messageFromUnknown(error)}`);
38610
38675
  }
38611
38676
  return {
38612
38677
  status: "invalid",
@@ -38633,7 +38698,7 @@ const loadConfigFromDirectory = async (directory) => {
38633
38698
  warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
38634
38699
  sawBrokenConfigFile = true;
38635
38700
  } catch (error) {
38636
- warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
38701
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${messageFromUnknown(error)}`);
38637
38702
  sawBrokenConfigFile = true;
38638
38703
  }
38639
38704
  }
@@ -38763,6 +38828,29 @@ const resolveScanTarget = async (requestedDirectory, options = {}) => {
38763
38828
  didRedirectViaRootDir: redirectedDirectory !== null
38764
38829
  };
38765
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
+ };
38766
38854
  const getDirectDependencyNames = (packageJson) => new Set([...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {})]);
38767
38855
  const buildExpoCheckContext = (rootDirectory, expoVersion) => {
38768
38856
  const packageJson = readPackageJson$1(Path.join(rootDirectory, "package.json"));
@@ -39908,7 +39996,7 @@ const readIgnoreFile = (filePath) => {
39908
39996
  try {
39909
39997
  content = NFS.readFileSync(filePath, "utf-8");
39910
39998
  } catch (error) {
39911
- const errnoCode = error?.code;
39999
+ const errnoCode = isErrnoException(error) ? error.code : void 0;
39912
40000
  if (errnoCode && errnoCode !== "ENOENT") runSync(warn$1(`Could not read ignore file ${filePath}: ${errnoCode}`));
39913
40001
  return [];
39914
40002
  }
@@ -40464,15 +40552,13 @@ var DeadCode = class DeadCode extends Service()("react-doctor/DeadCode") {
40464
40552
  })()) }));
40465
40553
  static layerOf = (diagnostics) => succeed$3(DeadCode, DeadCode.of({ run: () => fromIterable$1(diagnostics) }));
40466
40554
  };
40467
- const createNodeReadFileLinesSync = (rootDirectory) => {
40468
- return (filePath) => {
40469
- const absolutePath = Path.isAbsolute(filePath) ? filePath : Path.join(rootDirectory, filePath);
40470
- try {
40471
- return NFS.readFileSync(absolutePath, "utf-8").split("\n");
40472
- } catch {
40473
- return null;
40474
- }
40475
- };
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
+ }
40476
40562
  };
40477
40563
  var Files = class Files extends Service()("react-doctor/Files") {
40478
40564
  static layerNode = succeed$3(Files, Files.of({
@@ -40683,7 +40769,10 @@ var Git = class Git extends Service()("react-doctor/Git") {
40683
40769
  directory: input.directory,
40684
40770
  cause
40685
40771
  }) });
40686
- }));
40772
+ }), withSpan("git.exec", { attributes: {
40773
+ "git.command": input.command,
40774
+ "git.subcommand": input.args[0] ?? ""
40775
+ } }));
40687
40776
  const runGit = (directory, args) => runCommand({
40688
40777
  command: "git",
40689
40778
  args,
@@ -40711,7 +40800,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40711
40800
  ]);
40712
40801
  if (candidates.status !== 0) return null;
40713
40802
  return trimOrNull(candidates.stdout.split("\n")[0] ?? "");
40714
- });
40803
+ }).pipe(withSpan("Git.defaultBranch"));
40715
40804
  const branchExists = (directory, branch) => runGit(directory, [
40716
40805
  "rev-parse",
40717
40806
  "--verify",
@@ -40758,7 +40847,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40758
40847
  const result = resultOption.value;
40759
40848
  if (result.status !== 0) return null;
40760
40849
  return parseGithubViewerPermission(result.stdout);
40761
- }).pipe(catch_$1(() => succeed$2(null)));
40850
+ }).pipe(catch_$1(() => succeed$2(null)), withSpan("Git.githubViewerPermission"));
40762
40851
  /**
40763
40852
  * Resolves a `--diff A..B` / `A...B` commit range into a changed-file
40764
40853
  * selection. Each endpoint is validated with `isSafeGitRevision`
@@ -40872,7 +40961,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40872
40961
  changedFiles: splitNullSeparated(diff.stdout),
40873
40962
  isCurrentChanges: false
40874
40963
  };
40875
- }),
40964
+ }).pipe(withSpan("Git.diffSelection")),
40876
40965
  stagedFilePaths: (directory) => runGit(directory, [
40877
40966
  "diff",
40878
40967
  "--cached",
@@ -40914,7 +41003,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40914
41003
  status: result.status,
40915
41004
  stdout: result.stdout
40916
41005
  };
40917
- }),
41006
+ }).pipe(withSpan("Git.grep")),
40918
41007
  changedLineRanges: ({ directory, baseRef, cached, files }) => gen(function* () {
40919
41008
  if (files.length === 0) return [];
40920
41009
  if (baseRef !== void 0 && !isSafeGitRevision(baseRef)) return null;
@@ -40930,7 +41019,7 @@ var Git = class Git extends Service()("react-doctor/Git") {
40930
41019
  ]);
40931
41020
  if (result.status !== 0) return null;
40932
41021
  return parseChangedLineRanges(result.stdout);
40933
- })
41022
+ }).pipe(withSpan("Git.changedLineRanges"))
40934
41023
  });
40935
41024
  })).pipe(provide$2(layer$3.pipe(provide$2(mergeAll$1(layer$2, layer$1)))));
40936
41025
  /**
@@ -41145,7 +41234,7 @@ const neutralizeDisableDirectives = async (rootDirectory, includePaths) => {
41145
41234
  for (const [absolutePath, originalContent] of originalContents) try {
41146
41235
  NFS.writeFileSync(absolutePath, originalContent);
41147
41236
  } catch (error) {
41148
- 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`);
41149
41238
  }
41150
41239
  };
41151
41240
  const onExit = () => restore();
@@ -41251,7 +41340,7 @@ const resolveUserPlugin = (spec, configSourceDirectory) => {
41251
41340
  try {
41252
41341
  resolvedSpecifier = isRelative ? Path.resolve(configSourceDirectory, spec) : candidateRequire.resolve(spec);
41253
41342
  } catch (error) {
41254
- 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)}`);
41255
41344
  return null;
41256
41345
  }
41257
41346
  const { name, ruleNames } = readPluginShape(resolvedSpecifier, (target) => candidateRequire(target));
@@ -42097,7 +42186,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
42097
42186
  child.kill("SIGKILL");
42098
42187
  reject(new ReactDoctorError({ reason: new OxlintBatchExceeded({
42099
42188
  kind: "timeout",
42100
- detail: `${spawnTimeoutMs / 1e3}s budget exceeded`
42189
+ detail: `${spawnTimeoutMs / MILLISECONDS_PER_SECOND}s budget exceeded`
42101
42190
  }) }));
42102
42191
  }, spawnTimeoutMs);
42103
42192
  timeoutHandle.unref?.();
@@ -43219,17 +43308,17 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43219
43308
  }))))))));
43220
43309
  const deadCodeFailureState = yield* get$2(deadCodeFailure);
43221
43310
  const scanElapsedMilliseconds = Date.now() - scanStartTime;
43222
- const scanElapsedSeconds = (scanElapsedMilliseconds / 1e3).toFixed(1);
43311
+ const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1);
43223
43312
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
43224
43313
  else if (input.suppressScanSummary) yield* scanProgress.stop();
43225
43314
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
43226
43315
  yield* reporterService.finalize;
43227
- const finalDiagnostics = [
43316
+ const finalDiagnostics = assignFixGroups([
43228
43317
  ...envCollected,
43229
43318
  ...supplyChainCollected,
43230
43319
  ...lintCollected,
43231
43320
  ...deadCodeCollected
43232
- ];
43321
+ ]);
43233
43322
  const githubViewerPermission = yield* join(githubViewerPermissionFiber);
43234
43323
  const scoreMetadata = {
43235
43324
  ...repo !== null ? { repo } : {},
@@ -43456,7 +43545,7 @@ var StagedFiles = class StagedFiles extends Service()("react-doctor/StagedFiles"
43456
43545
  static layerNode = effect(StagedFiles, gen(function* () {
43457
43546
  const git = yield* Git;
43458
43547
  return StagedFiles.of({
43459
- 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")),
43460
43549
  materialize: ({ directory, stagedFiles, tempDirectory }) => materializeSourceTree({
43461
43550
  directory,
43462
43551
  files: stagedFiles,
@@ -43466,7 +43555,7 @@ var StagedFiles = class StagedFiles extends Service()("react-doctor/StagedFiles"
43466
43555
  tempDirectory: tree.tempDirectory,
43467
43556
  stagedFiles: tree.materializedFiles,
43468
43557
  cleanup: tree.cleanup
43469
- })))
43558
+ })), withSpan("StagedFiles.materialize"))
43470
43559
  });
43471
43560
  }));
43472
43561
  /**
@@ -43599,6 +43688,7 @@ const buildJsonReport = (input) => {
43599
43688
  score: result.score,
43600
43689
  skippedChecks: result.skippedChecks,
43601
43690
  ...result.skippedCheckReasons ? { skippedCheckReasons: result.skippedCheckReasons } : {},
43691
+ ...typeof result.scannedFileCount === "number" ? { scannedFileCount: result.scannedFileCount } : {},
43602
43692
  elapsedMilliseconds: result.elapsedMilliseconds
43603
43693
  }));
43604
43694
  const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
@@ -43934,6 +44024,17 @@ const detectTerminalKind = (env = process.env) => {
43934
44024
  return "unknown";
43935
44025
  };
43936
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
43937
44038
  //#region src/cli/utils/is-git-hook-environment.ts
43938
44039
  const isGitHookEnvironment = () => Boolean(process.env.GIT_DIR);
43939
44040
  //#endregion
@@ -44041,7 +44142,7 @@ const makeNoopConsole = () => ({
44041
44142
  });
44042
44143
  //#endregion
44043
44144
  //#region src/cli/utils/version.ts
44044
- const VERSION = "0.5.6-dev.93b796d";
44145
+ const VERSION = "0.5.6-dev.a9d2713";
44045
44146
  //#endregion
44046
44147
  //#region src/cli/utils/json-mode.ts
44047
44148
  let context = null;
@@ -44193,6 +44294,7 @@ const buildRunContext = () => {
44193
44294
  interactive: !isNonInteractiveEnvironment(),
44194
44295
  terminalKind: detectTerminalKind(),
44195
44296
  jsonMode: isJsonModeActive(),
44297
+ debug: isDebugFlagEnabled(),
44196
44298
  invokedVia: detectInvokedVia()
44197
44299
  };
44198
44300
  };
@@ -44264,6 +44366,7 @@ const buildSentryScope = (runContext = buildRunContext()) => {
44264
44366
  interactive: runContext.interactive,
44265
44367
  terminalKind: runContext.terminalKind,
44266
44368
  jsonMode: runContext.jsonMode,
44369
+ debug: runContext.debug,
44267
44370
  invokedVia: runContext.invokedVia,
44268
44371
  nodeMajor: runContext.nodeMajor
44269
44372
  };
@@ -44401,13 +44504,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
44401
44504
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
44402
44505
  * standard `SENTRY_RELEASE` override.
44403
44506
  */
44404
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.6-dev.93b796d`;
44507
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.6-dev.a9d2713`;
44405
44508
  /**
44406
44509
  * Deployment environment shown in Sentry's environment filter. Defaults to
44407
44510
  * `production` for tagged releases and `development` for dev/unbuilt versions,
44408
44511
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
44409
44512
  */
44410
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.6-dev.93b796d") ? "development" : "production");
44513
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.6-dev.a9d2713") ? "development" : "production");
44411
44514
  /**
44412
44515
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
44413
44516
  * (set to `0` to disable tracing) and falls back to
@@ -44471,7 +44574,7 @@ const flushSentry = async () => {
44471
44574
  const initializeSentry = () => {
44472
44575
  if (isInitialized || !shouldEnableSentry()) return;
44473
44576
  isInitialized = true;
44474
- resolvedTracesSampleRate = resolveTracesSampleRate();
44577
+ resolvedTracesSampleRate = isDebugFlagEnabled() ? 1 : resolveTracesSampleRate();
44475
44578
  const { tags, contexts } = buildSentryScope();
44476
44579
  Sentry.init({
44477
44580
  dsn: process.env.SENTRY_DSN || "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920",
@@ -47687,6 +47790,11 @@ const setActiveRunTrace = (trace) => {
47687
47790
  activeRunTrace = trace;
47688
47791
  };
47689
47792
  const getActiveRunTrace = () => activeRunTrace;
47793
+ let lastRunTraceId = null;
47794
+ const recordRunTraceId = (traceId) => {
47795
+ lastRunTraceId = traceId;
47796
+ };
47797
+ const getLastRunTraceId = () => lastRunTraceId;
47690
47798
  //#endregion
47691
47799
  //#region src/cli/utils/to-span-attributes.ts
47692
47800
  /**
@@ -47749,14 +47857,13 @@ const withSentryRunSpan = (run, options = {}) => {
47749
47857
  op: "cli.inspect",
47750
47858
  attributes: toSpanAttributes(tags)
47751
47859
  }, (rootSpan) => {
47752
- if (options.concurrentScan !== true) {
47753
- const spanContext = rootSpan.spanContext();
47754
- setActiveRunTrace({
47755
- traceId: spanContext.traceId,
47756
- spanId: spanContext.spanId,
47757
- sampled: (spanContext.traceFlags & 1) === 1
47758
- });
47759
- }
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
+ });
47760
47867
  return run(rootSpan);
47761
47868
  });
47762
47869
  };
@@ -48014,10 +48121,14 @@ const buildOutcomeAttributes = (input) => {
48014
48121
  }
48015
48122
  let diagnosticsInTestFiles = 0;
48016
48123
  let diagnosticsInStoryFiles = 0;
48124
+ const findingsPerFixGroup = /* @__PURE__ */ new Map();
48017
48125
  for (const diagnostic of result.diagnostics) {
48018
48126
  if (diagnostic.fileContext === "test") diagnosticsInTestFiles += 1;
48019
48127
  if (diagnostic.fileContext === "story") diagnosticsInStoryFiles += 1;
48128
+ if (diagnostic.fixGroupId) findingsPerFixGroup.set(diagnostic.fixGroupId, (findingsPerFixGroup.get(diagnostic.fixGroupId) ?? 0) + 1);
48020
48129
  }
48130
+ let fixGroupedFindings = 0;
48131
+ for (const count of findingsPerFixGroup.values()) fixGroupedFindings += count;
48021
48132
  const attributes = {
48022
48133
  outcome,
48023
48134
  exitCode: wouldBlock ? 1 : 0,
@@ -48031,6 +48142,8 @@ const buildOutcomeAttributes = (input) => {
48031
48142
  diagnosticsInTestFiles,
48032
48143
  diagnosticsInStoryFiles,
48033
48144
  distinctRulesFired: countByRule.size,
48145
+ "diag.fixGroups": findingsPerFixGroup.size,
48146
+ "diag.fixGroupedFindings": fixGroupedFindings,
48034
48147
  topRule,
48035
48148
  scannedFileCount: result.scannedFileCount ?? null,
48036
48149
  elapsedMs: result.elapsedMilliseconds,
@@ -48220,6 +48333,12 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
48220
48333
  };
48221
48334
  const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
48222
48335
  const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
48336
+ const getSharedFixSiteCount = (diagnostics) => {
48337
+ if (diagnostics.length < 2) return 0;
48338
+ const firstFixGroupId = diagnostics[0]?.fixGroupId;
48339
+ if (!firstFixGroupId) return 0;
48340
+ return diagnostics.every((diagnostic) => diagnostic.fixGroupId === firstFixGroupId) ? diagnostics.length : 0;
48341
+ };
48223
48342
  const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
48224
48343
  const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48225
48344
  const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
@@ -48535,6 +48654,8 @@ const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, rende
48535
48654
  const impactMessages = isCollapsedWarningGroup ? [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.message))] : [representative.message];
48536
48655
  for (const impactMessage of impactMessages) for (const explanationLine of wrapTextToWidth(impactMessage, resolveMeasureWidth(4), { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
48537
48656
  if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, resolveMeasureWidth(4), { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
48657
+ const sharedFixSiteCount = getSharedFixSiteCount(ruleDiagnostics);
48658
+ if (sharedFixSiteCount > 0) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}↳ One fix clears all ${sharedFixSiteCount} findings.`));
48538
48659
  if (renderEverySite && isAgentEnvironment) {
48539
48660
  const fixRecipeLine = formatFixRecipeLine(representative);
48540
48661
  if (fixRecipeLine) lines.push(highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${fixRecipeLine}`));
@@ -50093,7 +50214,9 @@ const buildHandoffPayload = (input) => {
50093
50214
  topGroups.forEach(([ruleKey, ruleDiagnostics], index) => {
50094
50215
  const representative = ruleDiagnostics[0];
50095
50216
  const severityLabel = representative.severity === "error" ? "ERROR" : "WARN";
50096
- lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (×${ruleDiagnostics.length})`, ` ${representative.message}`);
50217
+ const sharedFixSiteCount = getSharedFixSiteCount(ruleDiagnostics);
50218
+ const countBadge = sharedFixSiteCount > 0 ? `one fix · ${sharedFixSiteCount} sites` : `×${ruleDiagnostics.length}`;
50219
+ lines.push(`${index + 1}. ${severityLabel} ${representative.category}: ${representative.title ?? ruleKey} (${countBadge})`, ` ${representative.message}`);
50097
50220
  const fixRecipeLine = formatFixRecipeLine(representative);
50098
50221
  if (fixRecipeLine) lines.push(` ${fixRecipeLine}`);
50099
50222
  const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
@@ -50106,7 +50229,7 @@ const buildHandoffPayload = (input) => {
50106
50229
  });
50107
50230
  lines.push("");
50108
50231
  if (outputDirectory) lines.push(`Full results for all ${input.diagnostics.length} issues (diagnostics.json + a .txt per rule): ${outputDirectory}`, "");
50109
- 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.");
50232
+ 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.", "", "Then work through the rest from the full results above.");
50110
50233
  return lines.join("\n");
50111
50234
  };
50112
50235
  //#endregion
@@ -52141,6 +52264,7 @@ const reportErrorToSentry = async (error) => {
52141
52264
  sampled: runTrace.sampled,
52142
52265
  sampleRand: Math.random()
52143
52266
  });
52267
+ recordRunTraceId(scope.getPropagationContext().traceId);
52144
52268
  return Sentry.captureException(error);
52145
52269
  });
52146
52270
  await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
@@ -52755,6 +52879,10 @@ const validateModeFlags = (flags) => {
52755
52879
  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.`);
52756
52880
  if (flags.score && flags.json) throw new CliInputError("Cannot combine --score and --json; pick one output mode.");
52757
52881
  if (flags.score && flags.telemetry === false) throw new CliInputError("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
52882
+ if (flags.debug && (flags.score === false || flags.telemetry === false)) {
52883
+ const disablingFlag = flags.score === false ? "--no-score" : "--no-telemetry";
52884
+ throw new CliInputError(`Cannot combine --debug with ${disablingFlag}; ${disablingFlag} disables the Sentry reporting --debug needs to capture a trace.`);
52885
+ }
52758
52886
  };
52759
52887
  //#endregion
52760
52888
  //#region src/cli/commands/inspect.ts
@@ -53108,6 +53236,7 @@ const inspectAction = async (directory, flags) => {
53108
53236
  } catch (error) {
53109
53237
  const isUserError = isExpectedUserError(error);
53110
53238
  const sentryEventId = isUserError ? void 0 : await reportErrorToSentry(error);
53239
+ if (isDebugFlagEnabled()) await flushSentry();
53111
53240
  if (isJsonMode) {
53112
53241
  writeJsonErrorReport(error, sentryEventId);
53113
53242
  process.exitCode = 1;
@@ -53830,6 +53959,33 @@ const normalizeHelpInvocation = (argv, knownCommands) => {
53830
53959
  return [...nodeArguments, "--help"];
53831
53960
  };
53832
53961
  //#endregion
53962
+ //#region src/cli/utils/print-debug-trace.ts
53963
+ /**
53964
+ * The `--debug` end-of-run line, pure so it's testable without the Sentry SDK.
53965
+ * Mirrors the crash-reference phrasing in `handle-error.ts` ("mention this when
53966
+ * reporting") so users learn one habit for both paths. A `null` trace says why,
53967
+ * so `--debug` never silently does nothing.
53968
+ */
53969
+ const buildDebugTraceMessage = (traceId) => traceId === null ? "Sentry trace unavailable for this run (no trace was recorded)." : `Sentry trace (mention this when reporting): ${traceId}`;
53970
+ /**
53971
+ * Prints the run's Sentry trace id to stderr at the end of a `--debug` run, so
53972
+ * maintainers can pull the full trace from a pasted id. Runs from the process
53973
+ * `exit` handler, so it's the last line on both the success path and the error
53974
+ * funnels (which `process.exit()` before the promise chain could resume).
53975
+ *
53976
+ * Writes straight to `process.stderr` (not `Console`) for three reasons: the
53977
+ * exit handler is synchronous, JSON mode patches the global console to no-ops —
53978
+ * a diagnostic the user explicitly asked for must survive that — and stderr
53979
+ * keeps `--json` / `--score` stdout machine-clean. The write is wrapped because
53980
+ * a diagnostic must never throw out of an exit handler.
53981
+ */
53982
+ const printDebugTrace = () => {
53983
+ if (!Sentry.isInitialized()) return;
53984
+ try {
53985
+ process.stderr.write(`${highlighter.dim(buildDebugTraceMessage(getLastRunTraceId()))}\n`);
53986
+ } catch {}
53987
+ };
53988
+ //#endregion
53833
53989
  //#region src/cli/utils/removed-cli-flags.ts
53834
53990
  const REMOVED_FLAGS = new Map([
53835
53991
  ["--full", "use `--diff false` to force a full scan"],
@@ -53856,6 +54012,7 @@ const ROOT_FLAG_SPEC = {
53856
54012
  longOptionsWithoutValues: new Set([
53857
54013
  "--color",
53858
54014
  "--dead-code",
54015
+ "--debug",
53859
54016
  "--help",
53860
54017
  "--json",
53861
54018
  "--json-compact",
@@ -54023,6 +54180,9 @@ const stripUnknownCliFlags = (argv) => {
54023
54180
  initializeSentry();
54024
54181
  process.on("SIGINT", exitGracefully);
54025
54182
  process.on("SIGTERM", exitGracefully);
54183
+ process.on("exit", () => {
54184
+ if (isDebugFlagEnabled()) printDebugTrace();
54185
+ });
54026
54186
  unrefStdin();
54027
54187
  guardStdin();
54028
54188
  const formatExampleLines = (examples) => {
@@ -54067,7 +54227,7 @@ ${highlighter.dim("Learn more:")}
54067
54227
  ${highlighter.info(CANONICAL_GITHUB_URL)}
54068
54228
  `;
54069
54229
  const collectCategoryOption = (value, previousValues) => [...previousValues ?? [], value];
54070
- 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);
54230
+ 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);
54071
54231
  program.action(inspectAction);
54072
54232
  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));
54073
54233
  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);
@@ -54110,4 +54270,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
54110
54270
  export {};
54111
54271
 
54112
54272
  //# sourceMappingURL=cli.js.map
54113
- //# debugId=a06f0514-b1fa-5452-9c19-0140438862f8
54273
+ //# debugId=e4d06258-7975-53ee-88e5-490715798dd2