react-doctor 0.2.9 → 0.2.11-dev.402c7ea

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
  import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./rolldown-runtime-uZX_iqCz.js";
2
- import { A as isReactDoctorError, C as filterSourceFiles, D as groupBy, E as getDiffInfo, F as runInspect, I as toRelativePath, M as listWorkspacePackages, N as resolveScanTarget, O as highlighter, P as restoreLegacyThrow, S as filterDiagnosticsForSurface, T as formatReactDoctorError, _ as Score, a as DeadCode, b as buildJsonReportError, c as LintPartialFailures, d as OXLINT_NODE_REQUIREMENT, f as Progress, g as SKILL_NAME, h as SHARE_BASE_URL, i as Config, j as layerOtlp, k as isMonorepoRoot, l as Linter, m as Reporter, o as Files, p as Project, r as CANONICAL_GITHUB_URL, s as Git, t as cliLogger, u as NodeResolver, v as StagedFiles, w as formatErrorChain, x as discoverReactSubprojects, y as buildJsonReport } from "./cli-logger-BliQX9s8.js";
2
+ import { A as isReactDoctorError, C as filterSourceFiles, D as groupBy, E as getDiffInfo, F as runInspect, I as toRelativePath, M as listWorkspacePackages, N as resolveScanTarget, O as highlighter, P as restoreLegacyThrow, S as filterDiagnosticsForSurface, T as formatReactDoctorError, _ as Score, a as DeadCode, b as buildJsonReportError, c as LintPartialFailures, d as OXLINT_NODE_REQUIREMENT, f as Progress, g as SKILL_NAME, h as SHARE_BASE_URL, i as Config, j as layerOtlp, k as isMonorepoRoot, l as Linter, m as Reporter, o as Files, p as Project, r as CANONICAL_GITHUB_URL, s as Git, t as cliLogger, u as NodeResolver, v as StagedFiles, w as formatErrorChain, x as discoverReactSubprojects, y as buildJsonReport } from "./cli-logger-Df45H6Lw.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { execFileSync, execSync } from "node:child_process";
5
5
  import path, { join } from "node:path";
@@ -6320,11 +6320,11 @@ const colorizeByScore = (text, score) => {
6320
6320
  return highlighter.error(text);
6321
6321
  };
6322
6322
  //#endregion
6323
+ //#region src/cli/utils/constants.ts
6324
+ const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
6325
+ const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
6326
+ //#endregion
6323
6327
  //#region src/cli/utils/render-score-header.ts
6324
- const SCORE_BAR_ANIMATION_FRAME_COUNT = 40;
6325
- const SCORE_BAR_ANIMATION_FRAME_DELAY_MS = 50;
6326
- const PERFECT_SCORE_RAINBOW_FRAME_COUNT = 16;
6327
- const PERFECT_SCORE_RAINBOW_FRAME_DELAY_MS = 50;
6328
6328
  const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
6329
6329
  const RAINBOW_GRADIENT_WIDTH = 80;
6330
6330
  const RAINBOW_OKLCH_LIGHTNESS = .638;
