react-doctor 0.5.6-dev.937a7ca → 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]="ad091b20-c3e2-5c96-93ac-9a910745a035")}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,
@@ -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));
@@ -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
  }
@@ -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?.();
@@ -43218,17 +43308,17 @@ const runInspect = (input, hooks = {}) => gen(function* () {
43218
43308
  }))))))));
43219
43309
  const deadCodeFailureState = yield* get$2(deadCodeFailure);
43220
43310
  const scanElapsedMilliseconds = Date.now() - scanStartTime;
43221
- const scanElapsedSeconds = (scanElapsedMilliseconds / 1e3).toFixed(1);
43311
+ const scanElapsedSeconds = (scanElapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1);
43222
43312
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
43223
43313
  else if (input.suppressScanSummary) yield* scanProgress.stop();
43224
43314
  else yield* scanProgress.succeed(`Scanned ${scannedFilesLabel} in ${scanElapsedSeconds}s${workerCountSuffix}`);
43225
43315
  yield* reporterService.finalize;
43226
- const finalDiagnostics = [
43316
+ const finalDiagnostics = assignFixGroups([
43227
43317
  ...envCollected,
43228
43318
  ...supplyChainCollected,
43229
43319
  ...lintCollected,
43230
43320
  ...deadCodeCollected
43231
- ];
43321
+ ]);
43232
43322
  const githubViewerPermission = yield* join(githubViewerPermissionFiber);
43233
43323
  const scoreMetadata = {
43234
43324
  ...repo !== null ? { repo } : {},
@@ -43455,7 +43545,7 @@ var StagedFiles = class StagedFiles extends Service()("react-doctor/StagedFiles"
43455
43545
  static layerNode = effect(StagedFiles, gen(function* () {
43456
43546
  const git = yield* Git;
43457
43547
  return StagedFiles.of({
43458
- 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")),
43459
43549
  materialize: ({ directory, stagedFiles, tempDirectory }) => materializeSourceTree({
43460
43550
  directory,
43461
43551
  files: stagedFiles,
@@ -43465,7 +43555,7 @@ var StagedFiles = class StagedFiles extends Service()("react-doctor/StagedFiles"
43465
43555
  tempDirectory: tree.tempDirectory,
43466
43556
  stagedFiles: tree.materializedFiles,
43467
43557
  cleanup: tree.cleanup
43468
- })))
43558
+ })), withSpan("StagedFiles.materialize"))
43469
43559
  });
43470
43560
  }));
43471
43561
  /**
@@ -43598,6 +43688,7 @@ const buildJsonReport = (input) => {
43598
43688
  score: result.score,
43599
43689
  skippedChecks: result.skippedChecks,
43600
43690
  ...result.skippedCheckReasons ? { skippedCheckReasons: result.skippedCheckReasons } : {},
43691
+ ...typeof result.scannedFileCount === "number" ? { scannedFileCount: result.scannedFileCount } : {},
43601
43692
  elapsedMilliseconds: result.elapsedMilliseconds
43602
43693
  }));
43603
43694
  const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
@@ -43933,6 +44024,17 @@ const detectTerminalKind = (env = process.env) => {
43933
44024
  return "unknown";
43934
44025
  };
43935
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
43936
44038
  //#region src/cli/utils/is-git-hook-environment.ts
43937
44039
  const isGitHookEnvironment = () => Boolean(process.env.GIT_DIR);
43938
44040
  //#endregion
@@ -44040,7 +44142,7 @@ const makeNoopConsole = () => ({
44040
44142
  });
44041
44143
  //#endregion
44042
44144
  //#region src/cli/utils/version.ts
44043
- const VERSION = "0.5.6-dev.937a7ca";
44145
+ const VERSION = "0.5.6-dev.a9d2713";
44044
44146
  //#endregion
44045
44147
  //#region src/cli/utils/json-mode.ts
44046
44148
  let context = null;
@@ -44192,6 +44294,7 @@ const buildRunContext = () => {
44192
44294
  interactive: !isNonInteractiveEnvironment(),
44193
44295
  terminalKind: detectTerminalKind(),
44194
44296
  jsonMode: isJsonModeActive(),
44297
+ debug: isDebugFlagEnabled(),
44195
44298
  invokedVia: detectInvokedVia()
44196
44299
  };
44197
44300
  };
@@ -44263,6 +44366,7 @@ const buildSentryScope = (runContext = buildRunContext()) => {
44263
44366
  interactive: runContext.interactive,
44264
44367
  terminalKind: runContext.terminalKind,
44265
44368
  jsonMode: runContext.jsonMode,
44369
+ debug: runContext.debug,
44266
44370
  invokedVia: runContext.invokedVia,
44267
44371
  nodeMajor: runContext.nodeMajor
44268
44372
  };
@@ -44400,13 +44504,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
44400
44504
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
44401
44505
  * standard `SENTRY_RELEASE` override.
44402
44506
  */
44403
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.6-dev.937a7ca`;
44507
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.5.6-dev.a9d2713`;
44404
44508
  /**
44405
44509
  * Deployment environment shown in Sentry's environment filter. Defaults to
44406
44510
  * `production` for tagged releases and `development` for dev/unbuilt versions,
44407
44511
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
44408
44512
  */
44409
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.6-dev.937a7ca") ? "development" : "production");
44513
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.5.6-dev.a9d2713") ? "development" : "production");
44410
44514
  /**
44411
44515
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
44412
44516
  * (set to `0` to disable tracing) and falls back to
@@ -44470,7 +44574,7 @@ const flushSentry = async () => {
44470
44574
  const initializeSentry = () => {
44471
44575
  if (isInitialized || !shouldEnableSentry()) return;
44472
44576
  isInitialized = true;
44473
- resolvedTracesSampleRate = resolveTracesSampleRate();
44577
+ resolvedTracesSampleRate = isDebugFlagEnabled() ? 1 : resolveTracesSampleRate();
44474
44578
  const { tags, contexts } = buildSentryScope();
44475
44579
  Sentry.init({
44476
44580
  dsn: process.env.SENTRY_DSN || "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920",
@@ -47686,6 +47790,11 @@ const setActiveRunTrace = (trace) => {
47686
47790
  activeRunTrace = trace;
47687
47791
  };
47688
47792
  const getActiveRunTrace = () => activeRunTrace;
47793
+ let lastRunTraceId = null;
47794
+ const recordRunTraceId = (traceId) => {
47795
+ lastRunTraceId = traceId;
47796
+ };
47797
+ const getLastRunTraceId = () => lastRunTraceId;
47689
47798
  //#endregion
47690
47799
  //#region src/cli/utils/to-span-attributes.ts
47691
47800
  /**
@@ -47748,14 +47857,13 @@ const withSentryRunSpan = (run, options = {}) => {
47748
47857
  op: "cli.inspect",
47749
47858
  attributes: toSpanAttributes(tags)
47750
47859
  }, (rootSpan) => {
47751
- if (options.concurrentScan !== true) {
47752
- const spanContext = rootSpan.spanContext();
47753
- setActiveRunTrace({
47754
- traceId: spanContext.traceId,
47755
- spanId: spanContext.spanId,
47756
- sampled: (spanContext.traceFlags & 1) === 1
47757
- });
47758
- }
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
+ });
47759
47867
  return run(rootSpan);
47760
47868
  });
47761
47869
  };
@@ -48013,10 +48121,14 @@ const buildOutcomeAttributes = (input) => {
48013
48121
  }
48014
48122
  let diagnosticsInTestFiles = 0;
48015
48123
  let diagnosticsInStoryFiles = 0;
48124
+ const findingsPerFixGroup = /* @__PURE__ */ new Map();
48016
48125
  for (const diagnostic of result.diagnostics) {
48017
48126
  if (diagnostic.fileContext === "test") diagnosticsInTestFiles += 1;
48018
48127
  if (diagnostic.fileContext === "story") diagnosticsInStoryFiles += 1;
48128
+ if (diagnostic.fixGroupId) findingsPerFixGroup.set(diagnostic.fixGroupId, (findingsPerFixGroup.get(diagnostic.fixGroupId) ?? 0) + 1);
48019
48129
  }