@@ -6433,8 +6433,8 @@ const buildInitialScoreHeaderLine = ({ isPerfectScore, shouldAnimate, lineIndex,
6433
6433
  };
6434
6434
  const printAnimatedScore = (scoreFaceLine, barFaceLine, score, label, projectName) => Effect.gen(function* () {
6435
6435
  const isPerfectScore = score === 100;
6436
- for (let frame = 0; frame <= SCORE_BAR_ANIMATION_FRAME_COUNT; frame += 1) {
6437
- const progress = easeOutCubic(frame / SCORE_BAR_ANIMATION_FRAME_COUNT);
6436
+ for (let frame = 0; frame <= 40; frame += 1) {
6437
+ const progress = easeOutCubic(frame / 40);
6438
6438
  const animatedScore = Math.round(score * progress);
6439
6439
  if (isPerfectScore) {
6440
6440
  yield* writeScoreHeaderLine(`${frame === 0 ? "" : "\x1B[4A"}\r${buildRainbowScoreHeaderFrame({
@@ -6444,16 +6444,16 @@ const printAnimatedScore = (scoreFaceLine, barFaceLine, score, label, projectNam
6444
6444
  frame,
6445
6445
  projectName
6446
6446
  })}`);
6447
- if (frame < SCORE_BAR_ANIMATION_FRAME_COUNT) yield* sleep(SCORE_BAR_ANIMATION_FRAME_DELAY_MS);
6447
+ if (frame < 40) yield* sleep(50);
6448
6448
  continue;
6449
6449
  }
6450
6450
  const animatedScoreLine = buildScoreLine(animatedScore, score, label, projectName);
6451
6451
  const animatedBarLine = buildScoreBar(animatedScore, score);
6452
6452
  yield* writeScoreHeaderLine(`${frame === 0 ? "" : "\x1B[2A"}\r${buildScoreHeaderLine(scoreFaceLine, animatedScoreLine)}\n\r${buildScoreHeaderLine(barFaceLine, animatedBarLine)}\n`);
6453
- if (frame < SCORE_BAR_ANIMATION_FRAME_COUNT) yield* sleep(SCORE_BAR_ANIMATION_FRAME_DELAY_MS);
6453
+ if (frame < 40) yield* sleep(50);
6454
6454
  }
6455
6455
  if (!isPerfectScore) return;
6456
- for (let frame = 0; frame < PERFECT_SCORE_RAINBOW_FRAME_COUNT; frame += 1) {
6456
+ for (let frame = 0; frame < 16; frame += 1) {
6457
6457
  yield* writeScoreHeaderLine(`\x1b[4A\r${buildRainbowScoreHeaderFrame({
6458
6458
  score,
6459
6459
  displayScore: score,
@@ -6461,9 +6461,9 @@ const printAnimatedScore = (scoreFaceLine, barFaceLine, score, label, projectNam
6461
6461
  frame,
6462
6462
  projectName
6463
6463
  })}`);
6464
- yield* sleep(PERFECT_SCORE_RAINBOW_FRAME_DELAY_MS);
6464
+ yield* sleep(50);
6465
6465
  }
6466
- yield* writeScoreHeaderLine(`\x1b[4A\r${buildFinalPerfectScoreHeaderFrame(score, label, PERFECT_SCORE_RAINBOW_FRAME_COUNT, projectName)}\x1b[2A`);
6466
+ yield* writeScoreHeaderLine(`\x1b[4A\r${buildFinalPerfectScoreHeaderFrame(score, label, 16, projectName)}\x1b[2A`);
6467
6467
  });
6468
6468
  const printScoreHeader = (scoreResult, projectName) => Effect.gen(function* () {
6469
6469
  const isPerfectScore = scoreResult.score === 100;
@@ -6666,7 +6666,7 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
6666
6666
  const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
6667
6667
  //#endregion
6668
6668
  //#region src/cli/utils/version.ts
6669
- const VERSION = "0.2.9";
6669
+ const VERSION = "0.2.11-dev.402c7ea";
6670
6670
  //#endregion
6671
6671
  //#region src/inspect.ts
6672
6672
  const silentConsole = makeNoopConsole();
@@ -6764,9 +6764,7 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
6764
6764
  const output = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
6765
6765
  const didLintFail = lintBindingMissing || output.didLintFail;
6766
6766
  const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
6767
- const lintFailureReasonTag = output.lintFailureReasonTag;
6768
- const isNativeBindingFailure = lintFailureReasonTag === "OxlintUnavailable" || lintFailureReasonTag === "OxlintSpawnFailed";
6769
- if (!options.scoreOnly && !lintBindingMissing && output.didLintFail && lintFailureReason !== null) if (isNativeBindingFailure && /native binding/.test(lintFailureReason)) runConsole(Console.log(highlighter.gray(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`)));
6767
+ if (!options.scoreOnly && !lintBindingMissing && output.didLintFail && lintFailureReason !== null) if (output.lintFailureReasonKind === "native-binding-missing") runConsole(Console.log(highlighter.gray(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`)));
6770
6768
  else runConsole(Console.error(highlighter.error(lintFailureReason)));
6771
6769
  const inspectDiagnostics = output.diagnostics;
6772
6770
  const score = didLintFail ? null : output.score;
@@ -6854,10 +6852,6 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
6854
6852
  return buildResult();
6855
6853
  });
6856
6854
  //#endregion
6857
- //#region src/cli/utils/constants.ts
6858
- const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
6859
- const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
6860
- //#endregion
6861
6855
  //#region src/cli/utils/get-staged-files.ts
6862
6856
  const stagedFilesLayer = StagedFiles.layerNode.pipe(Layer.provide(Git.layerNode));
6863
6857
  const getStagedSourceFiles = async (directory) => {
@@ -7416,7 +7410,7 @@ const disableSetupPrompt = (projectRoot, storeOptions = {}) => {
7416
7410
  return true;
7417
7411
  };
7418
7412
  const shouldPromptInstallSetup = (options) => {
7419
- if (!options.hasScoredScan) return false;
7413
+ if (!(options.hasCompletedScan ?? options.hasScoredScan ?? false)) return false;
7420
7414
  if (options.isJsonMode) return false;
7421
7415
  if (options.isScoreOnly) return false;
7422
7416
  if (options.isStaged) return false;
@@ -7426,13 +7420,13 @@ const shouldPromptInstallSetup = (options) => {
7426
7420
  return !hasDoctorScript(options.projectRoot);
7427
7421
  };
7428
7422
  const resolveInstallSetupProjectRoot = (options) => {
7429
- if (options.completedScanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
7423
+ if (options.scanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
7430
7424
  const packageDirectories = /* @__PURE__ */ new Set();
7431
- for (const scanDirectory of options.completedScanDirectories) {
7425
+ for (const scanDirectory of options.scanDirectories) {
7432
7426
  const packageDirectory = findNearestPackageDirectory(scanDirectory, options.scanRoot) ?? findNearestPackageDirectory(scanDirectory) ?? scanDirectory;
7433
7427
  packageDirectories.add(packageDirectory);
7434
7428
  }
7435
- if (packageDirectories.size !== 1) return null;
7429
+ if (packageDirectories.size !== 1) return findNearestPackageDirectory(options.scanRoot, options.scanRoot);
7436
7430
  return [...packageDirectories][0] ?? null;
7437
7431
  };
7438
7432
  const defaultWait = (milliseconds) => new Promise((resolve) => {
@@ -7467,7 +7461,7 @@ const warnSetupPromptFailure = async (options, error) => {
7467
7461
  return;
7468
7462
  }
7469
7463
  try {
7470
- const { cliLogger } = await import("./cli-logger-BliQX9s8.js").then((n) => n.n);
7464
+ const { cliLogger } = await import("./cli-logger-Df45H6Lw.js").then((n) => n.n);
7471
7465
  cliLogger.warn(message);
7472
7466
  } catch {}
7473
7467
  };
@@ -7483,7 +7477,7 @@ const promptInstallSetup = async (options) => {
7483
7477
  writeLine("You can always run `npx react-doctor@latest install` to set it up later.");
7484
7478
  return;
7485
7479
  }
7486
- const install = options.install ?? (await Promise.resolve().then(() => install_skill_exports)).runInstallSkill;
7480
+ const install = options.install ?? (await Promise.resolve().then(() => install_react_doctor_exports)).runInstallReactDoctor;
7487
7481
  const previousExitCode = process.exitCode;
7488
7482
  let setupExitCode;
7489
7483
  try {
@@ -7502,7 +7496,7 @@ const promptInstallSetup = async (options) => {
7502
7496
  }
7503
7497
  };
7504
7498
  const shouldShowAgentInstallHint = (options) => {
7505
- if (!options.hasScoredScan) return false;
7499
+ if (!(options.hasCompletedScan ?? options.hasScoredScan ?? false)) return false;
7506
7500
  if (options.isJsonMode) return false;
7507
7501
  if (options.isScoreOnly) return false;
7508
7502
  if (options.isStaged) return false;
@@ -7567,15 +7561,17 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQui
7567
7561
  const { scanScope } = await prompts({
7568
7562
  type: "select",
7569
7563
  name: "scanScope",
7570
- message: "Select",
7564
+ message: "Choose what to scan",
7571
7565
  choices: [{
7572
7566
  title: "Full codebase",
7567
+ description: "Scan every source file",
7573
7568
  value: "full"
7574
7569
  }, {
7575
- title: `Changed files (${changedSourceFiles.length})`,
7570
+ title: diffInfo.isCurrentChanges ? `Uncommitted changes (${changedSourceFiles.length})` : `Changed files on ${diffInfo.currentBranch ?? "this branch"} (${changedSourceFiles.length})`,
7571
+ description: diffInfo.isCurrentChanges ? "Compare working tree changes against HEAD" : `Compare against ${diffInfo.baseBranch} from the branch merge-base`,
7576
7572
  value: "branch"
7577
7573
  }],
7578
- initial: 0
7574
+ initial: diffInfo.isCurrentChanges ? 0 : 1
7579
7575
  });
7580
7576
  return scanScope === "branch";
7581
7577
  };
@@ -7605,13 +7601,13 @@ const VALID_FAIL_ON_LEVELS = new Set([
7605
7601
  "warning",
7606
7602
  "none"
7607
7603
  ]);
7608
- const DEFAULT_FAIL_ON_LEVEL = "error";
7604
+ const DEFAULT_FAIL_ON_LEVEL = "none";
7609
7605
  const isValidFailOnLevel = (level) => VALID_FAIL_ON_LEVELS.has(level);
7610
7606
  const resolveFailOnLevel = (flags, userConfig) => {
7611
7607
  const sourceValue = flags.failOn ?? userConfig?.failOn ?? DEFAULT_FAIL_ON_LEVEL;
7612
7608
  if (isValidFailOnLevel(sourceValue)) return sourceValue;
7613
- cliLogger.warn(`Invalid failOn level "${sourceValue}". Expected one of: error, warning, none. Falling back to "none".`);
7614
- return "none";
7609
+ cliLogger.warn(`Invalid failOn level "${sourceValue}". Expected one of: error, warning, none. Falling back to "${DEFAULT_FAIL_ON_LEVEL}".`);
7610
+ return DEFAULT_FAIL_ON_LEVEL;
7615
7611
  };
7616
7612
  //#endregion
7617
7613
  //#region src/cli/utils/resolve-project-diff-include-paths.ts
@@ -7709,7 +7705,7 @@ const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
7709
7705
  }
7710
7706
  if (packages.length === 0) return [rootDirectory];
7711
7707
  if (packages.length === 1) {
7712
- cliLogger.log(`${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${packages[0].name}`);
7708
+ cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages[0].name}`);
7713
7709
  return [packages[0].directory];
7714
7710
  }
7715
7711
  if (projectFlag) return resolveProjectFlag(projectFlag, packages);
@@ -7733,13 +7729,13 @@ const resolveProjectFlag = (projectFlag, workspacePackages) => {
7733
7729
  return resolvedDirectories;
7734
7730
  };
7735
7731
  const printDiscoveredProjects = (packages) => {
7736
- cliLogger.log(`${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${packages.map((workspacePackage) => workspacePackage.name).join(", ")}`);
7732
+ cliLogger.log(`${highlighter.success("✔")} Select projects ${highlighter.dim("›")} ${packages.map((workspacePackage) => workspacePackage.name).join(", ")}`);
7737
7733
  };
7738
7734
  const promptProjectSelection = async (workspacePackages, rootDirectory) => {
7739
7735
  const { selectedDirectories } = await prompts({
7740
7736
  type: "multiselect",
7741
7737
  name: "selectedDirectories",
7742
- message: "Select projects to scan",
7738
+ message: "Select projects",
7743
7739
  choices: workspacePackages.map((workspacePackage) => ({
7744
7740
  title: workspacePackage.name,
7745
7741
  description: path.relative(rootDirectory, workspacePackage.directory),
@@ -7896,7 +7892,14 @@ const inspectAction = async (directory, flags) => {
7896
7892
  cliLogger.log(`Scanning ${highlighter.info(`${stagedFiles.length}`)} staged files...`);
7897
7893
  cliLogger.break();
7898
7894
  }
7899
- const snapshot = await materializeStagedFiles(resolvedDirectory, stagedFiles, mkdtempSync(path.join(tmpdir(), STAGED_FILES_TEMP_DIR_PREFIX)));
7895
+ const tempDirectory = mkdtempSync(path.join(tmpdir(), STAGED_FILES_TEMP_DIR_PREFIX));
7896
+ const snapshot = await materializeStagedFiles(resolvedDirectory, stagedFiles, tempDirectory).catch((error) => {
7897
+ rmSync(tempDirectory, {
7898
+ recursive: true,
7899
+ force: true
7900
+ });
7901
+ throw error;
7902
+ });
7900
7903
  try {
7901
7904
  const scanResult = await inspect(snapshot.tempDirectory, {
7902
7905
  ...scanOptions,
@@ -7905,7 +7908,7 @@ const inspectAction = async (directory, flags) => {
7905
7908
  });
7906
7909
  const remappedDiagnostics = scanResult.diagnostics.map((diagnostic) => ({
7907
7910
  ...diagnostic,
7908
- filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
7911
+ filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, () => resolvedDirectory) : diagnostic.filePath
7909
7912
  }));
7910
7913
  finalizeScans({
7911
7914
  diagnostics: remappedDiagnostics,
@@ -7996,13 +7999,13 @@ const inspectAction = async (directory, flags) => {
7996
7999
  });
7997
8000
  const setupProjectRoot = resolveInstallSetupProjectRoot({
7998
8001
  scanRoot: resolvedDirectory,
7999
- completedScanDirectories: completedScans.map((scan) => scan.directory)
8002
+ scanDirectories: projectDirectories
8000
8003
  });
8001
8004
  if (setupProjectRoot !== null) {
8002
- const hasScoredScan = completedScans.some((scan) => scan.result.score !== null);
8005
+ const hasCompletedScan = completedScans.length > 0;
8003
8006
  await promptInstallSetup({
8004
8007
  projectRoot: setupProjectRoot,
8005
- hasScoredScan,
8008
+ hasCompletedScan,
8006
8009
  issueCount: filterDiagnosticsForSurface(allDiagnostics, scanOptions.outputSurface ?? "cli", userConfig).length,
8007
8010
  isJsonMode,
8008
8011
  isScoreOnly,
@@ -8011,7 +8014,7 @@ const inspectAction = async (directory, flags) => {
8011
8014
  });
8012
8015
  if (shouldShowAgentInstallHint({
8013
8016
  projectRoot: setupProjectRoot,
8014
- hasScoredScan,
8017
+ hasCompletedScan,
8015
8018
  isJsonMode,
8016
8019
  isScoreOnly,
8017
8020
  isStaged: Boolean(flags.staged)
@@ -8643,8 +8646,12 @@ const installReactDoctorGitHook = (options) => {
8643
8646
  return installDirectGitHook(options);
8644
8647
  };
8645
8648
  //#endregion
8646
- //#region src/cli/utils/install-skill.ts
8647
- var install_skill_exports = /* @__PURE__ */ __exportAll({ runInstallSkill: () => runInstallSkill });
8649
+ //#region src/cli/utils/install-react-doctor.ts
8650
+ var install_react_doctor_exports = /* @__PURE__ */ __exportAll({ runInstallReactDoctor: () => runInstallReactDoctor });
8651
+ const SETUP_OPTION_GIT_HOOK = "git-hook";
8652
+ const SETUP_OPTION_AGENT_HOOKS = "agent-hooks";
8653
+ const SETUP_OPTION_WORKFLOW = "workflow";
8654
+ const SETUP_OPTION_SKIP = "skip";
8648
8655
  const CONFIG_ONLY_GIT_HOOK_KINDS = new Set([
8649
8656
  "ghooks",
8650
8657
  "git-hooks-js",
@@ -8825,7 +8832,30 @@ const getSkillSourceDirectory = () => {
8825
8832
  const distDirectory = path.dirname(fileURLToPath(import.meta.url));
8826
8833
  return path.join(distDirectory, "skills", SKILL_NAME);
8827
8834
  };
8828
- const runInstallSkill = async (options = {}) => {
8835
+ const canInstallNativeAgentHooks = (agents) => agents.some((agent) => agent === "claude-code" || agent === "cursor");
8836
+ const buildWorkflowContent = () => [
8837
+ "name: React Doctor",
8838
+ "",
8839
+ "on:",
8840
+ " pull_request:",
8841
+ " branches: [main]",
8842
+ "",
8843
+ "permissions:",
8844
+ " contents: read",
8845
+ " pull-requests: write",
8846
+ "",
8847
+ "jobs:",
8848
+ " react-doctor:",
8849
+ " runs-on: ubuntu-latest",
8850
+ " steps:",
8851
+ " - uses: actions/checkout@v4",
8852
+ " - uses: millionco/react-doctor@main",
8853
+ " with:",
8854
+ " github-token: ${{ secrets.GITHUB_TOKEN }}",
8855
+ " diff: main",
8856
+ ""
8857
+ ].join("\n");
8858
+ const runInstallReactDoctor = async (options = {}) => {
8829
8859
  const requestedProjectRoot = options.projectRoot ?? process.cwd();
8830
8860
  const projectRoot = findNearestPackageDirectory(requestedProjectRoot) ?? requestedProjectRoot;
8831
8861
  const sourceDir = options.sourceDir ?? getSkillSourceDirectory();
@@ -8846,7 +8876,8 @@ const runInstallSkill = async (options = {}) => {
8846
8876
  const gitHookTarget = options.gitHookPath === void 0 ? detectGitHookTarget(projectRoot) : options.gitHookPath === null ? null : buildManualGitHookTarget(options.gitHookPath, projectRoot);
8847
8877
  const gitHookPath = gitHookTarget?.hookPath;
8848
8878
  const promptOptions = options.onPromptCancel === void 0 ? {} : { onCancel: options.onPromptCancel };
8849
- const selectedAgents = skipPrompts ? detectedAgents : (await prompts({
8879
+ const prompt = options.prompt ?? prompts;
8880
+ const selectedAgents = skipPrompts ? detectedAgents : (await prompt({
8850
8881
  type: "multiselect",
8851
8882
  name: "agents",
8852
8883
  message: `Install the ${highlighter.info(`/react-doctor`)} skill for:`,
@@ -8859,13 +8890,48 @@ const runInstallSkill = async (options = {}) => {
8859
8890
  min: 1
8860
8891
  }, promptOptions)).agents ?? [];
8861
8892
  if (selectedAgents.length === 0) return;
8862
- const shouldInstallGitHook = gitHookPath !== null && gitHookPath !== void 0 && (Boolean(options.yes) || !skipPrompts && Boolean((await prompts({
8863
- type: "confirm",
8864
- name: "installGitHook",
8865
- message: "Check for issues before each commit?",
8866
- initial: true
8867
- }, promptOptions)).installGitHook));
8868
- const shouldInstallAgentHooks = Boolean(options.agentHooks);
8893
+ const workflowsDirectory = path.join(projectRoot, ".github", "workflows");
8894
+ const workflowTargetPath = path.join(workflowsDirectory, "react-doctor.yml");
8895
+ const hasExistingWorkflows = existsSync(workflowsDirectory);
8896
+ const canInstallWorkflow = !existsSync(workflowTargetPath);
8897
+ const setupActionChoices = [
8898
+ ...gitHookPath === null || gitHookPath === void 0 ? [] : [{
8899
+ title: "Pre-commit hook",
8900
+ description: "Check staged changes before each commit",
8901
+ value: SETUP_OPTION_GIT_HOOK,
8902
+ selected: true
8903
+ }],
8904
+ ...canInstallNativeAgentHooks(selectedAgents) ? [{
8905
+ title: "Agent hooks",
8906
+ description: "Ask Claude Code or Cursor to scan after code edits",
8907
+ value: SETUP_OPTION_AGENT_HOOKS,
8908
+ selected: Boolean(options.agentHooks)
8909
+ }] : [],
8910
+ ...canInstallWorkflow ? [{
8911
+ title: "GitHub Actions workflow",
8912
+ description: "Scan pull requests in CI",
8913
+ value: SETUP_OPTION_WORKFLOW,
8914
+ selected: hasExistingWorkflows
8915
+ }] : []
8916
+ ];
8917
+ const setupChoices = setupActionChoices.length === 0 ? [] : [{
8918
+ title: "Skip optional setup",
8919
+ description: "Install only the agent skill and package setup",
8920
+ value: SETUP_OPTION_SKIP,
8921
+ selected: false
8922
+ }, ...setupActionChoices];
8923
+ const selectedSetupOptions = skipPrompts || setupChoices.length === 0 ? [] : (await prompt({
8924
+ type: "multiselect",
8925
+ name: "setupOptions",
8926
+ message: "Select additional React Doctor setup:",
8927
+ choices: setupChoices,
8928
+ instructions: false
8929
+ }, promptOptions)).setupOptions ?? [];
8930
+ const selectedSetupActions = selectedSetupOptions.filter((setupOption) => setupOption !== SETUP_OPTION_SKIP);
8931
+ const didSkipOptionalSetup = selectedSetupActions.length === 0 && selectedSetupOptions.includes(SETUP_OPTION_SKIP);
8932
+ const shouldInstallGitHook = gitHookPath !== null && gitHookPath !== void 0 && (Boolean(options.yes) || !didSkipOptionalSetup && selectedSetupActions.includes(SETUP_OPTION_GIT_HOOK));
8933
+ const shouldInstallAgentHooks = Boolean(options.agentHooks) || !didSkipOptionalSetup && selectedSetupActions.includes(SETUP_OPTION_AGENT_HOOKS);
8934
+ const shouldInstallWorkflow = !skipPrompts && !didSkipOptionalSetup && canInstallWorkflow && selectedSetupActions.includes(SETUP_OPTION_WORKFLOW);
8869
8935
  if (options.dryRun) {
8870
8936
  cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
8871
8937
  for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
@@ -8874,6 +8940,7 @@ const runInstallSkill = async (options = {}) => {
8874
8940
  cliLogger.dim(" Dev dependency: react-doctor");
8875
8941
  if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
8876
8942
  if (shouldInstallAgentHooks) cliLogger.dim(" Agent hooks: Claude Code / Cursor when selected");
8943
+ if (shouldInstallWorkflow) cliLogger.dim(` GitHub Actions workflow: ${path.relative(projectRoot, workflowTargetPath)}`);
8877
8944
  return;
8878
8945
  }
8879
8946
  const installSpinner = spinner(`Installing ${SKILL_NAME} skill...`).start();
@@ -8921,47 +8988,15 @@ const runInstallSkill = async (options = {}) => {
8921
8988
  throw error;
8922
8989
  }
8923
8990
  }
8924
- const workflowsDirectory = path.join(projectRoot, ".github", "workflows");
8925
- const workflowTargetPath = path.join(workflowsDirectory, "react-doctor.yml");
8926
- if (!existsSync(workflowTargetPath) && !skipPrompts) {
8927
- const hasExistingWorkflows = existsSync(workflowsDirectory);
8928
- const { shouldInstallWorkflow } = await prompts({
8929
- type: "confirm",
8930
- name: "shouldInstallWorkflow",
8931
- message: "Add a GitHub Actions workflow to scan PRs?",
8932
- initial: hasExistingWorkflows
8933
- }, promptOptions);
8934
- if (shouldInstallWorkflow) {
8935
- if (!hasExistingWorkflows) mkdirSync(workflowsDirectory, { recursive: true });
8936
- const workflowSpinner = spinner("Adding GitHub Actions workflow...").start();
8937
- try {
8938
- writeFileSync(workflowTargetPath, [
8939
- "name: React Doctor",
8940
- "",
8941
- "on:",
8942
- " pull_request:",
8943
- " branches: [main]",
8944
- "",
8945
- "permissions:",
8946
- " contents: read",
8947
- " pull-requests: write",
8948
- "",
8949
- "jobs:",
8950
- " react-doctor:",
8951
- " runs-on: ubuntu-latest",
8952
- " steps:",
8953
- " - uses: actions/checkout@v4",
8954
- " - uses: millionco/react-doctor@main",
8955
- " with:",
8956
- " github-token: ${{ secrets.GITHUB_TOKEN }}",
8957
- " diff: main",
8958
- ""
8959
- ].join("\n"));
8960
- workflowSpinner.succeed(`GitHub Actions workflow added at ${path.relative(projectRoot, workflowTargetPath)}.`);
8961
- } catch (error) {
8962
- workflowSpinner.fail("Failed to add GitHub Actions workflow.");
8963
- throw error;
8964
- }
8991
+ if (shouldInstallWorkflow) {
8992
+ if (!hasExistingWorkflows) mkdirSync(workflowsDirectory, { recursive: true });
8993
+ const workflowSpinner = spinner("Adding GitHub Actions workflow...").start();
8994
+ try {
8995
+ writeFileSync(workflowTargetPath, buildWorkflowContent());
8996
+ workflowSpinner.succeed(`GitHub Actions workflow added at ${path.relative(projectRoot, workflowTargetPath)}.`);
8997
+ } catch (error) {
8998
+ workflowSpinner.fail("Failed to add GitHub Actions workflow.");
8999
+ throw error;
8965
9000
  }
8966
9001
  }
8967
9002
  };
@@ -8971,7 +9006,7 @@ const installAction = async (options, command) => {
8971
9006
  Effect.runSync(printBrandedHeader);
8972
9007
  try {
8973
9008
  const parentOptions = command?.parent?.opts?.();
8974
- await runInstallSkill({
9009
+ await runInstallReactDoctor({
8975
9010
  yes: options.yes ?? parentOptions?.yes,
8976
9011
  dryRun: options.dryRun,
8977
9012
  agentHooks: options.agentHooks,
@@ -9111,7 +9146,7 @@ const stripUnknownCliFlags = (argv) => {
9111
9146
  //#region src/cli/index.ts
9112
9147
  process.on("SIGINT", exitGracefully);
9113
9148
  process.on("SIGTERM", exitGracefully);
9114
- 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("--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("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--no-score", "skip the score API and the share URL").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none (default: error)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--pr-comment", "tune CLI output for sticky PR comments (drops weak-signal rule families like `design` from the printed list and the fail-on gate; configure via config.surfaces)").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "alias for --explain").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").addHelpText("after", `
9149
+ 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("--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("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--no-score", "skip the score API and the share URL").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none (default: none)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--pr-comment", "tune CLI output for sticky PR comments (drops weak-signal rule families like `design` from the printed list and the fail-on gate; configure via config.surfaces)").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "alias for --explain").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").addHelpText("after", `
9115
9150
  ${highlighter.dim("Configuration:")}
9116
9151
  Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
9117
9152
  CLI flags always override config values. See the README for the full schema.
package/dist/index.d.ts CHANGED
@@ -232,7 +232,9 @@ interface ReactDoctorConfig {
232
232
  * `categories` field, but keyed by React Doctor's display
233
233
  * categories (`"Server"`, `"React Native"`, `"Architecture"`,
234
234
  * `"Bundle Size"`, `"State & Effects"`, `"Security"`,
235
- * `"Accessibility"`, `"Performance"`, `"Correctness"`, …).
235
+ * `"Accessibility"`, `"Performance"`, `"Correctness"`,
236
+ * `"Next.js"`, `"Preact"`, `"TanStack Query"`,
237
+ * `"TanStack Start"`, …).
236
238
  *
237
239
  * ```json
238
240
  * { "categories": { "React Native": "warn", "Server": "off" } }
@@ -296,7 +298,7 @@ interface Diagnostic {
296
298
  }
297
299
  //#endregion
298
300
  //#region src/types/project-info.d.ts
299
- type Framework = "nextjs" | "vite" | "cra" | "remix" | "gatsby" | "expo" | "react-native" | "tanstack-start" | "unknown";
301
+ type Framework = "nextjs" | "vite" | "cra" | "remix" | "gatsby" | "expo" | "react-native" | "tanstack-start" | "preact" | "unknown";
300
302
  interface ProjectInfo {
301
303
  rootDirectory: string;
302
304
  projectName: string;
@@ -307,6 +309,18 @@ interface ProjectInfo {
307
309
  hasTypeScript: boolean;
308
310
  hasReactCompiler: boolean;
309
311
  hasTanStackQuery: boolean;
312
+ /**
313
+ * The declared `preact` version spec, or `null` when Preact isn't a
314
+ * dependency. Parallels `reactVersion` so a React-compatible runtime is
315
+ * modeled the same way React is. Drives the `preact` capability in
316
+ * `buildCapabilities` (which gates every `preact-*` rule) — keyed off
317
+ * this rather than `framework` because the dominant Preact setup
318
+ * (Preact-on-Vite) classifies as `framework: "vite"` but still needs
319
+ * Preact rules to fire.
320
+ */
321
+ preactVersion: string | null;
322
+ /** Parsed major from `preactVersion`, or `null` when absent/unparseable. Mirrors `reactMajorVersion`. */
323
+ preactMajorVersion: number | null;
310
324
  /**
311
325
  * `true` when the project (or any of its workspace packages) declares
312
326
  * React Native or Expo as a dependency. Enables the `react-native`
@@ -321,6 +335,13 @@ interface ProjectInfo {
321
335
  * — no `rn-*` rules load for the project at all.
322
336
  */
323
337
  hasReactNativeWorkspace: boolean;
338
+ /**
339
+ * `true` when the project (or any of its workspace packages) declares
340
+ * `react-native-reanimated`. Lets diagnostics surface reanimated's
341
+ * Compiler-compatible `.get()` / `.set()` accessors only where they
342
+ * apply, instead of on every React Native project.
343
+ */
344
+ hasReanimated: boolean;
324
345
  sourceFileCount: number;
325
346
  }
326
347
  //#endregion