48130
+ let fixGroupedFindings = 0;
48131
+ for (const count of findingsPerFixGroup.values()) fixGroupedFindings += count;
48020
48132
  const attributes = {
48021
48133
  outcome,
48022
48134
  exitCode: wouldBlock ? 1 : 0,
@@ -48030,6 +48142,8 @@ const buildOutcomeAttributes = (input) => {
48030
48142
  diagnosticsInTestFiles,
48031
48143
  diagnosticsInStoryFiles,
48032
48144
  distinctRulesFired: countByRule.size,
48145
+ "diag.fixGroups": findingsPerFixGroup.size,
48146
+ "diag.fixGroupedFindings": fixGroupedFindings,
48033
48147
  topRule,
48034
48148
  scannedFileCount: result.scannedFileCount ?? null,
48035
48149
  elapsedMs: result.elapsedMilliseconds,
@@ -48219,6 +48333,12 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
48219
48333
  };
48220
48334
  const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
48221
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
+ };
48222
48342
  const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
48223
48343
  const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
48224
48344
  const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
@@ -48534,6 +48654,8 @@ const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, rende
48534
48654
  const impactMessages = isCollapsedWarningGroup ? [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.message))] : [representative.message];
48535
48655
  for (const impactMessage of impactMessages) for (const explanationLine of wrapTextToWidth(impactMessage, resolveMeasureWidth(4), { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
48536
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.`));
48537
48659
  if (renderEverySite && isAgentEnvironment) {
48538
48660
  const fixRecipeLine = formatFixRecipeLine(representative);
48539
48661
  if (fixRecipeLine) lines.push(highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${fixRecipeLine}`));
@@ -49192,6 +49314,78 @@ const resolveCliCategories = (categoryFlag) => {
49192
49314
  return resolvedCategories.length > 0 ? resolvedCategories : void 0;
49193
49315
  };
49194
49316
  //#endregion
49317
+ //#region src/cli/utils/git-hook-shared.ts
49318
+ const HOOK_FILE_NAME = "pre-commit";
49319
+ const HOOK_RELATIVE_PATH = "hooks/pre-commit";
49320
+ const LEGACY_HOOK_RUNNER_RELATIVE_PATH = ".react-doctor/hooks/pre-commit";
49321
+ const HUSKY_HOOKS_PATH = ".husky";
49322
+ const VITE_PLUS_HOOKS_PATH = ".vite-hooks";
49323
+ const LEFTHOOK_CONFIG_FILES = ["lefthook.yml", "lefthook.yaml"];
49324
+ const PRE_COMMIT_CONFIG_FILE = ".pre-commit-config.yaml";
49325
+ const OVERCOMMIT_CONFIG_FILE = ".overcommit.yml";
49326
+ const REACT_DOCTOR_COMMAND = "react-doctor --staged --blocking warning";
49327
+ const NON_BLOCKING_REACT_DOCTOR_COMMAND = [
49328
+ "react_doctor_output=$(mktemp \"${TMPDIR:-/tmp}/react-doctor-hook.XXXXXX\");",
49329
+ `if ${REACT_DOCTOR_COMMAND} > "$react_doctor_output" 2>&1; then`,
49330
+ "rm -f \"$react_doctor_output\";",
49331
+ "else",
49332
+ "rm -f \"$react_doctor_output\";",
49333
+ `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;`,
49334
+ "fi"
49335
+ ].join(" ");
49336
+ const PACKAGE_JSON_FILE_NAME = "package.json";
49337
+ const runGit = (projectRoot, args) => {
49338
+ try {
49339
+ return execFileSync("git", [...args], {
49340
+ cwd: projectRoot,
49341
+ encoding: "utf8",
49342
+ stdio: [
49343
+ "ignore",
49344
+ "pipe",
49345
+ "ignore"
49346
+ ]
49347
+ }).trim();
49348
+ } catch {
49349
+ return null;
49350
+ }
49351
+ };
49352
+ const resolveGitPath = (baseDirectory, value) => Path.isAbsolute(value) ? value : Path.resolve(baseDirectory, value);
49353
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
49354
+ const getPackageJsonPath = (projectRoot) => Path.join(projectRoot, PACKAGE_JSON_FILE_NAME);
49355
+ const readPackageJson = (projectRoot) => {
49356
+ try {
49357
+ return JSON.parse(NFS.readFileSync(getPackageJsonPath(projectRoot), "utf8"));
49358
+ } catch {
49359
+ return null;
49360
+ }
49361
+ };
49362
+ const writeJsonFile$1 = (filePath, value) => {
49363
+ NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
49364
+ };
49365
+ const packageHasDependency = (projectRoot, dependencyName) => {
49366
+ const packageJson = readPackageJson(projectRoot);
49367
+ if (!isRecord$1(packageJson)) return false;
49368
+ return [
49369
+ "dependencies",
49370
+ "devDependencies",
49371
+ "optionalDependencies"
49372
+ ].some((fieldName) => {
49373
+ const dependencies = packageJson[fieldName];
49374
+ return isRecord$1(dependencies) && typeof dependencies[dependencyName] === "string";
49375
+ });
49376
+ };
49377
+ const packageHasRecordKey = (projectRoot, key) => {
49378
+ const packageJson = readPackageJson(projectRoot);
49379
+ return isRecord$1(packageJson) && isRecord$1(packageJson[key]);
49380
+ };
49381
+ const packageHasNestedRecordKey = (projectRoot, key, nestedKey) => {
49382
+ const packageJson = readPackageJson(projectRoot);
49383
+ if (!isRecord$1(packageJson)) return false;
49384
+ const value = packageJson[key];
49385
+ return isRecord$1(value) && isRecord$1(value[nestedKey]);
49386
+ };
49387
+ const ensureTrailingNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
49388
+ //#endregion
49195
49389
  //#region src/cli/utils/scan-result-cache.ts
49196
49390
  const CACHE_DISABLED_VALUES = new Set(["1", "true"]);
49197
49391
  const TOOLCHAIN_PACKAGE_SPECIFIERS = [
@@ -49202,7 +49396,7 @@ const TOOLCHAIN_PACKAGE_SPECIFIERS = [
49202
49396
  "eslint-plugin-react-hooks/package.json"
49203
49397
  ];
49204
49398
  const bundledRequire = createRequire(import.meta.url);
49205
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
49399
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
49206
49400
  const normalizeForStableJson = (value) => {
49207
49401
  if (value === null) return null;
49208
49402
  if (value === void 0) return void 0;
@@ -49231,24 +49425,9 @@ const stringifyStableJson = (value) => {
49231
49425
  }
49232
49426
  };
49233
49427
  const hashString = (value) => crypto.createHash("sha1").update(value).digest("hex");
49234
- const runGit$1 = (directory, args) => {
49235
- try {
49236
- return execFileSync("git", [...args], {
49237
- cwd: directory,
49238
- encoding: "utf8",
49239
- stdio: [
49240
- "ignore",
49241
- "pipe",
49242
- "ignore"
49243
- ]
49244
- }).trim();
49245
- } catch {
49246
- return null;
49247
- }
49248
- };
49249
- const readHeadSha = (projectDirectory) => runGit$1(projectDirectory, ["rev-parse", "HEAD"]);
49428
+ const readHeadSha = (projectDirectory) => runGit(projectDirectory, ["rev-parse", "HEAD"]);
49250
49429
  const isWorktreeClean = (projectDirectory) => {
49251
- const status = runGit$1(projectDirectory, [
49430
+ const status = runGit(projectDirectory, [
49252
49431
  "status",
49253
49432
  "--porcelain=v1",
49254
49433
  "--untracked-files=normal"
@@ -49256,7 +49435,7 @@ const isWorktreeClean = (projectDirectory) => {
49256
49435
  return status !== null && status.length === 0;
49257
49436
  };
49258
49437
  const hasHiddenTrackedFileState = (projectDirectory) => {
49259
- const output = runGit$1(projectDirectory, ["ls-files", "-v"]);
49438
+ const output = runGit(projectDirectory, ["ls-files", "-v"]);
49260
49439
  if (output === null) return true;
49261
49440
  return output.split("\n").some((line) => line.length > 0 && line[0] !== "H");
49262
49441
  };
@@ -49269,7 +49448,7 @@ const resolveCacheFilePath = (projectDirectory) => {
49269
49448
  const readPersistedCache = (cacheFilePath) => {
49270
49449
  try {
49271
49450
  const parsed = JSON.parse(fs.readFileSync(cacheFilePath, "utf8"));
49272
- if (!isRecord$1(parsed) || parsed.version !== 1) return {
49451
+ if (!isRecord(parsed) || parsed.version !== 1) return {
49273
49452
  version: 1,
49274
49453
  entries: []
49275
49454
  };
@@ -49279,8 +49458,8 @@ const readPersistedCache = (cacheFilePath) => {
49279
49458
  };
49280
49459
  const entries = [];
49281
49460
  for (const entry of parsed.entries) {
49282
- if (!isRecord$1(entry) || typeof entry.key !== "string" || typeof entry.createdAtMs !== "number") continue;
49283
- if (!isRecord$1(entry.payload) || !Array.isArray(entry.payload.diagnostics)) continue;
49461
+ if (!isRecord(entry) || typeof entry.key !== "string" || typeof entry.createdAtMs !== "number") continue;
49462
+ if (!isRecord(entry.payload) || !Array.isArray(entry.payload.diagnostics)) continue;
49284
49463
  entries.push(entry);
49285
49464
  }
49286
49465
  return {
@@ -50035,7 +50214,9 @@ const buildHandoffPayload = (input) => {
50035
50214
  topGroups.forEach(([ruleKey, ruleDiagnostics], index) => {
50036
50215
  const representative = ruleDiagnostics[0];
50037
50216
  const severityLabel = representative.severity === "error" ? "ERROR" : "WARN";
50038
- 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}`);
50039
50220
  const fixRecipeLine = formatFixRecipeLine(representative);
50040
50221
  if (fixRecipeLine) lines.push(` ${fixRecipeLine}`);
50041
50222
  const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
@@ -50048,7 +50229,7 @@ const buildHandoffPayload = (input) => {
50048
50229
  });
50049
50230
  lines.push("");
50050
50231
  if (outputDirectory) lines.push(`Full results for all ${input.diagnostics.length} issues (diagnostics.json + a .txt per rule): ${outputDirectory}`, "");
50051
- 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.");
50052
50233
  return lines.join("\n");
50053
50234
  };
50054
50235
  //#endregion
@@ -50092,78 +50273,6 @@ const detectAvailableAgents = async () => {
50092
50273
  return getSkillAgentTypes().filter((agent) => agent !== "universal" && detected.has(agent));
50093
50274
  };
50094
50275
  //#endregion
50095
- //#region src/cli/utils/git-hook-shared.ts
50096
- const HOOK_FILE_NAME = "pre-commit";
50097
- const HOOK_RELATIVE_PATH = "hooks/pre-commit";
50098
- const LEGACY_HOOK_RUNNER_RELATIVE_PATH = ".react-doctor/hooks/pre-commit";
50099
- const HUSKY_HOOKS_PATH = ".husky";
50100
- const VITE_PLUS_HOOKS_PATH = ".vite-hooks";
50101
- const LEFTHOOK_CONFIG_FILES = ["lefthook.yml", "lefthook.yaml"];
50102
- const PRE_COMMIT_CONFIG_FILE = ".pre-commit-config.yaml";
50103
- const OVERCOMMIT_CONFIG_FILE = ".overcommit.yml";
50104
- const REACT_DOCTOR_COMMAND = "react-doctor --staged --blocking warning";
50105
- const NON_BLOCKING_REACT_DOCTOR_COMMAND = [
50106
- "react_doctor_output=$(mktemp \"${TMPDIR:-/tmp}/react-doctor-hook.XXXXXX\");",
50107
- `if ${REACT_DOCTOR_COMMAND} > "$react_doctor_output" 2>&1; then`,
50108
- "rm -f \"$react_doctor_output\";",
50109
- "else",
50110
- "rm -f \"$react_doctor_output\";",
50111
- `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;`,
50112
- "fi"
50113
- ].join(" ");
50114
- const PACKAGE_JSON_FILE_NAME = "package.json";
50115
- const runGit = (projectRoot, args) => {
50116
- try {
50117
- return execFileSync("git", [...args], {
50118
- cwd: projectRoot,
50119
- encoding: "utf8",
50120
- stdio: [
50121
- "ignore",
50122
- "pipe",
50123
- "ignore"
50124
- ]
50125
- }).trim();
50126
- } catch {
50127
- return null;
50128
- }
50129
- };
50130
- const resolveGitPath = (baseDirectory, value) => Path.isAbsolute(value) ? value : Path.resolve(baseDirectory, value);
50131
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
50132
- const getPackageJsonPath = (projectRoot) => Path.join(projectRoot, PACKAGE_JSON_FILE_NAME);
50133
- const readPackageJson = (projectRoot) => {
50134
- try {
50135
- return JSON.parse(NFS.readFileSync(getPackageJsonPath(projectRoot), "utf8"));
50136
- } catch {
50137
- return null;
50138
- }
50139
- };
50140
- const writeJsonFile$1 = (filePath, value) => {
50141
- NFS.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
50142
- };
50143
- const packageHasDependency = (projectRoot, dependencyName) => {
50144
- const packageJson = readPackageJson(projectRoot);
50145
- if (!isRecord(packageJson)) return false;
50146
- return [
50147
- "dependencies",
50148
- "devDependencies",
50149
- "optionalDependencies"
50150
- ].some((fieldName) => {
50151
- const dependencies = packageJson[fieldName];
50152
- return isRecord(dependencies) && typeof dependencies[dependencyName] === "string";
50153
- });
50154
- };
50155
- const packageHasRecordKey = (projectRoot, key) => {
50156
- const packageJson = readPackageJson(projectRoot);
50157
- return isRecord(packageJson) && isRecord(packageJson[key]);
50158
- };
50159
- const packageHasNestedRecordKey = (projectRoot, key, nestedKey) => {
50160
- const packageJson = readPackageJson(projectRoot);
50161
- if (!isRecord(packageJson)) return false;
50162
- const value = packageJson[key];
50163
- return isRecord(value) && isRecord(value[nestedKey]);
50164
- };
50165
- const ensureTrailingNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
50166
- //#endregion
50167
50276
  //#region src/cli/utils/install-doctor-script.ts
50168
50277
  const DOCTOR_SCRIPT_NAME = "doctor";
50169
50278
  const FALLBACK_DOCTOR_SCRIPT_NAME = "react-doctor";
@@ -50189,31 +50298,31 @@ const findNearestPackageDirectory = (startDirectory, stopDirectory) => {
50189
50298
  };
50190
50299
  const hasDoctorScript = (projectRoot) => {
50191
50300
  const packageJson = readPackageJson(findNearestPackageDirectory(projectRoot) ?? projectRoot);
50192
- if (!isRecord(packageJson)) return false;
50301
+ if (!isRecord$1(packageJson)) return false;
50193
50302
  const scripts = packageJson.scripts;
50194
- if (!isRecord(scripts)) return false;
50303
+ if (!isRecord$1(scripts)) return false;
50195
50304
  return isReactDoctorScriptCommand(scripts[DOCTOR_SCRIPT_NAME]) || isReactDoctorScriptCommand(scripts[FALLBACK_DOCTOR_SCRIPT_NAME]);
50196
50305
  };
50197
50306
  const hasDoctorDependency = (packageJson) => DEPENDENCY_FIELD_NAMES.some((fieldName) => {
50198
50307
  const dependencies = packageJson[fieldName];
50199
- return isRecord(dependencies) && Object.hasOwn(dependencies, "react-doctor");
50308
+ return isRecord$1(dependencies) && Object.hasOwn(dependencies, "react-doctor");
50200
50309
  });
50201
50310
  const installDoctorScript = (options) => {
50202
50311
  const packageDirectory = findNearestPackageDirectory(options.projectRoot) ?? options.projectRoot;
50203
50312
  const packageJsonPath = getPackageJsonPath(packageDirectory);
50204
50313
  const packageJson = readPackageJson(packageDirectory);
50205
- if (!isRecord(packageJson)) return {
50314
+ if (!isRecord$1(packageJson)) return {
50206
50315
  packageJsonPath,
50207
50316
  scriptStatus: "skipped",
50208
50317
  scriptReason: "missing-or-invalid-package-json"
50209
50318
  };
50210
50319
  const scripts = packageJson.scripts;
50211
50320
  const scriptTarget = (() => {
50212
- if (scripts !== void 0 && !isRecord(scripts)) return {
50321
+ if (scripts !== void 0 && !isRecord$1(scripts)) return {
50213
50322
  status: "skipped",
50214
50323
  reason: "invalid-scripts"
50215
50324
  };
50216
- const scriptRecord = isRecord(scripts) ? scripts : {};
50325
+ const scriptRecord = isRecord$1(scripts) ? scripts : {};
50217
50326
  if (isReactDoctorScriptCommand(scriptRecord[DOCTOR_SCRIPT_NAME])) return {
50218
50327
  scriptName: DOCTOR_SCRIPT_NAME,
50219
50328
  status: "existing"
@@ -50247,7 +50356,7 @@ const installDoctorScript = (options) => {
50247
50356
  if (scriptStatus === "created") writeJsonFile$1(packageJsonPath, {
50248
50357
  ...packageJson,
50249
50358
  scripts: {
50250
- ...isRecord(scripts) ? scripts : {},
50359
+ ...isRecord$1(scripts) ? scripts : {},
50251
50360
  [scriptTarget.scriptName ?? DOCTOR_SCRIPT_NAME]: DOCTOR_SCRIPT_COMMAND
50252
50361
  }
50253
50362
  });
@@ -51075,13 +51184,13 @@ const installPackageJsonHook = (options, strategy) => {
51075
51184
  const packageJsonPath = getPackageJsonPath(options.projectRoot);
51076
51185
  const didHookExist = NFS.existsSync(packageJsonPath);
51077
51186
  const packageJson = readPackageJson(options.projectRoot);
51078
- const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
51187
+ const nextPackageJson = isRecord$1(packageJson) ? { ...packageJson } : {};
51079
51188
  const parentKeys = strategy.path.slice(0, -1);
51080
51189
  const leafKey = strategy.path[strategy.path.length - 1];
51081
51190
  let parent = nextPackageJson;
51082
51191
  for (const key of parentKeys) {
51083
51192
  const existing = parent[key];
51084
- const cloned = isRecord(existing) ? { ...existing } : {};
51193
+ const cloned = isRecord$1(existing) ? { ...existing } : {};
51085
51194
  parent[key] = cloned;
51086
51195
  parent = cloned;
51087
51196
  }
@@ -51252,7 +51361,7 @@ const isHuskyProject = (projectRoot) => NFS.existsSync(Path.join(projectRoot, ".
51252
51361
  const isVitePlusProject = (projectRoot) => packageHasDependency(projectRoot, "vite-plus");
51253
51362
  const isSimpleGitHooksProject = (projectRoot) => {
51254
51363
  const packageJson = readPackageJson(projectRoot);
51255
- return isRecord(packageJson) && isRecord(packageJson["simple-git-hooks"]) || packageHasDependency(projectRoot, "simple-git-hooks") || NFS.existsSync(Path.join(projectRoot, ".simple-git-hooks.cjs"));
51364
+ return isRecord$1(packageJson) && isRecord$1(packageJson["simple-git-hooks"]) || packageHasDependency(projectRoot, "simple-git-hooks") || NFS.existsSync(Path.join(projectRoot, ".simple-git-hooks.cjs"));
51256
51365
  };
51257
51366
  const getLefthookConfigPath = (projectRoot) => {
51258
51367
  for (const fileName of LEFTHOOK_CONFIG_FILES) {
@@ -51418,7 +51527,7 @@ const detectPackageManager = (projectRoot) => {
51418
51527
  let currentDirectory = Path.resolve(projectRoot);
51419
51528
  while (true) {
51420
51529
  const packageJson = readPackageJson(currentDirectory);
51421
- if (isRecord(packageJson) && typeof packageJson.packageManager === "string") {
51530
+ if (isRecord$1(packageJson) && typeof packageJson.packageManager === "string") {
51422
51531
  const packageManagerName = packageJson.packageManager.split("@")[0];
51423
51532
  if (packageManagerName === "pnpm" || packageManagerName === "yarn" || packageManagerName === "bun" || packageManagerName === "npm") return packageManagerName;
51424
51533
  }
@@ -51494,12 +51603,12 @@ const isSupplyChainTrustError = (error) => {
51494
51603
  const formatInstallCommand = (input) => [input.command, ...input.args].join(" ");
51495
51604
  const installReactDoctorDependency = async (options) => {
51496
51605
  const packageJson = readPackageJson(options.projectRoot);
51497
- if (!isRecord(packageJson)) return {
51606
+ if (!isRecord$1(packageJson)) return {
51498
51607
  dependencyStatus: "skipped",
51499
51608
  dependencyReason: "missing-or-invalid-package-json"
51500
51609
  };
51501
51610
  if (hasDoctorDependency(packageJson)) return { dependencyStatus: "existing" };
51502
- if (packageJson.devDependencies !== void 0 && !isRecord(packageJson.devDependencies)) return {
51611
+ if (packageJson.devDependencies !== void 0 && !isRecord$1(packageJson.devDependencies)) return {
51503
51612
  dependencyStatus: "skipped",
51504
51613
  dependencyReason: "invalid-dev-dependencies"
51505
51614
  };
@@ -52155,6 +52264,7 @@ const reportErrorToSentry = async (error) => {
52155
52264
  sampled: runTrace.sampled,
52156
52265
  sampleRand: Math.random()
52157
52266
  });
52267
+ recordRunTraceId(scope.getPropagationContext().traceId);
52158
52268
  return Sentry.captureException(error);
52159
52269
  });
52160
52270
  await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS);
@@ -52769,6 +52879,10 @@ const validateModeFlags = (flags) => {
52769
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.`);
52770
52880
  if (flags.score && flags.json) throw new CliInputError("Cannot combine --score and --json; pick one output mode.");
52771
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
+ }
52772
52886
  };
52773
52887
  //#endregion
52774
52888
  //#region src/cli/commands/inspect.ts
@@ -53122,6 +53236,7 @@ const inspectAction = async (directory, flags) => {
53122
53236
  } catch (error) {
53123
53237
  const isUserError = isExpectedUserError(error);
53124
53238
  const sentryEventId = isUserError ? void 0 : await reportErrorToSentry(error);
53239
+ if (isDebugFlagEnabled()) await flushSentry();
53125
53240
  if (isJsonMode) {
53126
53241
  writeJsonErrorReport(error, sentryEventId);
53127
53242
  process.exitCode = 1;
@@ -53844,6 +53959,33 @@ const normalizeHelpInvocation = (argv, knownCommands) => {
53844
53959
  return [...nodeArguments, "--help"];
53845
53960
  };
53846
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
53847
53989
  //#region src/cli/utils/removed-cli-flags.ts
53848
53990
  const REMOVED_FLAGS = new Map([
53849
53991
  ["--full", "use `--diff false` to force a full scan"],
@@ -53870,6 +54012,7 @@ const ROOT_FLAG_SPEC = {
53870
54012
  longOptionsWithoutValues: new Set([
53871
54013
  "--color",
53872
54014
  "--dead-code",
54015
+ "--debug",
53873
54016
  "--help",
53874
54017
  "--json",
53875
54018
  "--json-compact",
@@ -54037,6 +54180,9 @@ const stripUnknownCliFlags = (argv) => {
54037
54180
  initializeSentry();
54038
54181
  process.on("SIGINT", exitGracefully);
54039
54182
  process.on("SIGTERM", exitGracefully);
54183
+ process.on("exit", () => {
54184
+ if (isDebugFlagEnabled()) printDebugTrace();
54185
+ });
54040
54186
  unrefStdin();
54041
54187
  guardStdin();
54042
54188
  const formatExampleLines = (examples) => {
@@ -54081,7 +54227,7 @@ ${highlighter.dim("Learn more:")}
54081
54227
  ${highlighter.info(CANONICAL_GITHUB_URL)}
54082
54228
  `;
54083
54229
  const collectCategoryOption = (value, previousValues) => [...previousValues ?? [], value];
54084
- 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);
54085
54231
  program.action(inspectAction);
54086
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));
54087
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);
@@ -54124,4 +54270,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
54124
54270
  export {};
54125
54271
 
54126
54272
  //# sourceMappingURL=cli.js.map
54127
- //# debugId=ad091b20-c3e2-5c96-93ac-9a910745a035
54273
+ //# debugId=e4d06258-7975-53ee-88e5-490715798dd2