react-doctor 0.2.6 → 0.2.7

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,13 +1,12 @@
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 formatReactDoctorError, B as toRelativePath, C as buildJsonReportError, D as filterSourceFiles, E as filterDiagnosticsForSurface, F as layerOtlp, I as listWorkspacePackages, L as loadConfigWithSource, M as groupBy, N as highlighter, O as formatErrorChain, P as isReactDoctorError, R as resolveConfigRootDir, S as buildJsonReport, T as discoverReactSubprojects, _ as Reporter, a as Config, b as Score, c as Git, d as MILLISECONDS_PER_SECOND, f as NoReactDependencyError, g as ProjectNotFoundError, h as Project, i as CANONICAL_GITHUB_URL, j as getDiffInfo, k as formatFrameworkName, l as LintPartialFailures, m as OXLINT_NODE_REQUIREMENT, o as DeadCode, p as NodeResolver, r as AmbiguousProjectError, s as Files, t as cliLogger, u as Linter, v as SHARE_BASE_URL, w as calculateScore, x as StagedFiles, y as SKILL_NAME, z as runInspect } from "./cli-logger-Iz5pfDnL.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-C35LXalM.js";
3
3
  import { createRequire } from "node:module";
4
- import { execFileSync } from "node:child_process";
4
+ import { execFileSync, execSync } from "node:child_process";
5
5
  import path, { join } from "node:path";
6
6
  import { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
7
7
  import process$1 from "node:process";
8
8
  import * as Effect from "effect/Effect";
9
9
  import * as Layer from "effect/Layer";
10
- import * as Ref from "effect/Ref";
11
10
  import * as Console from "effect/Console";
12
11
  import os, { tmpdir } from "node:os";
13
12
  import { performance } from "node:perf_hooks";
@@ -3029,256 +3028,6 @@ const { program: program$1, createCommand, createArgument, createOption, Command
3029
3028
  exports.InvalidOptionArgumentError = InvalidArgumentError;
3030
3029
  })))(), 1)).default;
3031
3030
  //#endregion
3032
- //#region src/cli/utils/build-runtime-layers.ts
3033
- /**
3034
- * Composes the production layer stack for `inspect()`'s
3035
- * `Effect.runPromise(Effect.provide(...))` call. Lives outside
3036
- * `inspect.ts` so the orchestrator stays focused on Effect program
3037
- * construction and post-scan rendering — layer wiring is its own
3038
- * concern with its own contract.
3039
- *
3040
- * Same shape as `core/src/run-inspect.ts → layerInspectLive`
3041
- * (the default for `@react-doctor/api → diagnose()`) with two
3042
- * differences specific to the CLI path:
3043
- *
3044
- * - **Config**: when the caller passes `configOverride`, the
3045
- * already-loaded config is provided via `Config.layerOf` instead
3046
- * of re-loading from disk; `configSourceDirectory` is threaded
3047
- * through so `userConfig.plugins` resolution still anchors at
3048
- * the original config file location.
3049
- * - **Score**: always `layerOf(null)` because the CLI computes the
3050
- * real score AFTER `runInspect` returns, with surface filtering
3051
- * applied (the orchestrator's `Score.compute` only sees the
3052
- * per-element-filtered list, not the surface-filtered one).
3053
- */
3054
- const buildRuntimeLayers = (input) => {
3055
- const linterLayer = input.shouldSkipLint ? Linter.layerOf([]) : Linter.layerOxlint;
3056
- const deadCodeLayer = input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]);
3057
- const scoreLayer = Score.layerOf(null);
3058
- const configLayer = input.hasConfigOverride ? Config.layerOf({
3059
- config: input.userConfig,
3060
- resolvedDirectory: input.directory,
3061
- configSourceDirectory: input.configSourceDirectory
3062
- }) : Config.layerNode;
3063
- return Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, Reporter.layerNoop, scoreLayer);
3064
- };
3065
- //#endregion
3066
- //#region src/cli/utils/build-hidden-diagnostics-summary.ts
3067
- const buildHiddenDiagnosticsSummary = (hiddenDiagnostics) => {
3068
- const errorCount = hiddenDiagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
3069
- const warningCount = hiddenDiagnostics.length - errorCount;
3070
- const parts = [];
3071
- if (errorCount > 0) parts.push({
3072
- severity: "error",
3073
- count: errorCount,
3074
- text: `✗ ${errorCount} more error${errorCount === 1 ? "" : "s"}`
3075
- });
3076
- if (warningCount > 0) parts.push({
3077
- severity: "warning",
3078
- count: warningCount,
3079
- text: `⚠ ${warningCount} more warning${warningCount === 1 ? "" : "s"}`
3080
- });
3081
- return parts;
3082
- };
3083
- //#endregion
3084
- //#region src/cli/utils/indent-multiline-text.ts
3085
- const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
3086
- //#endregion
3087
- //#region src/cli/utils/wrap-indented-text.ts
3088
- const wrapLine = (lineText, contentWidth) => {
3089
- if (lineText.length <= contentWidth) return [lineText];
3090
- const wrappedLines = [];
3091
- let remainingText = lineText.trim();
3092
- while (remainingText.length > contentWidth) {
3093
- const candidateText = remainingText.slice(0, contentWidth);
3094
- const breakIndex = candidateText.lastIndexOf(" ");
3095
- if (breakIndex <= 0) {
3096
- wrappedLines.push(candidateText);
3097
- remainingText = remainingText.slice(contentWidth).trimStart();
3098
- continue;
3099
- }
3100
- wrappedLines.push(remainingText.slice(0, breakIndex));
3101
- remainingText = remainingText.slice(breakIndex + 1).trimStart();
3102
- }
3103
- if (remainingText.length > 0) wrappedLines.push(remainingText);
3104
- return wrappedLines;
3105
- };
3106
- const wrapIndentedText = (text, linePrefix, width) => {
3107
- const contentWidth = width - linePrefix.length;
3108
- if (contentWidth <= 0) return indentMultilineText(text, linePrefix);
3109
- return text.split("\n").flatMap((lineText) => wrapLine(lineText, contentWidth)).map((lineText) => `${linePrefix}${lineText}`).join("\n");
3110
- };
3111
- //#endregion
3112
- //#region src/cli/utils/render-diagnostics.ts
3113
- const SEVERITY_ORDER = {
3114
- error: 0,
3115
- warning: 1
3116
- };
3117
- const colorizeBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
3118
- const sortByImportance = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
3119
- const severityDelta = SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
3120
- if (severityDelta !== 0) return severityDelta;
3121
- return diagnosticsB.length - diagnosticsA.length;
3122
- });
3123
- const collectAffectedFiles = (diagnostics) => new Set(diagnostics.map((diagnostic) => diagnostic.filePath));
3124
- const buildVerboseSiteMap = (diagnostics) => {
3125
- const fileSites = /* @__PURE__ */ new Map();
3126
- for (const diagnostic of diagnostics) {
3127
- const sites = fileSites.get(diagnostic.filePath) ?? [];
3128
- if (diagnostic.line > 0) sites.push({
3129
- line: diagnostic.line,
3130
- suppressionHint: diagnostic.suppressionHint
3131
- });
3132
- fileSites.set(diagnostic.filePath, sites);
3133
- }
3134
- return fileSites;
3135
- };
3136
- const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
3137
- const formatIssueCount = (count) => `${count} ${count === 1 ? "issue" : "issues"}`;
3138
- const toRuleTitle = (ruleName) => {
3139
- const readableRuleName = ruleName.replace(/^(no|prefer|require|use)-/, "").replace(/^(nextjs|tanstack-start)-/, "").replaceAll("-", " ");
3140
- return (readableRuleName.charAt(0).toUpperCase() + readableRuleName.slice(1)).replace(/\b(css|html|url|svg|jsx|api|ua)\b/gi, (match) => match.toUpperCase());
3141
- };
3142
- const computeRuleNameColumnWidth = (ruleKeys) => {
3143
- const longestRuleNameLength = ruleKeys.reduce((longest, ruleKey) => Math.max(longest, ruleKey.length), 0);
3144
- return Math.max(36, longestRuleNameLength);
3145
- };
3146
- const padRuleNameToColumn = (ruleName, columnWidth) => {
3147
- if (ruleName.length >= columnWidth) return ruleName;
3148
- return ruleName + " ".repeat(columnWidth - ruleName.length);
3149
- };
3150
- const grayLine = (text) => highlighter.gray(text);
3151
- const grayWrappedLine = (text, linePrefix) => grayLine(wrapIndentedText(text, linePrefix, 88));
3152
- const buildCompactRuleGroupLine = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
3153
- const firstDiagnostic = ruleDiagnostics[0];
3154
- const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
3155
- const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
3156
- return ` ${icon} ${siteCountBadge.length > 0 ? colorizeBySeverity(padRuleNameToColumn(ruleKey, ruleNameColumnWidth), firstDiagnostic.severity) : colorizeBySeverity(ruleKey, firstDiagnostic.severity)}${siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : ""}`;
3157
- };
3158
- const getWorstSeverity = (diagnostics) => diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "error" : "warning";
3159
- const buildCategoryDiagnosticGroups = (diagnostics) => {
3160
- return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
3161
- return {
3162
- category,
3163
- diagnostics: categoryDiagnostics,
3164
- ruleGroups: sortByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()])
3165
- };
3166
- }).toSorted((categoryGroupA, categoryGroupB) => {
3167
- const severityDelta = SEVERITY_ORDER[getWorstSeverity(categoryGroupA.diagnostics)] - SEVERITY_ORDER[getWorstSeverity(categoryGroupB.diagnostics)];
3168
- if (severityDelta !== 0) return severityDelta;
3169
- if (categoryGroupA.diagnostics.length !== categoryGroupB.diagnostics.length) return categoryGroupB.diagnostics.length - categoryGroupA.diagnostics.length;
3170
- return categoryGroupA.category.localeCompare(categoryGroupB.category);
3171
- });
3172
- };
3173
- const buildDefaultRuleGroupLines = (ruleKey, ruleDiagnostics, rootDirectory) => {
3174
- const firstDiagnostic = ruleDiagnostics[0];
3175
- const ruleTitle = toRuleTitle(firstDiagnostic.rule);
3176
- const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
3177
- const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
3178
- const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
3179
- const lines = [];
3180
- lines.push(` ${icon} ${ruleTitle}${trailingBadge}`);
3181
- lines.push(grayWrappedLine(firstDiagnostic.message, " "));
3182
- if (firstDiagnostic.help) lines.push(grayWrappedLine(firstDiagnostic.help, " "));
3183
- if (firstDiagnostic.url) lines.push(grayLine(` ${firstDiagnostic.url}`));
3184
- const firstLocation = ruleDiagnostics.find((diagnostic) => diagnostic.line > 0);
3185
- if (firstLocation) {
3186
- const locationPath = toRelativePath(firstLocation.filePath, rootDirectory);
3187
- lines.push(grayLine(` ${locationPath}:${firstLocation.line}`));
3188
- }
3189
- return lines;
3190
- };
3191
- const buildDefaultCategoryGroupLines = (categoryGroup, visibleRuleGroups, rootDirectory) => {
3192
- const issueCount = formatIssueCount(categoryGroup.diagnostics.length);
3193
- const lines = [`${highlighter.bold(categoryGroup.category)} ${highlighter.dim(issueCount)}`];
3194
- for (const [ruleKey, ruleDiagnostics] of visibleRuleGroups) lines.push(...buildDefaultRuleGroupLines(ruleKey, ruleDiagnostics, rootDirectory));
3195
- lines.push("");
3196
- return lines;
3197
- };
3198
- const buildVerboseRuleGroupLines = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
3199
- const lines = [];
3200
- lines.push(buildCompactRuleGroupLine(ruleKey, ruleDiagnostics, ruleNameColumnWidth));
3201
- const firstDiagnostic = ruleDiagnostics[0];
3202
- lines.push(grayLine(indentMultilineText(firstDiagnostic.message, " ")));
3203
- if (firstDiagnostic.help) lines.push(grayLine(indentMultilineText(`→ ${firstDiagnostic.help}`, " ")));
3204
- const fileSites = buildVerboseSiteMap(ruleDiagnostics);
3205
- for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
3206
- lines.push(grayLine(` ${filePath}:${site.line}`));
3207
- if (site.suppressionHint) lines.push(grayLine(` ↳ ${site.suppressionHint}`));
3208
- }
3209
- else lines.push(grayLine(` ${filePath}`));
3210
- lines.push("");
3211
- return lines;
3212
- };
3213
- const buildHiddenDiagnosticsLines = (hiddenRuleGroups) => {
3214
- return [
3215
- ` ${buildHiddenDiagnosticsSummary(hiddenRuleGroups.flatMap(([, ruleDiagnostics]) => ruleDiagnostics)).map((part) => {
3216
- const [icon, ...labelParts] = part.text.split(" ");
3217
- return `${colorizeBySeverity(icon, part.severity)} ${highlighter.dim(labelParts.join(" "))}`;
3218
- }).join(" ")}`,
3219
- grayLine(" Run `npx react-doctor@latest . --verbose` to get all details"),
3220
- ""
3221
- ];
3222
- };
3223
- const buildDefaultDiagnosticsLines = (diagnostics, rootDirectory) => {
3224
- const categoryGroups = buildCategoryDiagnosticGroups(diagnostics);
3225
- const hiddenRuleGroups = [];
3226
- const visibleCategoryGroups = categoryGroups.slice(0, 5);
3227
- const hiddenCategoryGroups = categoryGroups.slice(5);
3228
- const lines = [];
3229
- for (const categoryGroup of visibleCategoryGroups) {
3230
- const visibleRuleGroups = categoryGroup.ruleGroups.slice(0, 3);
3231
- const remainingRuleGroups = categoryGroup.ruleGroups.slice(3);
3232
- lines.push(...buildDefaultCategoryGroupLines(categoryGroup, visibleRuleGroups, rootDirectory));
3233
- hiddenRuleGroups.push(...remainingRuleGroups);
3234
- }
3235
- hiddenRuleGroups.push(...hiddenCategoryGroups.flatMap((categoryGroup) => categoryGroup.ruleGroups));
3236
- if (hiddenRuleGroups.length > 0) lines.push(...buildHiddenDiagnosticsLines(hiddenRuleGroups));
3237
- return lines;
3238
- };
3239
- /**
3240
- * Effect-typed diagnostics renderer. Internal helpers build the
3241
- * line array purely; the IO happens once at the boundary with a
3242
- * single Effect.forEach over Console.log so failures or fiber
3243
- * interruption produce predictable partial output.
3244
- */
3245
- const printDiagnostics = (diagnostics, isVerbose, rootDirectory) => Effect.gen(function* () {
3246
- let lines;
3247
- if (!isVerbose) lines = buildDefaultDiagnosticsLines(diagnostics, rootDirectory);
3248
- else {
3249
- const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
3250
- const ruleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.map(([ruleKey]) => ruleKey));
3251
- lines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => buildVerboseRuleGroupLines(ruleKey, ruleDiagnostics, ruleNameColumnWidth));
3252
- }
3253
- for (const line of lines) yield* Console.log(line);
3254
- });
3255
- const formatElapsedTime = (elapsedMilliseconds) => {
3256
- if (elapsedMilliseconds < 1e3) return `${Math.round(elapsedMilliseconds)}ms`;
3257
- return `${(elapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1)}s`;
3258
- };
3259
- const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
3260
- const firstDiagnostic = ruleDiagnostics[0];
3261
- const sections = [
3262
- `Rule: ${ruleKey}`,
3263
- `Severity: ${firstDiagnostic.severity}`,
3264
- `Category: ${firstDiagnostic.category}`,
3265
- `Count: ${ruleDiagnostics.length}`,
3266
- "",
3267
- firstDiagnostic.message
3268
- ];
3269
- if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
3270
- if (firstDiagnostic.url) sections.push("", `Docs: ${firstDiagnostic.url}`);
3271
- sections.push("", "Files:");
3272
- const fileSites = buildVerboseSiteMap(ruleDiagnostics);
3273
- for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
3274
- sections.push(` ${filePath}:${site.line}`);
3275
- if (site.suppressionHint) sections.push(` ${site.suppressionHint}`);
3276
- }
3277
- else sections.push(` ${filePath}`);
3278
- return sections.join("\n") + "\n";
3279
- };
3280
- const sortRuleGroupsByImportance = sortByImportance;
3281
- //#endregion
3282
3031
  //#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
3283
3032
  const ANSI_BACKGROUND_OFFSET = 10;
3284
3033
  const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
@@ -6226,6 +5975,31 @@ function ora(options) {
6226
5975
  return new Ora(options);
6227
5976
  }
6228
5977
  //#endregion
5978
+ //#region src/cli/utils/is-ci-environment.ts
5979
+ const CI_ENVIRONMENT_VARIABLES = [
5980
+ "GITHUB_ACTIONS",
5981
+ "GITLAB_CI",
5982
+ "CIRCLECI"
5983
+ ];
5984
+ const CODING_AGENT_ENVIRONMENT_VARIABLES = [
5985
+ "CLAUDECODE",
5986
+ "CLAUDE_CODE",
5987
+ "CURSOR_AGENT",
5988
+ "CODEX_CI",
5989
+ "CODEX_SANDBOX",
5990
+ "CODEX_SANDBOX_NETWORK_DISABLED",
5991
+ "OPENCODE",
5992
+ "GOOSE_TERMINAL",
5993
+ "AGENT_SESSION_ID",
5994
+ "AMP_THREAD_ID",
5995
+ "AGENT_THREAD_ID"
5996
+ ];
5997
+ const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
5998
+ const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
5999
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
6000
+ const isCodingAgentEnvironment = () => CODING_AGENT_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES.some((envVariable) => CODING_AGENT_ENVIRONMENT_VALUES[envVariable].some((value) => process.env[envVariable]?.toLowerCase() === value));
6001
+ const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
6002
+ //#endregion
6229
6003
  //#region src/cli/utils/is-non-interactive-environment.ts
6230
6004
  const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
6231
6005
  "CI",
@@ -6240,15 +6014,9 @@ const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
6240
6014
  "CIRCLECI",
6241
6015
  "TRAVIS",
6242
6016
  "DRONE",
6243
- "CLAUDECODE",
6244
- "CLAUDE_CODE",
6245
- "CURSOR_AGENT",
6246
- "CODEX_CI",
6247
- "OPENCODE",
6248
- "AMP_HOME",
6249
6017
  "GIT_DIR"
6250
6018
  ];
6251
- const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
6019
+ const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || isCodingAgentEnvironment();
6252
6020
  //#endregion
6253
6021
  //#region src/cli/utils/is-spinner-interactive.ts
6254
6022
  const isSpinnerInteractive = (stream = process.stderr) => {
@@ -6267,6 +6035,7 @@ const setSpinnerSilent = (silent) => {
6267
6035
  };
6268
6036
  const isSpinnerSilent = () => isSilent;
6269
6037
  const noopHandle = Object.freeze({
6038
+ update: () => {},
6270
6039
  succeed: () => {},
6271
6040
  fail: () => {}
6272
6041
  });
@@ -6283,6 +6052,10 @@ const spinner = (text) => ({ start() {
6283
6052
  if (isEnabled) instance.start();
6284
6053
  let didFinalize = false;
6285
6054
  return {
6055
+ update(displayText) {
6056
+ if (didFinalize) return;
6057
+ instance.text = displayText;
6058
+ },
6286
6059
  succeed(displayText) {
6287
6060
  if (didFinalize) return;
6288
6061
  didFinalize = true;
@@ -6296,85 +6069,331 @@ const spinner = (text) => ({ start() {
6296
6069
  };
6297
6070
  } });
6298
6071
  //#endregion
6299
- //#region src/cli/utils/render-project-detection.ts
6072
+ //#region src/cli/utils/build-runtime-layers.ts
6300
6073
  /**
6301
- * Each "completed step" is rendered by ora's `succeed` (writes a
6302
- * green + the supplied label) — the ora handle is created, started,
6303
- * and immediately succeeded so the user sees a static checklist
6304
- * rather than a spinning frame for steps that finish synchronously.
6305
- * The wrapping `Effect.sync` keeps the imperative IO inside the
6306
- * Effect graph so cancellation / Console swap behave consistently
6307
- * with the rest of the renderer.
6074
+ * Adapts the CLI's existing `spinner()` helper (an ora wrapper that
6075
+ * already handles non-interactive demotion + `setSpinnerSilent`) into
6076
+ * a `ProgressHandle` factory the orchestrator can drive via the
6077
+ * `Progress` service.
6308
6078
  */
6309
- const completeStep = (message) => Effect.sync(() => {
6310
- spinner(message).start().succeed(message);
6079
+ const buildSpinnerProgressHandle = (text) => {
6080
+ const oraHandle = spinner(text).start();
6081
+ return {
6082
+ update: (displayText) => Effect.sync(() => oraHandle.update(displayText)),
6083
+ succeed: (displayText) => Effect.sync(() => oraHandle.succeed(displayText)),
6084
+ fail: (displayText) => Effect.sync(() => oraHandle.fail(displayText))
6085
+ };
6086
+ };
6087
+ /**
6088
+ * Composes the production layer stack for `inspect()`'s
6089
+ * `Effect.runPromise(Effect.provide(...))` call. Lives outside
6090
+ * `inspect.ts` so the orchestrator stays focused on Effect program
6091
+ * construction and post-scan rendering — layer wiring is its own
6092
+ * concern with its own contract.
6093
+ *
6094
+ * Same shape as `core/src/run-inspect.ts → layerInspectLive`
6095
+ * (the default for `@react-doctor/api → diagnose()`) with the
6096
+ * differences specific to the CLI path:
6097
+ *
6098
+ * - **Config**: when the caller passes `configOverride`, the
6099
+ * already-loaded config is provided via `Config.layerOf` instead
6100
+ * of re-loading from disk; `configSourceDirectory` is threaded
6101
+ * through so `userConfig.plugins` resolution still anchors at
6102
+ * the original config file location.
6103
+ * - **Score**: `layerHttp` for normal runs; `layerOf(null)` only when
6104
+ * the caller passed `--no-score`. The orchestrator applies the
6105
+ * `"score"` surface filter to the diagnostic set before calling
6106
+ * `Score.compute`, so the in-band score matches what the public-API
6107
+ * contract documents.
6108
+ * - **Progress**: `layerOra` wired to the CLI's existing ora-backed
6109
+ * spinner helper for terminal feedback; `layerNoop` for silent /
6110
+ * score-only / lint-skipped runs.
6111
+ */
6112
+ const buildRuntimeLayers = (input) => {
6113
+ const linterLayer = input.shouldSkipLint ? Linter.layerOf([]) : Linter.layerOxlint;
6114
+ const deadCodeLayer = input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]);
6115
+ const scoreLayer = input.shouldComputeScore ? Score.layerHttp : Score.layerOf(null);
6116
+ const progressLayer = input.shouldShowProgressSpinners ? Progress.layerOra(buildSpinnerProgressHandle) : Progress.layerNoop;
6117
+ const configLayer = input.hasConfigOverride ? Config.layerOf({
6118
+ config: input.userConfig,
6119
+ resolvedDirectory: input.directory,
6120
+ configSourceDirectory: input.configSourceDirectory
6121
+ }) : Config.layerNode;
6122
+ return Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
6123
+ };
6124
+ //#endregion
6125
+ //#region src/cli/utils/noop-console.ts
6126
+ /**
6127
+ * A concrete `Console.Console` whose methods are all no-ops.
6128
+ *
6129
+ * Used by `--silent` (provided via
6130
+ * `Effect.provideService(Console.Console, makeNoopConsole())`) and by
6131
+ * `enableJsonMode` (assigned over the relevant slots on
6132
+ * `globalThis.console` so imperative legacy callsites that aren't
6133
+ * Effect-typed also fall silent). Sourcing both from a single concrete
6134
+ * object keeps "what is a no-op console" answered in one place; the
6135
+ * earlier `new Proxy({} as Console.Console, { get: () => () => undefined })`
6136
+ * combined a cast with a Proxy to do the same thing implicitly.
6137
+ *
6138
+ * The interface mirrors Effect v4's `Console.Console` shape exactly so
6139
+ * `Effect.provideService(Console.Console, makeNoopConsole())` requires
6140
+ * no cast.
6141
+ */
6142
+ const makeNoopConsole = () => ({
6143
+ assert: () => {},
6144
+ clear: () => {},
6145
+ count: () => {},
6146
+ countReset: () => {},
6147
+ debug: () => {},
6148
+ dir: () => {},
6149
+ dirxml: () => {},
6150
+ error: () => {},
6151
+ group: () => {},
6152
+ groupCollapsed: () => {},
6153
+ groupEnd: () => {},
6154
+ info: () => {},
6155
+ log: () => {},
6156
+ table: () => {},
6157
+ time: () => {},
6158
+ timeEnd: () => {},
6159
+ timeLog: () => {},
6160
+ trace: () => {},
6161
+ warn: () => {}
6311
6162
  });
6312
- const printProjectDetection = (input) => Effect.gen(function* () {
6313
- const frameworkLabel = formatFrameworkName(input.projectInfo.framework);
6314
- const languageLabel = input.projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
6315
- yield* completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
6316
- yield* completeStep(`Detecting React version. Found ${highlighter.info(`React ${input.projectInfo.reactVersion}`)}.`);
6317
- yield* completeStep(`Detecting Tailwind. ${input.projectInfo.tailwindVersion ? `Found ${highlighter.info(`Tailwind ${input.projectInfo.tailwindVersion}`)}.` : "Not found."}`);
6318
- yield* completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
6319
- yield* completeStep(`Detecting React Compiler. ${input.projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
6320
- if (input.isDiffMode) yield* completeStep(`Scanning ${highlighter.info(`${input.includePaths.length}`)} changed source files.`);
6321
- else yield* completeStep(`Found ${highlighter.info(`${input.lintSourceFileCount ?? input.projectInfo.sourceFileCount}`)} source files.`);
6322
- if (input.userConfig) yield* completeStep(`Loaded ${highlighter.info("react-doctor config")}.`);
6163
+ //#endregion
6164
+ //#region src/cli/utils/render-agent-guidance.ts
6165
+ const AGENT_GUIDANCE_LINES = [
6166
+ "Treat React Doctor diagnostics as starting hypotheses. Read the relevant code before confirming or suppressing each finding.",
6167
+ "For each group, decide true positive, false positive, or needs-human-review, then assign high/medium/low confidence.",
6168
+ "Do not suppress a finding without evidence from the file in question. Confidence requires code context.",
6169
+ "Understand the root cause before editing. Fix the underlying code instead of changing react-doctor config or suppressing rules unless explicitly asked.",
6170
+ "Investigate deeply where relevant: race conditions, security-sensitive flows, state propagation, multi-file refactors, and downstream dependency chains.",
6171
+ "Ignore pure style preferences, theoretical issues without real impact, missing features, and unrelated pre-existing code.",
6172
+ "Start with high-confidence fixes that preserve behavior. Leave low-confidence or product-dependent changes as notes.",
6173
+ "Run `npx react-doctor@latest --verbose --diff` before and after changes, plus relevant tests after each focused batch.",
6174
+ "When available, spawn subagents or isolated worktrees for independent rule families, then review and merge only the best safe fixes.",
6175
+ "Split unrelated, broad, or behavior-changing work into separate PRs/branches instead of one large cleanup.",
6176
+ "For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.",
6177
+ "If a fix needs an API, UX, or architecture decision, stop and ask before editing."
6178
+ ];
6179
+ const printAgentGuidance = () => Effect.gen(function* () {
6180
+ yield* Console.log(`${highlighter.bold("Agent guidance")}`);
6181
+ for (const line of AGENT_GUIDANCE_LINES) yield* Console.log(highlighter.gray(` - ${line}`));
6323
6182
  yield* Console.log("");
6324
6183
  });
6325
6184
  //#endregion
6326
- //#region src/cli/utils/colorize-by-score.ts
6327
- const colorizeByScore = (text, score) => {
6328
- if (score >= 75) return highlighter.success(text);
6329
- if (score >= 50) return highlighter.warn(text);
6330
- return highlighter.error(text);
6331
- };
6185
+ //#region src/cli/utils/indent-multiline-text.ts
6186
+ const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
6332
6187
  //#endregion
6333
- //#region src/cli/utils/render-score-header.ts
6334
- const buildScoreBarSegments = (score) => {
6335
- const filledCount = Math.round(score / 100 * 50);
6336
- const emptyCount = 50 - filledCount;
6337
- return {
6338
- filledSegment: "█".repeat(filledCount),
6339
- emptySegment: "░".repeat(emptyCount)
6340
- };
6341
- };
6342
- const buildScoreBar = (score) => {
6343
- const { filledSegment, emptySegment } = buildScoreBarSegments(score);
6344
- return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
6345
- };
6346
- const getDoctorFace = (score) => {
6347
- if (score >= 75) return ["◠ ◠", " ▽ "];
6348
- if (score >= 50) return ["• •", " ─ "];
6349
- return ["x x", " ▽ "];
6188
+ //#region src/cli/utils/render-diagnostics.ts
6189
+ const POINTER = isUnicodeSupported() ? "›" : ">";
6190
+ const SEVERITY_ORDER = {
6191
+ error: 0,
6192
+ warning: 1
6350
6193
  };
6351
- const BRANDING_LINE = `React Doctor ${highlighter.dim("(www.react.doctor)")}`;
6352
- const buildFaceRenderedLines = (score) => {
6353
- const [eyes, mouth] = getDoctorFace(score);
6354
- const colorize = (text) => colorizeByScore(text, score);
6355
- return [
6356
- "┌─────┐",
6194
+ const colorizeBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
6195
+ const sortByImportance = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
6196
+ const severityDelta = SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
6197
+ if (severityDelta !== 0) return severityDelta;
6198
+ return diagnosticsB.length - diagnosticsA.length;
6199
+ });
6200
+ const collectAffectedFiles = (diagnostics) => new Set(diagnostics.map((diagnostic) => diagnostic.filePath));
6201
+ const buildVerboseSiteMap = (diagnostics) => {
6202
+ const fileSites = /* @__PURE__ */ new Map();
6203
+ for (const diagnostic of diagnostics) {
6204
+ const sites = fileSites.get(diagnostic.filePath) ?? [];
6205
+ if (diagnostic.line > 0) sites.push({
6206
+ line: diagnostic.line,
6207
+ suppressionHint: diagnostic.suppressionHint
6208
+ });
6209
+ fileSites.set(diagnostic.filePath, sites);
6210
+ }
6211
+ return fileSites;
6212
+ };
6213
+ const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
6214
+ const computeRuleNameColumnWidth = (ruleKeys) => {
6215
+ const longestRuleNameLength = ruleKeys.reduce((longest, ruleKey) => Math.max(longest, ruleKey.length), 0);
6216
+ return Math.max(36, longestRuleNameLength);
6217
+ };
6218
+ const padRuleNameToColumn = (ruleName, columnWidth) => {
6219
+ if (ruleName.length >= columnWidth) return ruleName;
6220
+ return ruleName + " ".repeat(columnWidth - ruleName.length);
6221
+ };
6222
+ const grayLine = (text) => highlighter.gray(text);
6223
+ const buildCompactRuleGroupLine = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
6224
+ const firstDiagnostic = ruleDiagnostics[0];
6225
+ const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
6226
+ const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
6227
+ return ` ${icon} ${siteCountBadge.length > 0 ? colorizeBySeverity(padRuleNameToColumn(ruleKey, ruleNameColumnWidth), firstDiagnostic.severity) : colorizeBySeverity(ruleKey, firstDiagnostic.severity)}${siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : ""}`;
6228
+ };
6229
+ const getWorstSeverity = (diagnostics) => diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "error" : "warning";
6230
+ const buildCategoryDiagnosticGroups = (diagnostics) => {
6231
+ return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
6232
+ return {
6233
+ category,
6234
+ diagnostics: categoryDiagnostics,
6235
+ ruleGroups: sortByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()])
6236
+ };
6237
+ }).toSorted((categoryGroupA, categoryGroupB) => {
6238
+ const severityDelta = SEVERITY_ORDER[getWorstSeverity(categoryGroupA.diagnostics)] - SEVERITY_ORDER[getWorstSeverity(categoryGroupB.diagnostics)];
6239
+ if (severityDelta !== 0) return severityDelta;
6240
+ if (categoryGroupA.diagnostics.length !== categoryGroupB.diagnostics.length) return categoryGroupB.diagnostics.length - categoryGroupA.diagnostics.length;
6241
+ return categoryGroupA.category.localeCompare(categoryGroupB.category);
6242
+ });
6243
+ };
6244
+ const buildCompactCategoryLine = (categoryGroup) => {
6245
+ const errorCount = categoryGroup.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
6246
+ const warningCount = categoryGroup.diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
6247
+ const parts = [];
6248
+ if (errorCount > 0) parts.push(highlighter.error(`${errorCount} ${errorCount === 1 ? "error" : "errors"}`));
6249
+ if (warningCount > 0) parts.push(highlighter.warn(highlighter.dim(`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`)));
6250
+ return ` ${highlighter.bold(categoryGroup.category)} ${highlighter.dim(POINTER)} ${parts.join(highlighter.dim(", "))}`;
6251
+ };
6252
+ const buildVerboseRuleGroupLines = (ruleKey, ruleDiagnostics, ruleNameColumnWidth) => {
6253
+ const lines = [];
6254
+ lines.push(buildCompactRuleGroupLine(ruleKey, ruleDiagnostics, ruleNameColumnWidth));
6255
+ const firstDiagnostic = ruleDiagnostics[0];
6256
+ lines.push(grayLine(indentMultilineText(firstDiagnostic.message, " ")));
6257
+ if (firstDiagnostic.help) lines.push(grayLine(indentMultilineText(`→ ${firstDiagnostic.help}`, " ")));
6258
+ const fileSites = buildVerboseSiteMap(ruleDiagnostics);
6259
+ for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
6260
+ lines.push(grayLine(` ${filePath}:${site.line}`));
6261
+ if (site.suppressionHint) lines.push(grayLine(` ↳ ${site.suppressionHint}`));
6262
+ }
6263
+ else lines.push(grayLine(` ${filePath}`));
6264
+ lines.push("");
6265
+ return lines;
6266
+ };
6267
+ const buildDefaultDiagnosticsLines = (diagnostics) => {
6268
+ const categoryGroups = buildCategoryDiagnosticGroups(diagnostics);
6269
+ const lines = [];
6270
+ for (const categoryGroup of categoryGroups) lines.push(buildCompactCategoryLine(categoryGroup));
6271
+ lines.push("");
6272
+ return lines;
6273
+ };
6274
+ /**
6275
+ * Effect-typed diagnostics renderer. Internal helpers build the
6276
+ * line array purely; the IO happens once at the boundary with a
6277
+ * single Effect.forEach over Console.log so failures or fiber
6278
+ * interruption produce predictable partial output.
6279
+ */
6280
+ const printDiagnostics = (diagnostics, isVerbose, rootDirectory) => Effect.gen(function* () {
6281
+ let lines;
6282
+ if (!isVerbose) lines = buildDefaultDiagnosticsLines(diagnostics);
6283
+ else {
6284
+ const sortedRuleGroups = sortByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
6285
+ const ruleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.map(([ruleKey]) => ruleKey));
6286
+ lines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => buildVerboseRuleGroupLines(ruleKey, ruleDiagnostics, ruleNameColumnWidth));
6287
+ }
6288
+ for (const line of lines) yield* Console.log(line);
6289
+ });
6290
+ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
6291
+ const firstDiagnostic = ruleDiagnostics[0];
6292
+ const sections = [
6293
+ `Rule: ${ruleKey}`,
6294
+ `Severity: ${firstDiagnostic.severity}`,
6295
+ `Category: ${firstDiagnostic.category}`,
6296
+ `Count: ${ruleDiagnostics.length}`,
6297
+ "",
6298
+ firstDiagnostic.message
6299
+ ];
6300
+ if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
6301
+ if (firstDiagnostic.url) sections.push("", `Docs: ${firstDiagnostic.url}`);
6302
+ sections.push("", "Files:");
6303
+ const fileSites = buildVerboseSiteMap(ruleDiagnostics);
6304
+ for (const [filePath, sites] of fileSites) if (sites.length > 0) for (const site of sites) {
6305
+ sections.push(` ${filePath}:${site.line}`);
6306
+ if (site.suppressionHint) sections.push(` ${site.suppressionHint}`);
6307
+ }
6308
+ else sections.push(` ${filePath}`);
6309
+ return sections.join("\n") + "\n";
6310
+ };
6311
+ const sortRuleGroupsByImportance = sortByImportance;
6312
+ //#endregion
6313
+ //#region src/cli/utils/render-project-detection.ts
6314
+ const printProjectDetection = (_input) => Effect.void;
6315
+ //#endregion
6316
+ //#region src/cli/utils/colorize-by-score.ts
6317
+ const colorizeByScore = (text, score) => {
6318
+ if (score >= 75) return highlighter.success(text);
6319
+ if (score >= 50) return highlighter.warn(text);
6320
+ return highlighter.error(text);
6321
+ };
6322
+ //#endregion
6323
+ //#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 easeOutCubic = (progress) => 1 - (1 - progress) ** 3;
6327
+ const sleep = (milliseconds) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, milliseconds)));
6328
+ const buildScoreBarSegments = (filledCount) => {
6329
+ const emptyCount = 50 - filledCount;
6330
+ return {
6331
+ filledSegment: "█".repeat(filledCount),
6332
+ emptySegment: "░".repeat(emptyCount)
6333
+ };
6334
+ };
6335
+ const getFilledCount = (score) => Math.round(score / 100 * 50);
6336
+ const buildScoreBar = (displayScore, colorScore = displayScore) => {
6337
+ const { filledSegment, emptySegment } = buildScoreBarSegments(getFilledCount(displayScore));
6338
+ return colorizeByScore(filledSegment, colorScore) + highlighter.dim(emptySegment);
6339
+ };
6340
+ const getDoctorFace = (score) => {
6341
+ if (score >= 75) return ["◠ ◠", " ▽ "];
6342
+ if (score >= 50) return ["• •", " ─ "];
6343
+ return ["x x", " ▽ "];
6344
+ };
6345
+ const BRANDING_LINE = `React Doctor ${highlighter.dim("(https://react.doctor)")}`;
6346
+ const buildFaceRenderedLines = (score) => {
6347
+ const [eyes, mouth] = getDoctorFace(score);
6348
+ const colorize = (text) => colorizeByScore(text, score);
6349
+ return [
6350
+ "┌─────┐",
6357
6351
  `│ ${eyes} │`,
6358
6352
  `│ ${mouth} │`,
6359
6353
  "└─────┘"
6360
6354
  ].map(colorize);
6361
6355
  };
6362
- const printScoreHeader = (scoreResult) => Effect.gen(function* () {
6356
+ const buildScoreHeaderLine = (faceLine, rightColumnContent) => {
6357
+ return ` ${faceLine}${rightColumnContent.length > 0 ? " " : ""}${rightColumnContent}`;
6358
+ };
6359
+ const writeScoreHeaderLine = (line) => Effect.sync(() => {
6360
+ process.stdout.write(line);
6361
+ });
6362
+ const buildScoreLine = (displayScore, finalScore, label, projectName) => {
6363
+ const scoreNumber = colorizeByScore(`${displayScore}`, finalScore);
6364
+ const scoreLabel = colorizeByScore(label, finalScore);
6365
+ const projectSuffix = projectName ? ` ${highlighter.dim("·")} ${highlighter.dim(projectName)}` : "";
6366
+ return `${scoreNumber} ${highlighter.dim(`/ 100`)} ${scoreLabel}${projectSuffix}`;
6367
+ };
6368
+ const printAnimatedScore = (scoreFaceLine, barFaceLine, score, label, projectName) => Effect.gen(function* () {
6369
+ for (let frame = 0; frame <= SCORE_BAR_ANIMATION_FRAME_COUNT; frame += 1) {
6370
+ const progress = easeOutCubic(frame / SCORE_BAR_ANIMATION_FRAME_COUNT);
6371
+ const animatedScore = Math.round(score * progress);
6372
+ const animatedScoreLine = buildScoreLine(animatedScore, score, label, projectName);
6373
+ const animatedBarLine = buildScoreBar(animatedScore, score);
6374
+ yield* writeScoreHeaderLine(`${frame === 0 ? "" : "\x1B[2A"}\r${buildScoreHeaderLine(scoreFaceLine, animatedScoreLine)}\n\r${buildScoreHeaderLine(barFaceLine, animatedBarLine)}\n`);
6375
+ if (frame < SCORE_BAR_ANIMATION_FRAME_COUNT) yield* sleep(SCORE_BAR_ANIMATION_FRAME_DELAY_MS);
6376
+ }
6377
+ });
6378
+ const printScoreHeader = (scoreResult, projectName) => Effect.gen(function* () {
6363
6379
  const renderedFaceLines = buildFaceRenderedLines(scoreResult.score);
6364
- const scoreNumber = colorizeByScore(`${scoreResult.score}`, scoreResult.score);
6365
- const scoreLabel = colorizeByScore(scoreResult.label, scoreResult.score);
6380
+ const shouldAnimate = !isSpinnerSilent() && isSpinnerInteractive(process.stdout);
6366
6381
  const rightColumnLines = [
6367
- `${scoreNumber} ${highlighter.dim(`/ 100`)} ${scoreLabel}`,
6368
- buildScoreBar(scoreResult.score),
6382
+ buildScoreLine(shouldAnimate ? 0 : scoreResult.score, scoreResult.score, scoreResult.label, projectName),
6383
+ shouldAnimate ? buildScoreBar(0, scoreResult.score) : buildScoreBar(scoreResult.score),
6369
6384
  BRANDING_LINE,
6370
6385
  ""
6371
6386
  ];
6372
6387
  for (let lineIndex = 0; lineIndex < renderedFaceLines.length; lineIndex += 1) {
6373
6388
  const rightColumnContent = rightColumnLines[lineIndex] ?? "";
6374
- const separator = rightColumnContent.length > 0 ? " " : "";
6375
- yield* Console.log(` ${renderedFaceLines[lineIndex]}${separator}${rightColumnContent}`);
6389
+ yield* Console.log(buildScoreHeaderLine(renderedFaceLines[lineIndex], rightColumnContent));
6376
6390
  }
6377
6391
  yield* Console.log("");
6392
+ if (shouldAnimate) {
6393
+ yield* writeScoreHeaderLine("\x1B[5A");
6394
+ yield* printAnimatedScore(renderedFaceLines[0], renderedFaceLines[1], scoreResult.score, scoreResult.label, projectName);
6395
+ yield* writeScoreHeaderLine("\x1B[3B");
6396
+ }
6378
6397
  });
6379
6398
  const printBrandingOnlyHeader = Effect.gen(function* () {
6380
6399
  yield* Console.log(` ${BRANDING_LINE}`);
@@ -6409,31 +6428,27 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
6409
6428
  if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
6410
6429
  return `${SHARE_BASE_URL}?${params.toString()}`;
6411
6430
  };
6412
- const printCountsSummaryLine = (diagnostics, totalSourceFileCount, elapsedMilliseconds) => Effect.gen(function* () {
6431
+ const printCountsSummaryLine = (diagnostics, isVerbose) => Effect.gen(function* () {
6432
+ const totalIssueCount = diagnostics.length;
6413
6433
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
6414
6434
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
6415
- const affectedFileCount = collectAffectedFiles(diagnostics).size;
6416
- const totalIssueCount = diagnostics.length;
6417
- const elapsedTimeLabel = formatElapsedTime(elapsedMilliseconds);
6418
- const issueCountColor = errorCount > 0 ? highlighter.error : warningCount > 0 ? highlighter.warn : highlighter.dim;
6419
- const issueCountText = `${totalIssueCount} ${totalIssueCount === 1 ? "issue" : "issues"}`;
6420
- const fileCountText = totalSourceFileCount > 0 ? `across ${affectedFileCount}/${totalSourceFileCount} files` : `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`;
6421
- const elapsedTimeText = `in ${elapsedTimeLabel}`;
6422
- yield* Console.log(` ${issueCountColor(issueCountText)} ${highlighter.dim(`${fileCountText} ${elapsedTimeText}`)}`);
6435
+ const issueText = (errorCount > 0 ? highlighter.error : warningCount > 0 ? highlighter.warn : highlighter.dim)(`${totalIssueCount} ${totalIssueCount === 1 ? "issue" : "issues"}`);
6436
+ yield* Console.log(` ${issueText}`);
6437
+ if (!isVerbose && totalIssueCount > 0) yield* Console.log(highlighter.dim(` Run ${highlighter.info("npx react-doctor@latest --verbose")} to see details`));
6423
6438
  });
6424
6439
  const printSummary = (input) => Effect.gen(function* () {
6425
- if (input.scoreResult) yield* printScoreHeader(input.scoreResult);
6440
+ if (input.scoreResult) yield* printScoreHeader(input.scoreResult, input.projectName);
6426
6441
  else yield* printNoScoreHeader(input.noScoreMessage);
6427
- yield* printCountsSummaryLine(input.diagnostics, input.totalSourceFileCount, input.elapsedMilliseconds);
6442
+ yield* printCountsSummaryLine(input.diagnostics, input.verbose ?? false);
6428
6443
  const diagnosticsDirectory = yield* Effect.try({
6429
6444
  try: () => writeDiagnosticsDirectory(input.diagnostics),
6430
6445
  catch: (cause) => cause
6431
6446
  }).pipe(Effect.orElseSucceed(() => null));
6432
- if (diagnosticsDirectory !== null) yield* Console.log(highlighter.gray(` Full diagnostics written to ${diagnosticsDirectory}`));
6447
+ if (diagnosticsDirectory !== null && input.verbose) yield* Console.log(highlighter.gray(` Full diagnostics written to ${diagnosticsDirectory}`));
6433
6448
  if (!input.isOffline) {
6434
6449
  yield* Console.log("");
6435
6450
  const shareUrl = buildShareUrl(input.diagnostics, input.scoreResult, input.projectName);
6436
- yield* Console.log(` ${highlighter.bold("→ Share your results:")} ${highlighter.info(shareUrl)}`);
6451
+ yield* Console.log(` ${highlighter.bold("→ Share:")} ${highlighter.info(shareUrl)}`);
6437
6452
  yield* Console.log("");
6438
6453
  }
6439
6454
  });
@@ -6546,10 +6561,10 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
6546
6561
  const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
6547
6562
  //#endregion
6548
6563
  //#region src/cli/utils/version.ts
6549
- const VERSION = "0.2.6";
6564
+ const VERSION = "0.2.7";
6550
6565
  //#endregion
6551
6566
  //#region src/inspect.ts
6552
- const silentConsole = new Proxy({}, { get: () => () => void 0 });
6567
+ const silentConsole = makeNoopConsole();
6553
6568
  const runConsole = (effect) => {
6554
6569
  Effect.runSync(effect);
6555
6570
  };
@@ -6565,6 +6580,8 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
6565
6580
  scoreOnly: inputOptions.scoreOnly ?? false,
6566
6581
  noScore: inputOptions.noScore ?? userConfig?.noScore ?? false,
6567
6582
  isCi: inputOptions.isCi ?? false,
6583
+ isCiOrCodingAgentEnvironment: isCiOrCodingAgentEnvironment(),
6584
+ isNonInteractiveEnvironment: isNonInteractiveEnvironment(),
6568
6585
  silent: inputOptions.silent ?? false,
6569
6586
  includePaths: inputOptions.includePaths ?? [],
6570
6587
  customRulesOnly: userConfig?.customRulesOnly ?? false,
@@ -6572,39 +6589,24 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
6572
6589
  respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
6573
6590
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
6574
6591
  ignoredTags: buildIgnoredTags(userConfig),
6575
- outputSurface: inputOptions.outputSurface ?? "cli"
6592
+ outputSurface: inputOptions.outputSurface ?? "cli",
6593
+ suppressRendering: inputOptions.suppressRendering ?? false
6576
6594
  });
6577
- /**
6578
- * Tagged-reason → legacy-class dispatch for the public `inspect()`
6579
- * contract. Each case converts a `ReactDoctorError` reason into the
6580
- * historical thrown class (`NoReactDependencyError`, …) via
6581
- * `Effect.die`, which `Effect.runPromise` re-throws unchanged.
6582
- * Unmatched reasons (GitInvocationFailed, OxlintSpawnFailed, …)
6583
- * flow through as the original tagged `ReactDoctorError` instance.
6584
- *
6585
- * Adding a new public thrown class is one new entry on this object
6586
- * — no `instanceof` checks, no `switch` ladder. The function form
6587
- * (vs. the standalone constant) is required so `Effect.catchReasons`
6588
- * gets the surrounding Effect's error channel for type inference.
6589
- */
6590
- const restoreLegacyThrow = (effect) => effect.pipe(Effect.catchReasons("ReactDoctorError", {
6591
- NoReactDependency: (reason) => Effect.die(new NoReactDependencyError(reason.directory)),
6592
- ProjectNotFound: (reason) => Effect.die(new ProjectNotFoundError(reason.directory)),
6593
- AmbiguousProject: (reason) => Effect.die(new AmbiguousProjectError(reason.directory, [...reason.candidates]))
6594
- }, (_reason, error) => Effect.die(new Error(error.message))));
6595
6595
  const inspect = async (directory, inputOptions = {}) => {
6596
6596
  const startTime = performance.now();
6597
6597
  const hasConfigOverride = inputOptions.configOverride !== void 0;
6598
- let scanDirectory = directory;
6598
+ let scanDirectory;
6599
6599
  let userConfig;
6600
- let configSourceDirectory = null;
6601
- if (hasConfigOverride) userConfig = inputOptions.configOverride ?? null;
6602
- else {
6603
- const loadedConfig = loadConfigWithSource(directory);
6604
- const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
6605
- if (redirectedDirectory) scanDirectory = redirectedDirectory;
6606
- userConfig = loadedConfig?.config ?? null;
6607
- configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
6600
+ let configSourceDirectory;
6601
+ if (hasConfigOverride) {
6602
+ scanDirectory = directory;
6603
+ userConfig = inputOptions.configOverride ?? null;
6604
+ configSourceDirectory = null;
6605
+ } else {
6606
+ const scanTarget = resolveScanTarget(directory);
6607
+ scanDirectory = scanTarget.resolvedDirectory;
6608
+ userConfig = scanTarget.userConfig;
6609
+ configSourceDirectory = scanTarget.configSourceDirectory;
6608
6610
  }
6609
6611
  const options = mergeInspectOptions(inputOptions, userConfig);
6610
6612
  const wasSpinnerSilent = isSpinnerSilent();
@@ -6619,79 +6621,50 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
6619
6621
  const isDiffMode = options.includePaths.length > 0;
6620
6622
  const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
6621
6623
  const lintBindingMissing = options.lint && !resolvedNodeBinaryPath;
6624
+ const shouldShowProgressSpinners = !options.isCiOrCodingAgentEnvironment && !options.silent && !options.scoreOnly && options.lint && Boolean(resolvedNodeBinaryPath);
6622
6625
  const layers = buildRuntimeLayers({
6623
6626
  directory,
6624
6627
  hasConfigOverride,
6625
6628
  userConfig,
6626
6629
  configSourceDirectory,
6627
6630
  shouldSkipLint: !options.lint || lintBindingMissing,
6628
- shouldRunDeadCode: options.deadCode
6629
- });
6630
- const program = Effect.gen(function* () {
6631
- const spinnerRef = yield* Ref.make(null);
6632
- return {
6633
- output: yield* runInspect({
6634
- directory,
6635
- includePaths: options.includePaths,
6636
- customRulesOnly: options.customRulesOnly,
6637
- respectInlineDisables: options.respectInlineDisables,
6638
- adoptExistingLintConfig: options.adoptExistingLintConfig,
6639
- ignoredTags: options.ignoredTags,
6640
- nodeBinaryPath: resolvedNodeBinaryPath ?? void 0,
6641
- runDeadCode: options.deadCode,
6642
- isCi: options.isCi,
6643
- doctorVersion: VERSION,
6644
- resolveLocalGithubViewerPermission: !options.noScore
6645
- }, {
6646
- beforeLint: (projectInfo, lintIncludePaths) => Effect.gen(function* () {
6647
- const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
6648
- if (!options.scoreOnly) yield* printProjectDetection({
6649
- projectInfo,
6650
- userConfig,
6651
- isDiffMode,
6652
- includePaths: options.includePaths,
6653
- lintSourceFileCount
6654
- });
6655
- if (options.lint && resolvedNodeBinaryPath && !options.scoreOnly && !options.silent) {
6656
- const handle = spinner("Running lint checks...").start();
6657
- yield* Ref.set(spinnerRef, {
6658
- succeed: (text) => handle.succeed(text),
6659
- fail: (text) => handle.fail(text)
6660
- });
6661
- }
6662
- }),
6663
- afterLint: (didFail) => Effect.gen(function* () {
6664
- const handle = yield* Ref.get(spinnerRef);
6665
- if (handle && !didFail) handle.succeed("Running lint checks.");
6666
- })
6667
- }),
6668
- finalHandle: yield* Ref.get(spinnerRef)
6669
- };
6631
+ shouldRunDeadCode: options.deadCode,
6632
+ shouldComputeScore: !options.noScore,
6633
+ shouldShowProgressSpinners
6670
6634
  });
6635
+ const program = runInspect({
6636
+ directory,
6637
+ includePaths: options.includePaths,
6638
+ customRulesOnly: options.customRulesOnly,
6639
+ respectInlineDisables: options.respectInlineDisables,
6640
+ adoptExistingLintConfig: options.adoptExistingLintConfig,
6641
+ ignoredTags: options.ignoredTags,
6642
+ nodeBinaryPath: resolvedNodeBinaryPath ?? void 0,
6643
+ runDeadCode: options.deadCode,
6644
+ isCi: options.isCi,
6645
+ doctorVersion: VERSION,
6646
+ resolveLocalGithubViewerPermission: !options.noScore
6647
+ }, { beforeLint: (projectInfo, lintIncludePaths) => Effect.gen(function* () {
6648
+ if (options.scoreOnly || options.suppressRendering) return;
6649
+ const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
6650
+ yield* printProjectDetection({
6651
+ projectInfo,
6652
+ userConfig,
6653
+ isDiffMode,
6654
+ includePaths: options.includePaths,
6655
+ lintSourceFileCount
6656
+ });
6657
+ }) });
6671
6658
  const programWithLayers = options.silent ? program.pipe(Effect.provide(layers), Effect.provideService(Console.Console, silentConsole), Effect.provide(layerOtlp)) : program.pipe(Effect.provide(layers), Effect.provide(layerOtlp));
6672
- const { output, finalHandle: finalSpinnerHandle } = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
6659
+ const output = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
6673
6660
  const didLintFail = lintBindingMissing || output.didLintFail;
6674
6661
  const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
6675
6662
  const lintFailureReasonTag = output.lintFailureReasonTag;
6676
6663
  const isNativeBindingFailure = lintFailureReasonTag === "OxlintUnavailable" || lintFailureReasonTag === "OxlintSpawnFailed";
6677
- if (!options.scoreOnly && !lintBindingMissing && output.didLintFail && finalSpinnerHandle !== null && lintFailureReason !== null) if (isNativeBindingFailure && /native binding/.test(lintFailureReason)) {
6678
- finalSpinnerHandle.fail(`Lint checks failed — oxlint native binding not found (Node ${process.version}).`);
6679
- runConsole(Console.log(highlighter.gray(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`)));
6680
- } else {
6681
- finalSpinnerHandle.fail("Lint checks failed (non-fatal, skipping).");
6682
- runConsole(Console.error(highlighter.error(lintFailureReason)));
6683
- }
6684
- if (!options.scoreOnly && !options.silent && options.deadCode && !isDiffMode) {
6685
- const deadCodeSpinner = spinner("Analyzing dead code...").start();
6686
- if (output.didDeadCodeFail) deadCodeSpinner.fail("Dead-code analysis failed (non-fatal, skipping).");
6687
- else deadCodeSpinner.succeed("Analyzing dead code.");
6688
- }
6664
+ 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`)));
6665
+ else runConsole(Console.error(highlighter.error(lintFailureReason)));
6689
6666
  const inspectDiagnostics = output.diagnostics;
6690
- const scoreDiagnostics = filterDiagnosticsForSurface([...inspectDiagnostics], "score", output.userConfig);
6691
- const score = didLintFail || options.noScore ? null : await calculateScore([...scoreDiagnostics], {
6692
- isCi: options.isCi,
6693
- metadata: output.scoreMetadata
6694
- });
6667
+ const score = didLintFail ? null : output.score;
6695
6668
  const finalizeInput = {
6696
6669
  options,
6697
6670
  elapsedMilliseconds: performance.now() - startTime,
@@ -6727,6 +6700,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
6727
6700
  project,
6728
6701
  elapsedMilliseconds
6729
6702
  });
6703
+ if (options.suppressRendering) return buildResult();
6730
6704
  if (options.scoreOnly) {
6731
6705
  if (score) yield* Console.log(`${score.score}`);
6732
6706
  else yield* Console.log(highlighter.gray(noScoreMessage));
@@ -6751,6 +6725,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
6751
6725
  }
6752
6726
  yield* Console.log("");
6753
6727
  yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory);
6728
+ if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
6754
6729
  if (demotedDiagnosticCount > 0) {
6755
6730
  yield* Console.log(highlighter.gray(` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`));
6756
6731
  yield* Console.log("");
@@ -6763,7 +6738,8 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
6763
6738
  projectName: project.projectName,
6764
6739
  totalSourceFileCount: lintSourceFileCount,
6765
6740
  noScoreMessage,
6766
- isOffline: !shouldShowShareLink
6741
+ isOffline: !shouldShowShareLink,
6742
+ verbose: options.verbose
6767
6743
  });
6768
6744
  if (hasSkippedChecks) {
6769
6745
  const skippedLabel = skippedChecks.join(" and ");
@@ -6804,6 +6780,58 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
6804
6780
  };
6805
6781
  //#endregion
6806
6782
  //#region src/cli/utils/handle-error.ts
6783
+ const OTLP_ENDPOINT_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_ENDPOINT";
6784
+ const OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_AUTH_HEADER";
6785
+ const formatErrorForReport = (error) => isReactDoctorError(error) ? formatReactDoctorError(error) : formatErrorChain(error);
6786
+ const formatSingleLine = (text) => text.replaceAll(/\s+/g, " ").trim();
6787
+ const getErrorReportContext = () => ({
6788
+ cwd: process.cwd(),
6789
+ command: process.argv.join(" "),
6790
+ nodeVersion: process.version,
6791
+ platform: process.platform,
6792
+ architecture: process.arch,
6793
+ isOtlpEndpointConfigured: Boolean(process.env[OTLP_ENDPOINT_ENVIRONMENT_VARIABLE]),
6794
+ isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE])
6795
+ });
6796
+ const formatConfiguredState = (isConfigured) => isConfigured ? "yes" : "no";
6797
+ const buildErrorIssueBody = (error, context) => {
6798
+ const formattedError = formatErrorForReport(error) || "(empty error)";
6799
+ const isOtlpExporterEnabled = context.isOtlpEndpointConfigured && context.isOtlpAuthHeaderConfigured;
6800
+ return [
6801
+ "## Error",
6802
+ "",
6803
+ "```text",
6804
+ formattedError,
6805
+ "```",
6806
+ "",
6807
+ "## Runtime",
6808
+ "",
6809
+ `- react-doctor version: ${VERSION}`,
6810
+ `- node: ${context.nodeVersion}`,
6811
+ `- platform: ${context.platform} ${context.architecture}`,
6812
+ `- cwd: ${context.cwd}`,
6813
+ `- command: ${context.command}`,
6814
+ "",
6815
+ "## OpenTelemetry",
6816
+ "",
6817
+ `- ${OTLP_ENDPOINT_ENVIRONMENT_VARIABLE} configured: ${formatConfiguredState(context.isOtlpEndpointConfigured)}`,
6818
+ `- ${OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE} configured: ${formatConfiguredState(context.isOtlpAuthHeaderConfigured)} (value redacted)`,
6819
+ `- OTLP exporter enabled: ${formatConfiguredState(isOtlpExporterEnabled)}`,
6820
+ "- trace/span link, if exported: ",
6821
+ "",
6822
+ "## Notes",
6823
+ "",
6824
+ "Please add reproduction steps and any relevant repository details."
6825
+ ].join("\n");
6826
+ };
6827
+ const buildErrorIssueUrl = (error) => {
6828
+ const formattedError = formatSingleLine(formatErrorForReport(error));
6829
+ const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
6830
+ issueUrl.searchParams.set("title", formattedError ? `CLI error: ${formattedError}` : "CLI error");
6831
+ issueUrl.searchParams.set("labels", "bug");
6832
+ issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext()));
6833
+ return issueUrl.toString();
6834
+ };
6807
6835
  /**
6808
6836
  * Effect-typed renderer: every message routes through `Console.error`
6809
6837
  * so test runs can swap `Console` to a capture sink and the output
@@ -6814,9 +6842,9 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
6814
6842
  const handleErrorEffect = (error) => Effect.gen(function* () {
6815
6843
  yield* Console.error("");
6816
6844
  yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
6817
- yield* Console.error(highlighter.error(`If the problem persists, please open an issue at ${CANONICAL_GITHUB_URL}/issues.`));
6845
+ yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
6818
6846
  yield* Console.error("");
6819
- yield* Console.error(highlighter.error(isReactDoctorError(error) ? formatReactDoctorError(error) : formatErrorChain(error)));
6847
+ yield* Console.error(highlighter.error(formatErrorForReport(error)));
6820
6848
  yield* Console.error("");
6821
6849
  });
6822
6850
  /**
@@ -6833,19 +6861,30 @@ const handleError = (error, options = { shouldExit: true }) => {
6833
6861
  //#region src/cli/utils/json-mode.ts
6834
6862
  let context = null;
6835
6863
  /**
6836
- * JSON mode writes the report payload to stdout; any incidental
6837
- * log line printed by an Effect program would corrupt the JSON.
6838
- * Effect's `Console` module resolves to `globalThis.console` by
6839
- * default (see `effect/internal/effect.ts` → `ConsoleRef`), so
6840
- * monkey-patching the global is enough to silence every
6864
+ * JSON mode writes the report payload to stdout; any incidental log
6865
+ * line printed by an Effect program would corrupt the JSON. Effect's
6866
+ * `Console` module resolves to `globalThis.console` by default (see
6867
+ * `effect/internal/effect.ts` → `ConsoleRef`), so copying the methods
6868
+ * from `makeNoopConsole()` onto the global is enough to silence every
6841
6869
  * `yield* Console.log(...)` and `cliLogger.*` call sourced from
6842
- * react-doctor or its services. We snapshot the originals (used
6843
- * by `writeJsonReport` → `process.stdout.write`) and never need
6844
- * to restore JSON mode is one-shot per CLI invocation.
6870
+ * react-doctor or its services.
6871
+ *
6872
+ * We use the same `makeNoopConsole()` source as the `--silent` path
6873
+ * (which provides the Effect Console via
6874
+ * `Effect.provideService(Console.Console, makeNoopConsole())`) — one
6875
+ * canonical "no-op console" definition shared by the two silent
6876
+ * mechanisms. The two routes still differ in how they install the
6877
+ * noop: silent mode swaps the Effect Console reference inside the
6878
+ * program; JSON mode patches the global because the surrounding CLI
6879
+ * command body is still imperative. Both will collapse into the
6880
+ * Effect-typed route once the command body finishes its migration.
6881
+ *
6882
+ * JSON mode is one-shot per CLI invocation, so we never restore.
6845
6883
  */
6846
6884
  const installSilentConsole = () => {
6847
- const noop = () => {};
6848
- const console = globalThis.console;
6885
+ const noopConsole = makeNoopConsole();
6886
+ const target = globalThis.console;
6887
+ const source = noopConsole;
6849
6888
  for (const key of [
6850
6889
  "log",
6851
6890
  "error",
@@ -6853,7 +6892,7 @@ const installSilentConsole = () => {
6853
6892
  "info",
6854
6893
  "debug",
6855
6894
  "trace"
6856
- ]) console[key] = noop;
6895
+ ]) target[key] = source[key];
6857
6896
  };
6858
6897
  const enableJsonMode = ({ compact, directory }) => {
6859
6898
  context = {
@@ -6919,6 +6958,168 @@ const printBrandedHeader = Effect.gen(function* () {
6919
6958
  yield* Console.log("");
6920
6959
  });
6921
6960
  //#endregion
6961
+ //#region src/cli/utils/copy-issues-to-clipboard.ts
6962
+ const MAX_RULES_SHOWN = 10;
6963
+ const MAX_FILES_PER_RULE = 3;
6964
+ const buildIssuesSummary = (input) => {
6965
+ const lines = [];
6966
+ lines.push(`# React Doctor: ${input.projectName}`);
6967
+ if (input.score) lines.push(`Score: ${input.score.score}/100`);
6968
+ lines.push(`${input.diagnostics.length} issues found`);
6969
+ lines.push("");
6970
+ const sortedRules = [...groupBy([...input.diagnostics], (diagnostic) => diagnostic.rule).entries()].sort(([, diagnosticsA], [, diagnosticsB]) => diagnosticsB.length - diagnosticsA.length);
6971
+ const visibleRules = sortedRules.slice(0, MAX_RULES_SHOWN);
6972
+ for (const [rule, ruleDiagnostics] of visibleRules) {
6973
+ const severity = ruleDiagnostics[0].severity;
6974
+ const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
6975
+ const shownFiles = uniqueFiles.slice(0, MAX_FILES_PER_RULE);
6976
+ const remainingFileCount = uniqueFiles.length - shownFiles.length;
6977
+ lines.push(`${severity === "error" ? "ERROR" : "WARN"} ${rule} (×${ruleDiagnostics.length})`);
6978
+ lines.push(` ${ruleDiagnostics[0].message}`);
6979
+ for (const filePath of shownFiles) {
6980
+ const firstSite = ruleDiagnostics.find((diagnostic) => diagnostic.filePath === filePath && diagnostic.line > 0);
6981
+ lines.push(` - ${filePath}${firstSite ? `:${firstSite.line}` : ""}`);
6982
+ }
6983
+ if (remainingFileCount > 0) lines.push(` - +${remainingFileCount} more files`);
6984
+ }
6985
+ const hiddenRuleCount = sortedRules.length - visibleRules.length;
6986
+ if (hiddenRuleCount > 0) {
6987
+ lines.push("");
6988
+ lines.push(`+${hiddenRuleCount} more rules`);
6989
+ }
6990
+ try {
6991
+ const diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
6992
+ lines.push("");
6993
+ lines.push(`Full trace: ${diagnosticsDirectory}`);
6994
+ } catch {}
6995
+ lines.push("");
6996
+ lines.push("## How to fix");
6997
+ lines.push("1. Run `npx react-doctor@latest --verbose` to see full details");
6998
+ lines.push("2. Fix errors first, then warnings. Start with high-count rules.");
6999
+ lines.push("3. Read the code before acting. Treat findings as hypotheses, not commands.");
7000
+ lines.push("4. Fix root causes, not symptoms. Don't suppress rules without evidence.");
7001
+ lines.push("5. Run `npx react-doctor@latest --verbose --diff` after changes to verify.");
7002
+ lines.push("6. Split unrelated fixes into separate PRs.");
7003
+ return lines.join("\n");
7004
+ };
7005
+ const copyToClipboard = (text) => {
7006
+ const platform = os.platform();
7007
+ try {
7008
+ if (platform === "darwin") {
7009
+ execSync("pbcopy", {
7010
+ input: text,
7011
+ stdio: [
7012
+ "pipe",
7013
+ "ignore",
7014
+ "ignore"
7015
+ ]
7016
+ });
7017
+ return true;
7018
+ }
7019
+ if (platform === "win32") {
7020
+ execSync("clip", {
7021
+ input: text,
7022
+ stdio: [
7023
+ "pipe",
7024
+ "ignore",
7025
+ "ignore"
7026
+ ]
7027
+ });
7028
+ return true;
7029
+ }
7030
+ execSync("xclip -selection clipboard", {
7031
+ input: text,
7032
+ stdio: [
7033
+ "pipe",
7034
+ "ignore",
7035
+ "ignore"
7036
+ ]
7037
+ });
7038
+ return true;
7039
+ } catch {
7040
+ return false;
7041
+ }
7042
+ };
7043
+ const promptCopyIssues = async (input) => {
7044
+ if (input.diagnostics.length === 0) return;
7045
+ const { shouldCopy } = await prompts({
7046
+ type: "confirm",
7047
+ name: "shouldCopy",
7048
+ message: "Copy issues to clipboard?",
7049
+ initial: true
7050
+ }, { onCancel: () => true });
7051
+ if (!shouldCopy) return;
7052
+ const issuesSummary = buildIssuesSummary(input);
7053
+ if (copyToClipboard(issuesSummary)) cliLogger.log(" Copied to clipboard.");
7054
+ else cliLogger.log(issuesSummary);
7055
+ };
7056
+ //#endregion
7057
+ //#region src/cli/utils/render-multi-project-summary.ts
7058
+ const SUMMARY_BAR_WIDTH_CHARS = 20;
7059
+ const buildMiniBar = (score) => {
7060
+ const filledCount = Math.round(score / 100 * SUMMARY_BAR_WIDTH_CHARS);
7061
+ const emptyCount = SUMMARY_BAR_WIDTH_CHARS - filledCount;
7062
+ return colorizeByScore("█".repeat(filledCount), score) + highlighter.dim("░".repeat(emptyCount));
7063
+ };
7064
+ const getScoreLabel = (score) => {
7065
+ if (score >= 75) return "Great";
7066
+ if (score >= 50) return "OK";
7067
+ return "Needs work";
7068
+ };
7069
+ const buildSummaryLine = (entry, longestProjectNameLength) => {
7070
+ const paddedName = entry.projectName.padEnd(longestProjectNameLength);
7071
+ const nameRendering = entry.score !== null ? colorizeByScore(paddedName, entry.score) : highlighter.dim(paddedName);
7072
+ if (entry.score === null) {
7073
+ const issueLabel = `${entry.issueCount} ${entry.issueCount === 1 ? "issue" : "issues"}`;
7074
+ return ` ${nameRendering} ${highlighter.dim("—".repeat(SUMMARY_BAR_WIDTH_CHARS))} ${highlighter.dim("no score")} ${highlighter.dim(issueLabel)}`;
7075
+ }
7076
+ const scoreRendering = colorizeByScore(String(entry.score).padStart(3), entry.score);
7077
+ const bar = buildMiniBar(entry.score);
7078
+ const label = colorizeByScore(getScoreLabel(entry.score), entry.score);
7079
+ const issuesParts = [];
7080
+ if (entry.errorCount > 0) issuesParts.push(highlighter.error(`${entry.errorCount} ${entry.errorCount === 1 ? "error" : "errors"}`));
7081
+ const warningCount = entry.issueCount - entry.errorCount;
7082
+ if (warningCount > 0) issuesParts.push(highlighter.warn(`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`));
7083
+ return ` ${nameRendering} ${scoreRendering} ${bar} ${label} ${issuesParts.length > 0 ? issuesParts.join(highlighter.dim(", ")) : ""}`;
7084
+ };
7085
+ const computeAggregateScore = (completedScans) => {
7086
+ const scoredScans = completedScans.filter((scan) => scan.result.score !== null);
7087
+ if (scoredScans.length === 0) return null;
7088
+ return scoredScans.reduce((worst, scan) => scan.result.score.score < worst.result.score.score ? scan : worst).result.score;
7089
+ };
7090
+ const printMultiProjectSummary = (input) => Effect.gen(function* () {
7091
+ const { completedScans, userConfig, verbose } = input;
7092
+ const surfaceDiagnostics = filterDiagnosticsForSurface(completedScans.flatMap((scan) => scan.result.diagnostics), "cli", userConfig);
7093
+ if (surfaceDiagnostics.length > 0) {
7094
+ yield* Console.log("");
7095
+ yield* printDiagnostics(surfaceDiagnostics, verbose, "");
7096
+ }
7097
+ const aggregateScore = computeAggregateScore(completedScans);
7098
+ const totalSourceFileCount = completedScans.reduce((sum, scan) => sum + scan.result.project.sourceFileCount, 0);
7099
+ yield* printSummary({
7100
+ diagnostics: surfaceDiagnostics,
7101
+ elapsedMilliseconds: completedScans.reduce((sum, scan) => sum + scan.result.elapsedMilliseconds, 0),
7102
+ scoreResult: aggregateScore,
7103
+ projectName: completedScans.map((scan) => scan.result.project.projectName).join(", "),
7104
+ totalSourceFileCount,
7105
+ noScoreMessage: "Score unavailable.",
7106
+ isOffline: true,
7107
+ verbose
7108
+ });
7109
+ const entries = completedScans.map((scan) => {
7110
+ const errorCount = scan.result.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
7111
+ return {
7112
+ projectName: scan.result.project.projectName,
7113
+ score: scan.result.score?.score ?? null,
7114
+ issueCount: scan.result.diagnostics.length,
7115
+ errorCount
7116
+ };
7117
+ });
7118
+ const longestProjectNameLength = Math.max(...entries.map((entry) => entry.projectName.length));
7119
+ for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
7120
+ yield* Console.log("");
7121
+ });
7122
+ //#endregion
6922
7123
  //#region src/cli/utils/git-hook-shared.ts
6923
7124
  const HOOK_FILE_NAME = "pre-commit";
6924
7125
  const HOOK_RELATIVE_PATH = "hooks/pre-commit";
@@ -6993,7 +7194,8 @@ const ensureTrailingNewline = (content) => content.endsWith("\n") ? content : `$
6993
7194
  //#endregion
6994
7195
  //#region src/cli/utils/install-doctor-script.ts
6995
7196
  const DOCTOR_SCRIPT_NAME = "doctor";
6996
- const DOCTOR_SCRIPT_COMMAND = "react-doctor";
7197
+ const FALLBACK_DOCTOR_SCRIPT_NAME = "react-doctor";
7198
+ const DOCTOR_SCRIPT_COMMAND = "npx react-doctor@latest";
6997
7199
  const DOCTOR_PACKAGE_NAME = "react-doctor";
6998
7200
  const DEPENDENCY_FIELD_NAMES = [
6999
7201
  "dependencies",
@@ -7001,50 +7203,87 @@ const DEPENDENCY_FIELD_NAMES = [
7001
7203
  "optionalDependencies",
7002
7204
  "peerDependencies"
7003
7205
  ];
7004
- const getDoctorDependencyVersion = () => `^${VERSION}`;
7206
+ const isReactDoctorScriptCommand = (value) => typeof value === "string" && /\breact-doctor\b/.test(value);
7207
+ const findNearestPackageDirectory = (startDirectory, stopDirectory) => {
7208
+ let currentDirectory = path.resolve(startDirectory);
7209
+ const resolvedStopDirectory = stopDirectory === void 0 ? void 0 : path.resolve(stopDirectory);
7210
+ while (true) {
7211
+ if (existsSync(getPackageJsonPath(currentDirectory))) return currentDirectory;
7212
+ if (currentDirectory === resolvedStopDirectory) return null;
7213
+ const parentDirectory = path.dirname(currentDirectory);
7214
+ if (parentDirectory === currentDirectory) return null;
7215
+ currentDirectory = parentDirectory;
7216
+ }
7217
+ };
7005
7218
  const hasDoctorScript = (projectRoot) => {
7006
- const packageJson = readPackageJson(projectRoot);
7219
+ const packageJson = readPackageJson(findNearestPackageDirectory(projectRoot) ?? projectRoot);
7007
7220
  if (!isRecord(packageJson)) return false;
7008
7221
  const scripts = packageJson.scripts;
7009
- return isRecord(scripts) && Object.hasOwn(scripts, "doctor");
7222
+ if (!isRecord(scripts)) return false;
7223
+ return isReactDoctorScriptCommand(scripts["doctor"]) || isReactDoctorScriptCommand(scripts["react-doctor"]);
7010
7224
  };
7011
7225
  const hasDoctorDependency = (packageJson) => DEPENDENCY_FIELD_NAMES.some((fieldName) => {
7012
7226
  const dependencies = packageJson[fieldName];
7013
7227
  return isRecord(dependencies) && Object.hasOwn(dependencies, "react-doctor");
7014
7228
  });
7015
7229
  const installDoctorScript = (options) => {
7016
- const packageJsonPath = getPackageJsonPath(options.projectRoot);
7017
- const packageJson = readPackageJson(options.projectRoot);
7230
+ const packageDirectory = findNearestPackageDirectory(options.projectRoot) ?? options.projectRoot;
7231
+ const packageJsonPath = getPackageJsonPath(packageDirectory);
7232
+ const packageJson = readPackageJson(packageDirectory);
7018
7233
  if (!isRecord(packageJson)) return {
7019
7234
  packageJsonPath,
7020
7235
  scriptStatus: "skipped",
7021
- dependencyStatus: "skipped",
7022
- scriptReason: "missing-or-invalid-package-json",
7023
- dependencyReason: "missing-or-invalid-package-json"
7236
+ scriptReason: "missing-or-invalid-package-json"
7024
7237
  };
7025
7238
  const scripts = packageJson.scripts;
7026
- const devDependencies = packageJson.devDependencies;
7027
- const scriptStatus = isRecord(scripts) && Object.hasOwn(scripts, "doctor") ? "existing" : scripts !== void 0 && !isRecord(scripts) ? "skipped" : "created";
7028
- const dependencyStatus = hasDoctorDependency(packageJson) ? "existing" : devDependencies !== void 0 && !isRecord(devDependencies) ? "skipped" : "created";
7029
- const didCreateScript = scriptStatus === "created";
7030
- const didCreateDependency = dependencyStatus === "created";
7031
- if (didCreateScript || didCreateDependency) writeJsonFile$1(packageJsonPath, {
7239
+ const scriptTarget = (() => {
7240
+ if (scripts !== void 0 && !isRecord(scripts)) return {
7241
+ status: "skipped",
7242
+ reason: "invalid-scripts"
7243
+ };
7244
+ const scriptRecord = isRecord(scripts) ? scripts : {};
7245
+ if (isReactDoctorScriptCommand(scriptRecord["doctor"])) return {
7246
+ scriptName: DOCTOR_SCRIPT_NAME,
7247
+ status: "existing"
7248
+ };
7249
+ if (!Object.hasOwn(scriptRecord, "doctor")) {
7250
+ if (isReactDoctorScriptCommand(scriptRecord["react-doctor"])) return {
7251
+ scriptName: FALLBACK_DOCTOR_SCRIPT_NAME,
7252
+ status: "existing"
7253
+ };
7254
+ return {
7255
+ scriptName: DOCTOR_SCRIPT_NAME,
7256
+ status: "created"
7257
+ };
7258
+ }
7259
+ if (isReactDoctorScriptCommand(scriptRecord["react-doctor"])) return {
7260
+ scriptName: FALLBACK_DOCTOR_SCRIPT_NAME,
7261
+ status: "existing",
7262
+ reason: "doctor-script-taken"
7263
+ };
7264
+ if (Object.hasOwn(scriptRecord, "react-doctor")) return {
7265
+ status: "skipped",
7266
+ reason: "script-names-taken"
7267
+ };
7268
+ return {
7269
+ scriptName: FALLBACK_DOCTOR_SCRIPT_NAME,
7270
+ status: "created",
7271
+ reason: "doctor-script-taken"
7272
+ };
7273
+ })();
7274
+ const scriptStatus = scriptTarget.status;
7275
+ if (scriptStatus === "created") writeJsonFile$1(packageJsonPath, {
7032
7276
  ...packageJson,
7033
- ...didCreateScript ? { scripts: {
7277
+ scripts: {
7034
7278
  ...isRecord(scripts) ? scripts : {},
7035
- [DOCTOR_SCRIPT_NAME]: DOCTOR_SCRIPT_COMMAND
7036
- } } : {},
7037
- ...didCreateDependency ? { devDependencies: {
7038
- ...isRecord(devDependencies) ? devDependencies : {},
7039
- [DOCTOR_PACKAGE_NAME]: getDoctorDependencyVersion()
7040
- } } : {}
7279
+ [scriptTarget.scriptName ?? "doctor"]: DOCTOR_SCRIPT_COMMAND
7280
+ }
7041
7281
  });
7042
7282
  return {
7043
7283
  packageJsonPath,
7284
+ ...scriptTarget.scriptName !== void 0 ? { scriptName: scriptTarget.scriptName } : {},
7044
7285
  scriptStatus,
7045
- dependencyStatus,
7046
- ...scriptStatus === "skipped" ? { scriptReason: "invalid-scripts" } : {},
7047
- ...dependencyStatus === "skipped" ? { dependencyReason: "invalid-dev-dependencies" } : {}
7286
+ ...scriptTarget.reason !== void 0 ? { scriptReason: scriptTarget.reason } : {}
7048
7287
  };
7049
7288
  };
7050
7289
  const SETUP_PROMPT_CHOICE_NEVER = "never";
@@ -7077,9 +7316,20 @@ const shouldPromptInstallSetup = (options) => {
7077
7316
  if (options.isScoreOnly) return false;
7078
7317
  if (options.isStaged) return false;
7079
7318
  if (options.skipPrompts) return false;
7319
+ if (isCiOrCodingAgentEnvironment()) return false;
7080
7320
  if (hasDisabledSetupPrompt(options.projectRoot, options.store)) return false;
7081
7321
  return !hasDoctorScript(options.projectRoot);
7082
7322
  };
7323
+ const resolveInstallSetupProjectRoot = (options) => {
7324
+ if (options.completedScanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
7325
+ const packageDirectories = /* @__PURE__ */ new Set();
7326
+ for (const scanDirectory of options.completedScanDirectories) {
7327
+ const packageDirectory = findNearestPackageDirectory(scanDirectory, options.scanRoot) ?? findNearestPackageDirectory(scanDirectory) ?? scanDirectory;
7328
+ packageDirectories.add(packageDirectory);
7329
+ }
7330
+ if (packageDirectories.size !== 1) return null;
7331
+ return [...packageDirectories][0] ?? null;
7332
+ };
7083
7333
  const defaultWait = (milliseconds) => new Promise((resolve) => {
7084
7334
  setTimeout(resolve, milliseconds);
7085
7335
  });
@@ -7088,20 +7338,15 @@ const defaultSelect = async (message) => {
7088
7338
  type: "select",
7089
7339
  name: "setupReactDoctorChoice",
7090
7340
  message,
7091
- choices: [
7092
- {
7093
- title: "Yes",
7094
- value: "yes"
7095
- },
7096
- {
7097
- title: "No",
7098
- value: "no"
7099
- },
7100
- {
7101
- title: "No, never ask again for this project",
7102
- value: SETUP_PROMPT_CHOICE_NEVER
7103
- }
7104
- ],
7341
+ choices: [{
7342
+ title: "Yes (recommended)",
7343
+ description: "Use agents to automatically fix issues",
7344
+ value: "yes"
7345
+ }, {
7346
+ title: "Skip",
7347
+ description: "Not recommended. Issues may go unfixed.",
7348
+ value: SETUP_PROMPT_CHOICE_NEVER
7349
+ }],
7105
7350
  initial: 0
7106
7351
  }, { onCancel: () => true });
7107
7352
  return setupReactDoctorChoice ?? "no";
@@ -7109,15 +7354,6 @@ const defaultSelect = async (message) => {
7109
7354
  const defaultWriteLine = (line = "") => {
7110
7355
  console.log(line);
7111
7356
  };
7112
- const buildInstallSetupPitchLines = (issueCount) => {
7113
- const issueLabel = `${issueCount} ${issueCount === 1 ? "issue" : "issues"}`;
7114
- return [
7115
- "",
7116
- issueCount > 0 ? `React Doctor found ${issueLabel}! Do you want to add React Doctor to this project? It will help humans and agents keep working through those fixes after this scan.` : "React Doctor did not find issues this time! Do you want to add React Doctor to this project? It will help humans and agents catch future regressions early.",
7117
- "Setup will add a `doctor` package script, install React Doctor skills for your coding agents, and offer optional hooks for pre-commit and post-edit checks.",
7118
- ""
7119
- ];
7120
- };
7121
7357
  const formatSetupPromptFailure = (error) => error instanceof Error ? error.message : String(error);
7122
7358
  const warnSetupPromptFailure = async (options, error) => {
7123
7359
  const message = `React Doctor setup prompt skipped: ${formatSetupPromptFailure(error)}`;
@@ -7126,22 +7362,22 @@ const warnSetupPromptFailure = async (options, error) => {
7126
7362
  return;
7127
7363
  }
7128
7364
  try {
7129
- const { cliLogger } = await import("./cli-logger-Iz5pfDnL.js").then((n) => n.n);
7365
+ const { cliLogger } = await import("./cli-logger-C35LXalM.js").then((n) => n.n);
7130
7366
  cliLogger.warn(message);
7131
7367
  } catch {}
7132
7368
  };
7133
7369
  const promptInstallSetup = async (options) => {
7134
7370
  try {
7135
7371
  if (!shouldPromptInstallSetup(options)) return;
7136
- await (options.wait ?? defaultWait)(300);
7137
- const writeLine = options.writeLine ?? defaultWriteLine;
7138
- for (const line of buildInstallSetupPitchLines(options.issueCount)) writeLine(line);
7372
+ await (options.wait ?? defaultWait)(100);
7139
7373
  const setupReactDoctorChoice = await (options.select ?? defaultSelect)("Set up React Doctor for this project?");
7140
- if (setupReactDoctorChoice === "never") {
7141
- disableSetupPrompt(options.projectRoot, options.store);
7374
+ if (setupReactDoctorChoice !== "yes") {
7375
+ if (setupReactDoctorChoice === "never") disableSetupPrompt(options.projectRoot, options.store);
7376
+ const writeLine = options.writeLine ?? defaultWriteLine;
7377
+ writeLine("");
7378
+ writeLine("You can always run `npx react-doctor@latest install` to set it up later.");
7142
7379
  return;
7143
7380
  }
7144
- if (setupReactDoctorChoice !== "yes") return;
7145
7381
  const install = options.install ?? (await Promise.resolve().then(() => install_skill_exports)).runInstallSkill;
7146
7382
  const previousExitCode = process.exitCode;
7147
7383
  let setupExitCode;
@@ -7160,25 +7396,50 @@ const promptInstallSetup = async (options) => {
7160
7396
  await warnSetupPromptFailure(options, error);
7161
7397
  }
7162
7398
  };
7163
- //#endregion
7164
- //#region src/cli/utils/is-ci-environment.ts
7165
- const CI_ENVIRONMENT_VARIABLES = [
7166
- "GITHUB_ACTIONS",
7167
- "GITLAB_CI",
7168
- "CIRCLECI"
7399
+ const shouldShowAgentInstallHint = (options) => {
7400
+ if (!options.hasScoredScan) return false;
7401
+ if (options.isJsonMode) return false;
7402
+ if (options.isScoreOnly) return false;
7403
+ if (options.isStaged) return false;
7404
+ if (!(options.isCodingAgent ?? isCodingAgentEnvironment())) return false;
7405
+ if (hasDisabledSetupPrompt(options.projectRoot, options.store)) return false;
7406
+ return !hasDoctorScript(options.projectRoot);
7407
+ };
7408
+ const AGENT_INSTALL_HINT_LINES = [
7409
+ "React Doctor is not installed in this project.",
7410
+ "Ask the user if they would like to set it up, then run:",
7411
+ "",
7412
+ " npx react-doctor install --yes",
7413
+ "",
7414
+ "This adds agent skill files, a `doctor` package script, and react-doctor as a dev dependency."
7169
7415
  ];
7170
- const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
7416
+ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
7417
+ writeLine("");
7418
+ for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
7419
+ };
7171
7420
  //#endregion
7172
7421
  //#region src/cli/utils/resolve-cli-inspect-options.ts
7422
+ /**
7423
+ * Translates CLI flags into the `InspectOptions` contract `inspect()`
7424
+ * accepts. Flag-specific computed fields (`scoreOnly`, `noScore`,
7425
+ * `silent`, `outputSurface`, `isCi`) live here — there's no
7426
+ * `userConfig` knob for them, only flag derivation. The remaining
7427
+ * boolean knobs (`lint`, `deadCode`, `verbose`, `respectInlineDisables`)
7428
+ * pass through unchanged: `inspect()` owns the userConfig-fallback
7429
+ * layer so the merge logic isn't duplicated. The shell still hands
7430
+ * `userConfig` in via `configOverride` and `noScore` so this resolver
7431
+ * can apply the one flag-and-config rule that flags own
7432
+ * (`--score false` wins, otherwise inherit `userConfig.noScore`).
7433
+ */
7173
7434
  const resolveCliInspectOptions = (flags, userConfig) => ({
7174
- lint: flags.lint ?? userConfig?.lint ?? true,
7175
- deadCode: flags.deadCode ?? userConfig?.deadCode ?? true,
7176
- verbose: flags.verbose ?? userConfig?.verbose ?? false,
7435
+ lint: flags.lint,
7436
+ deadCode: flags.deadCode,
7437
+ verbose: flags.verbose,
7438
+ respectInlineDisables: flags.respectInlineDisables,
7177
7439
  scoreOnly: flags.score === true,
7178
7440
  noScore: flags.score === false || (userConfig?.noScore ?? false),
7179
7441
  isCi: isCiEnvironment(),
7180
7442
  silent: Boolean(flags.json),
7181
- respectInlineDisables: flags.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
7182
7443
  outputSurface: flags.prComment ? "prComment" : "cli"
7183
7444
  });
7184
7445
  //#endregion
@@ -7198,14 +7459,20 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQui
7198
7459
  if (changedSourceFiles.length === 0) return false;
7199
7460
  if (shouldSkipPrompts) return false;
7200
7461
  if (isQuiet) return false;
7201
- const currentBranchLabel = diffInfo.currentBranch ?? "(detached HEAD)";
7202
- const { shouldScanChangedOnly } = await prompts({
7203
- type: "confirm",
7204
- name: "shouldScanChangedOnly",
7205
- message: diffInfo.isCurrentChanges ? `Found ${changedSourceFiles.length} uncommitted changed files. Only scan those?` : `On branch ${currentBranchLabel} (${changedSourceFiles.length} files changed vs ${diffInfo.baseBranch}). Only scan changed files?`,
7206
- initial: true
7462
+ const { scanScope } = await prompts({
7463
+ type: "select",
7464
+ name: "scanScope",
7465
+ message: "Select",
7466
+ choices: [{
7467
+ title: "Full codebase",
7468
+ value: "full"
7469
+ }, {
7470
+ title: `Changed files (${changedSourceFiles.length})`,
7471
+ value: "branch"
7472
+ }],
7473
+ initial: 0
7207
7474
  });
7208
- return Boolean(shouldScanChangedOnly);
7475
+ return scanScope === "branch";
7209
7476
  };
7210
7477
  //#endregion
7211
7478
  //#region src/cli/utils/coerce-diff-value.ts
@@ -7259,6 +7526,40 @@ const resolveProjectDiffIncludePaths = (rootDirectory, projectDirectory, diffInf
7259
7526
  });
7260
7527
  };
7261
7528
  //#endregion
7529
+ //#region src/cli/utils/build-diagnostic-issue-url.ts
7530
+ const formatRuleIdentifier = (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`;
7531
+ const buildDiagnosticIssueBody = (input) => {
7532
+ const { diagnostic, relativeFilePath } = input;
7533
+ const lines = [
7534
+ "## Diagnostic",
7535
+ "",
7536
+ `- Rule: ${formatRuleIdentifier(diagnostic)}`,
7537
+ `- Severity: ${diagnostic.severity}`,
7538
+ `- Category: ${diagnostic.category}`,
7539
+ `- Location: ${relativeFilePath}:${diagnostic.line}`,
7540
+ "",
7541
+ "## Message",
7542
+ "",
7543
+ "```text",
7544
+ diagnostic.message,
7545
+ "```"
7546
+ ];
7547
+ if (diagnostic.help) lines.push("", "## Suggested Fix", "", "```text", diagnostic.help, "```");
7548
+ lines.push("", "## Why this looks wrong or needs follow-up", "", "Please explain why this should be changed, suppressed, or treated as a false positive.");
7549
+ return lines.join("\n");
7550
+ };
7551
+ const buildDiagnosticIssueUrl = (input) => {
7552
+ const { diagnostic, relativeFilePath } = input;
7553
+ const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
7554
+ issueUrl.searchParams.set("title", `Diagnostic follow-up: ${formatRuleIdentifier(diagnostic)}`);
7555
+ issueUrl.searchParams.set("labels", "bug");
7556
+ issueUrl.searchParams.set("body", buildDiagnosticIssueBody({
7557
+ diagnostic,
7558
+ relativeFilePath
7559
+ }));
7560
+ return issueUrl.toString();
7561
+ };
7562
+ //#endregion
7262
7563
  //#region src/cli/utils/find-owning-project.ts
7263
7564
  const findOwningProjectDirectory = (rootDirectory, filePath) => {
7264
7565
  const absoluteFile = path.isAbsolute(filePath) ? filePath : path.resolve(rootDirectory, filePath);
@@ -7297,7 +7598,10 @@ const parseFileLineArgument = (rawArgument) => {
7297
7598
  //#region src/cli/utils/select-projects.ts
7298
7599
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
7299
7600
  let packages = listWorkspacePackages(rootDirectory);
7300
- if (packages.length === 0) packages = discoverReactSubprojects(rootDirectory);
7601
+ if (packages.length === 0) {
7602
+ if (!isMonorepoRoot(rootDirectory)) return [rootDirectory];
7603
+ packages = discoverReactSubprojects(rootDirectory);
7604
+ }
7301
7605
  if (packages.length === 0) return [rootDirectory];
7302
7606
  if (packages.length === 1) {
7303
7607
  cliLogger.log(`${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${packages[0].name}`);
@@ -7375,6 +7679,10 @@ const runExplain = async (fileLineArgument, context) => {
7375
7679
  cliLogger.log(`${severitySymbol} ${colorizedRule} ${highlighter.dim(`(${severityLabel})`)} — ${diagnostic.message}`);
7376
7680
  if (diagnostic.category) cliLogger.dim(` Category: ${diagnostic.category}`);
7377
7681
  if (diagnostic.help) cliLogger.dim(` ${diagnostic.help}`);
7682
+ cliLogger.dim(` If this needs follow-up or looks like a false positive, open: ${buildDiagnosticIssueUrl({
7683
+ diagnostic,
7684
+ relativeFilePath: requestedRelativePath
7685
+ })}`);
7378
7686
  if (diagnostic.suppressionHint) {
7379
7687
  cliLogger.break();
7380
7688
  cliLogger.log(` Suppression diagnosis: ${diagnostic.suppressionHint}`);
@@ -7407,6 +7715,26 @@ const validateModeFlags = (flags) => {
7407
7715
  };
7408
7716
  //#endregion
7409
7717
  //#region src/cli/commands/inspect.ts
7718
+ /**
7719
+ * Post-scan finalization shared by the staged-arm and project-loop
7720
+ * paths of `inspectAction`: emit the JSON report (when in JSON mode),
7721
+ * print PR annotations (when `--annotations`), and set
7722
+ * `process.exitCode = 1` when the configured fail-on threshold is
7723
+ * crossed. Both arms previously inlined the same four-step shape.
7724
+ */
7725
+ const finalizeScans = (input) => {
7726
+ if (input.isJsonMode) writeJsonReport(buildJsonReport({
7727
+ version: VERSION,
7728
+ directory: input.resolvedDirectory,
7729
+ mode: input.mode,
7730
+ diff: input.diff,
7731
+ scans: input.completedScans,
7732
+ totalElapsedMilliseconds: performance.now() - input.startTime
7733
+ }));
7734
+ if (input.flags.annotations) printAnnotations(input.diagnostics, input.isJsonMode);
7735
+ const ciFailureDiagnostics = filterDiagnosticsForSurface(input.diagnostics, "ciFailure", input.userConfig);
7736
+ if (!input.isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(input.flags, input.userConfig))) process.exitCode = 1;
7737
+ };
7410
7738
  const inspectAction = async (directory, flags) => {
7411
7739
  const isScoreOnly = Boolean(flags.score);
7412
7740
  const isJsonMode = Boolean(flags.json);
@@ -7419,12 +7747,11 @@ const inspectAction = async (directory, flags) => {
7419
7747
  });
7420
7748
  try {
7421
7749
  validateModeFlags(flags);
7422
- const loadedConfig = loadConfigWithSource(requestedDirectory);
7423
- const userConfig = loadedConfig?.config ?? null;
7424
- const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
7425
- const resolvedDirectory = redirectedDirectory ?? requestedDirectory;
7750
+ const scanTarget = resolveScanTarget(requestedDirectory);
7751
+ const userConfig = scanTarget.userConfig;
7752
+ const resolvedDirectory = scanTarget.resolvedDirectory;
7426
7753
  setJsonReportDirectory(resolvedDirectory);
7427
- if (redirectedDirectory && !isQuiet) {
7754
+ if (scanTarget.didRedirectViaRootDir && !isQuiet) {
7428
7755
  cliLogger.dim(`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`);
7429
7756
  cliLogger.break();
7430
7757
  }
@@ -7475,12 +7802,9 @@ const inspectAction = async (directory, flags) => {
7475
7802
  ...diagnostic,
7476
7803
  filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
7477
7804
  }));
7478
- if (isJsonMode) writeJsonReport(buildJsonReport({
7479
- version: VERSION,
7480
- directory: resolvedDirectory,
7481
- mode: "staged",
7482
- diff: null,
7483
- scans: [{
7805
+ finalizeScans({
7806
+ diagnostics: remappedDiagnostics,
7807
+ completedScans: [{
7484
7808
  directory: resolvedDirectory,
7485
7809
  result: {
7486
7810
  ...scanResult,
@@ -7491,11 +7815,15 @@ const inspectAction = async (directory, flags) => {
7491
7815
  }
7492
7816
  }
7493
7817
  }],
7494
- totalElapsedMilliseconds: performance.now() - startTime
7495
- }));
7496
- if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
7497
- const ciFailureDiagnostics = filterDiagnosticsForSurface(remappedDiagnostics, "ciFailure", userConfig);
7498
- if (!isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
7818
+ mode: "staged",
7819
+ diff: null,
7820
+ isJsonMode,
7821
+ isScoreOnly,
7822
+ flags,
7823
+ userConfig,
7824
+ resolvedDirectory,
7825
+ startTime
7826
+ });
7499
7827
  } finally {
7500
7828
  snapshot.cleanup();
7501
7829
  }
@@ -7516,6 +7844,7 @@ const inspectAction = async (directory, flags) => {
7516
7844
  }
7517
7845
  const allDiagnostics = [];
7518
7846
  const completedScans = [];
7847
+ const isMultiProject = projectDirectories.length > 1;
7519
7848
  for (const projectDirectory of projectDirectories) {
7520
7849
  let includePaths;
7521
7850
  if (isDiffMode) {
@@ -7529,42 +7858,68 @@ const inspectAction = async (directory, flags) => {
7529
7858
  }
7530
7859
  includePaths = changedSourceFiles;
7531
7860
  }
7532
- if (!isQuiet) {
7533
- cliLogger.dim(`Scanning ${projectDirectory}...`);
7534
- cliLogger.break();
7535
- }
7861
+ if (!isQuiet && !isMultiProject) cliLogger.dim(" ");
7536
7862
  const scanResult = await inspect(projectDirectory, {
7537
7863
  ...scanOptions,
7538
7864
  includePaths,
7539
- configOverride: userConfig
7865
+ configOverride: userConfig,
7866
+ suppressRendering: isMultiProject
7540
7867
  });
7541
7868
  allDiagnostics.push(...scanResult.diagnostics);
7542
7869
  completedScans.push({
7543
7870
  directory: projectDirectory,
7544
7871
  result: scanResult
7545
7872
  });
7546
- if (!isQuiet) cliLogger.break();
7873
+ if (!isQuiet && !isMultiProject) cliLogger.break();
7547
7874
  }
7548
- if (isJsonMode) writeJsonReport(buildJsonReport({
7549
- version: VERSION,
7550
- directory: resolvedDirectory,
7875
+ if (!isQuiet && isMultiProject && completedScans.length > 0) await Effect.runPromise(printMultiProjectSummary({
7876
+ completedScans,
7877
+ userConfig,
7878
+ verbose: Boolean(flags.verbose)
7879
+ }));
7880
+ finalizeScans({
7881
+ diagnostics: allDiagnostics,
7882
+ completedScans,
7551
7883
  mode: isDiffMode ? "diff" : "full",
7552
7884
  diff: isDiffMode ? diffInfo : null,
7553
- scans: completedScans,
7554
- totalElapsedMilliseconds: performance.now() - startTime
7555
- }));
7556
- if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
7557
- const ciFailureDiagnostics = filterDiagnosticsForSurface(allDiagnostics, "ciFailure", userConfig);
7558
- if (!isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
7559
- await promptInstallSetup({
7560
- projectRoot: resolvedDirectory,
7561
- hasScoredScan: completedScans.some((scan) => scan.result.score !== null),
7562
- issueCount: filterDiagnosticsForSurface(allDiagnostics, scanOptions.outputSurface ?? "cli", userConfig).length,
7563
7885
  isJsonMode,
7564
7886
  isScoreOnly,
7565
- isStaged: Boolean(flags.staged),
7566
- skipPrompts
7887
+ flags,
7888
+ userConfig,
7889
+ resolvedDirectory,
7890
+ startTime
7891
+ });
7892
+ const setupProjectRoot = resolveInstallSetupProjectRoot({
7893
+ scanRoot: resolvedDirectory,
7894
+ completedScanDirectories: completedScans.map((scan) => scan.directory)
7567
7895
  });
7896
+ if (setupProjectRoot !== null) {
7897
+ const hasScoredScan = completedScans.some((scan) => scan.result.score !== null);
7898
+ await promptInstallSetup({
7899
+ projectRoot: setupProjectRoot,
7900
+ hasScoredScan,
7901
+ issueCount: filterDiagnosticsForSurface(allDiagnostics, scanOptions.outputSurface ?? "cli", userConfig).length,
7902
+ isJsonMode,
7903
+ isScoreOnly,
7904
+ isStaged: Boolean(flags.staged),
7905
+ skipPrompts
7906
+ });
7907
+ if (shouldShowAgentInstallHint({
7908
+ projectRoot: setupProjectRoot,
7909
+ hasScoredScan,
7910
+ isJsonMode,
7911
+ isScoreOnly,
7912
+ isStaged: Boolean(flags.staged)
7913
+ })) printAgentInstallHint();
7914
+ }
7915
+ if (!skipPrompts && !isQuiet && allDiagnostics.length > 0) {
7916
+ const lastScan = completedScans[completedScans.length - 1];
7917
+ await promptCopyIssues({
7918
+ diagnostics: allDiagnostics,
7919
+ score: lastScan?.result.score ?? null,
7920
+ projectName: lastScan?.result.project.projectName ?? path.basename(resolvedDirectory)
7921
+ });
7922
+ }
7568
7923
  } catch (error) {
7569
7924
  if (isJsonMode) {
7570
7925
  writeJsonErrorReport(error);
@@ -7765,7 +8120,7 @@ const buildAgentHookScript = () => [
7765
8120
  "const input = readInput();",
7766
8121
  "const scanOutput = fs.readFileSync(outputPath, 'utf8').trim();",
7767
8122
  "if (!scanOutput) process.exit(0);",
7768
- "const message = `React Doctor found issues in the changed files. Review this output and fix the regressions before finishing:\\n\\n${scanOutput}`;",
8123
+ "const message = `React Doctor found issues in the changed files. Review this output and fix the regressions before finishing. For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.\\n\\n${scanOutput}`;",
7769
8124
  "if (input.hook_event_name === 'PostToolBatch') {",
7770
8125
  " console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: message } }));",
7771
8126
  "} else {",
@@ -7881,24 +8236,6 @@ const installDirectGitHook = (options) => {
7881
8236
  };
7882
8237
  //#endregion
7883
8238
  //#region src/cli/utils/install-git-hook-config-managers.ts
7884
- const installSimpleGitHooks = (options) => {
7885
- const packageJsonPath = getPackageJsonPath(options.projectRoot);
7886
- const didHookExist = existsSync(packageJsonPath);
7887
- const packageJson = readPackageJson(options.projectRoot);
7888
- const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
7889
- const existingConfig = nextPackageJson["simple-git-hooks"];
7890
- const nextConfig = isRecord(existingConfig) ? { ...existingConfig } : {};
7891
- const existingPreCommit = typeof nextConfig["pre-commit"] === "string" ? nextConfig["pre-commit"] : "";
7892
- nextConfig["pre-commit"] = existingPreCommit.includes("react-doctor --staged --fail-on warning") ? existingPreCommit : [existingPreCommit, NON_BLOCKING_REACT_DOCTOR_COMMAND].filter(Boolean).join("\n");
7893
- nextPackageJson["simple-git-hooks"] = nextConfig;
7894
- writeJsonFile$1(packageJsonPath, nextPackageJson);
7895
- removeLegacyManagedRunner(options.projectRoot);
7896
- return {
7897
- hookPath: packageJsonPath,
7898
- kind: "simple-git-hooks",
7899
- status: didHookExist ? "updated" : "created"
7900
- };
7901
- };
7902
8239
  const appendStringCommand = (existingCommand) => {
7903
8240
  const existingCommandText = typeof existingCommand === "string" ? existingCommand : Array.isArray(existingCommand) ? existingCommand.filter((entry) => typeof entry === "string").join("\n") : "";
7904
8241
  return existingCommandText.includes("react-doctor --staged --fail-on warning") ? existingCommandText : [existingCommandText, NON_BLOCKING_REACT_DOCTOR_COMMAND].filter(Boolean).join("\n");
@@ -7907,58 +8244,63 @@ const appendArrayCommand = (existingCommands) => {
7907
8244
  const commands = Array.isArray(existingCommands) ? existingCommands.filter((entry) => typeof entry === "string") : typeof existingCommands === "string" ? [existingCommands] : [];
7908
8245
  return commands.some((command) => command.includes("react-doctor --staged --fail-on warning")) ? commands : [...commands, NON_BLOCKING_REACT_DOCTOR_COMMAND];
7909
8246
  };
7910
- const installPackageJsonPreCommitString = (options, kind, configKey) => {
7911
- const packageJsonPath = getPackageJsonPath(options.projectRoot);
7912
- const didHookExist = existsSync(packageJsonPath);
7913
- const packageJson = readPackageJson(options.projectRoot);
7914
- const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
7915
- const existingConfig = nextPackageJson[configKey];
7916
- const nextConfig = isRecord(existingConfig) ? { ...existingConfig } : {};
7917
- nextConfig["pre-commit"] = appendStringCommand(nextConfig["pre-commit"]);
7918
- nextPackageJson[configKey] = nextConfig;
7919
- writeJsonFile$1(packageJsonPath, nextPackageJson);
7920
- removeLegacyManagedRunner(options.projectRoot);
7921
- return {
7922
- hookPath: packageJsonPath,
7923
- kind,
7924
- status: didHookExist ? "updated" : "created"
7925
- };
7926
- };
7927
- const installGhooks = (options) => {
7928
- const packageJsonPath = getPackageJsonPath(options.projectRoot);
7929
- const didHookExist = existsSync(packageJsonPath);
7930
- const packageJson = readPackageJson(options.projectRoot);
7931
- const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
7932
- const existingConfig = nextPackageJson.config;
7933
- const nextConfig = isRecord(existingConfig) ? { ...existingConfig } : {};
7934
- const existingGhooks = nextConfig.ghooks;
7935
- const nextGhooks = isRecord(existingGhooks) ? { ...existingGhooks } : {};
7936
- nextGhooks["pre-commit"] = appendStringCommand(nextGhooks["pre-commit"]);
7937
- nextConfig.ghooks = nextGhooks;
7938
- nextPackageJson.config = nextConfig;
7939
- writeJsonFile$1(packageJsonPath, nextPackageJson);
7940
- removeLegacyManagedRunner(options.projectRoot);
7941
- return {
7942
- hookPath: packageJsonPath,
7943
- kind: "ghooks",
7944
- status: didHookExist ? "updated" : "created"
7945
- };
7946
- };
7947
- const installPreCommitNpm = (options) => {
8247
+ const installPackageJsonHook = (options, strategy) => {
7948
8248
  const packageJsonPath = getPackageJsonPath(options.projectRoot);
7949
8249
  const didHookExist = existsSync(packageJsonPath);
7950
8250
  const packageJson = readPackageJson(options.projectRoot);
7951
8251
  const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
7952
- nextPackageJson["pre-commit"] = appendArrayCommand(nextPackageJson["pre-commit"]);
8252
+ const parentKeys = strategy.path.slice(0, -1);
8253
+ const leafKey = strategy.path[strategy.path.length - 1];
8254
+ let parent = nextPackageJson;
8255
+ for (const key of parentKeys) {
8256
+ const existing = parent[key];
8257
+ const cloned = isRecord(existing) ? { ...existing } : {};
8258
+ parent[key] = cloned;
8259
+ parent = cloned;
8260
+ }
8261
+ parent[leafKey] = strategy.leafShape === "array" ? appendArrayCommand(parent[leafKey]) : appendStringCommand(parent[leafKey]);
7953
8262
  writeJsonFile$1(packageJsonPath, nextPackageJson);
7954
8263
  removeLegacyManagedRunner(options.projectRoot);
7955
8264
  return {
7956
8265
  hookPath: packageJsonPath,
7957
- kind: "pre-commit-npm",
8266
+ kind: strategy.kind,
7958
8267
  status: didHookExist ? "updated" : "created"
7959
8268
  };
7960
8269
  };
7961
- const installPrettyQuick = (options) => installPackageJsonPreCommitString(options, "pretty-quick", "gitHooks");
8270
+ const installSimpleGitHooks = (options) => installPackageJsonHook(options, {
8271
+ kind: "simple-git-hooks",
8272
+ path: ["simple-git-hooks", "pre-commit"],
8273
+ leafShape: "string"
8274
+ });
8275
+ const installGhooks = (options) => installPackageJsonHook(options, {
8276
+ kind: "ghooks",
8277
+ path: [
8278
+ "config",
8279
+ "ghooks",
8280
+ "pre-commit"
8281
+ ],
8282
+ leafShape: "string"
8283
+ });
8284
+ const installPreCommitNpm = (options) => installPackageJsonHook(options, {
8285
+ kind: "pre-commit-npm",
8286
+ path: ["pre-commit"],
8287
+ leafShape: "array"
8288
+ });
8289
+ const installPrettyQuick = (options) => installPackageJsonHook(options, {
8290
+ kind: "pretty-quick",
8291
+ path: ["gitHooks", "pre-commit"],
8292
+ leafShape: "string"
8293
+ });
8294
+ const installYorkie = (options) => installPackageJsonHook(options, {
8295
+ kind: "yorkie",
8296
+ path: ["gitHooks", "pre-commit"],
8297
+ leafShape: "string"
8298
+ });
8299
+ const installGitHooksJs = (options) => installPackageJsonHook(options, {
8300
+ kind: "git-hooks-js",
8301
+ path: ["git-hooks", "pre-commit"],
8302
+ leafShape: "string"
8303
+ });
7962
8304
  const appendIndentedBlockToTopLevelSection = (content, sectionName, block) => {
7963
8305
  const normalizedContent = ensureTrailingNewline(content);
7964
8306
  const match = new RegExp(`^${sectionName}:\\s*$`, "m").exec(normalizedContent);
@@ -8188,9 +8530,9 @@ const installReactDoctorGitHook = (options) => {
8188
8530
  if (options.kind === "lefthook") return installLefthook(options);
8189
8531
  if (options.kind === "pre-commit") return installPreCommit(options);
8190
8532
  if (options.kind === "overcommit") return installOvercommit(options);
8191
- if (options.kind === "yorkie") return installPackageJsonPreCommitString(options, "yorkie", "gitHooks");
8533
+ if (options.kind === "yorkie") return installYorkie(options);
8192
8534
  if (options.kind === "ghooks") return installGhooks(options);
8193
- if (options.kind === "git-hooks-js") return installPackageJsonPreCommitString(options, "git-hooks-js", "git-hooks");
8535
+ if (options.kind === "git-hooks-js") return installGitHooksJs(options);
8194
8536
  if (options.kind === "pre-commit-npm") return installPreCommitNpm(options);
8195
8537
  if (options.kind === "pretty-quick") return installPrettyQuick(options);
8196
8538
  return installDirectGitHook(options);
@@ -8198,7 +8540,6 @@ const installReactDoctorGitHook = (options) => {
8198
8540
  //#endregion
8199
8541
  //#region src/cli/utils/install-skill.ts
8200
8542
  var install_skill_exports = /* @__PURE__ */ __exportAll({ runInstallSkill: () => runInstallSkill });
8201
- const NATIVE_AGENT_HOOK_AGENTS = new Set(["claude-code", "cursor"]);
8202
8543
  const CONFIG_ONLY_GIT_HOOK_KINDS = new Set([
8203
8544
  "ghooks",
8204
8545
  "git-hooks-js",
@@ -8210,35 +8551,167 @@ const CONFIG_ONLY_GIT_HOOK_KINDS = new Set([
8210
8551
  "simple-git-hooks",
8211
8552
  "yorkie"
8212
8553
  ]);
8554
+ const PACKAGE_MANAGER_LOCKFILES = [
8555
+ {
8556
+ packageManager: "pnpm",
8557
+ fileName: "pnpm-lock.yaml"
8558
+ },
8559
+ {
8560
+ packageManager: "yarn",
8561
+ fileName: "yarn.lock"
8562
+ },
8563
+ {
8564
+ packageManager: "bun",
8565
+ fileName: "bun.lockb"
8566
+ },
8567
+ {
8568
+ packageManager: "bun",
8569
+ fileName: "bun.lock"
8570
+ },
8571
+ {
8572
+ packageManager: "npm",
8573
+ fileName: "package-lock.json"
8574
+ }
8575
+ ];
8576
+ const findNearestFileDirectory = (startDirectory, fileNames) => {
8577
+ let currentDirectory = path.resolve(startDirectory);
8578
+ while (true) {
8579
+ if (fileNames.some((fileName) => existsSync(path.join(currentDirectory, fileName)))) return currentDirectory;
8580
+ const parentDirectory = path.dirname(currentDirectory);
8581
+ if (parentDirectory === currentDirectory) return null;
8582
+ currentDirectory = parentDirectory;
8583
+ }
8584
+ };
8585
+ const detectPackageManager = (projectRoot) => {
8586
+ let currentDirectory = path.resolve(projectRoot);
8587
+ while (true) {
8588
+ const packageJson = readPackageJson(currentDirectory);
8589
+ if (isRecord(packageJson) && typeof packageJson.packageManager === "string") {
8590
+ const packageManagerName = packageJson.packageManager.split("@")[0];
8591
+ if (packageManagerName === "pnpm" || packageManagerName === "yarn" || packageManagerName === "bun" || packageManagerName === "npm") return packageManagerName;
8592
+ }
8593
+ const parentDirectory = path.dirname(currentDirectory);
8594
+ if (parentDirectory === currentDirectory) break;
8595
+ currentDirectory = parentDirectory;
8596
+ }
8597
+ const lockfileDirectory = findNearestFileDirectory(projectRoot, PACKAGE_MANAGER_LOCKFILES.map((lockfile) => lockfile.fileName));
8598
+ return PACKAGE_MANAGER_LOCKFILES.find((lockfile) => lockfileDirectory !== null && existsSync(path.join(lockfileDirectory, lockfile.fileName)))?.packageManager ?? "npm";
8599
+ };
8600
+ const packageManagerNeedsWorkspaceFlag = (projectRoot) => existsSync(path.join(projectRoot, "pnpm-workspace.yaml")) || findNearestFileDirectory(projectRoot, ["pnpm-workspace.yaml"]) !== null;
8601
+ const buildInstallCommand = (projectRoot) => {
8602
+ const packageManager = detectPackageManager(projectRoot);
8603
+ const packageSpecifier = `${DOCTOR_PACKAGE_NAME}@latest`;
8604
+ if (packageManager === "npm") return {
8605
+ command: "npm",
8606
+ args: [
8607
+ "install",
8608
+ "--save-dev",
8609
+ packageSpecifier
8610
+ ],
8611
+ cwd: projectRoot
8612
+ };
8613
+ if (packageManager === "yarn") return {
8614
+ command: "yarn",
8615
+ args: [
8616
+ "add",
8617
+ "--dev",
8618
+ packageSpecifier
8619
+ ],
8620
+ cwd: projectRoot
8621
+ };
8622
+ if (packageManager === "bun") return {
8623
+ command: "bun",
8624
+ args: [
8625
+ "add",
8626
+ "--dev",
8627
+ packageSpecifier
8628
+ ],
8629
+ cwd: projectRoot
8630
+ };
8631
+ return {
8632
+ command: "pnpm",
8633
+ args: [
8634
+ "add",
8635
+ "--save-dev",
8636
+ ...packageManagerNeedsWorkspaceFlag(projectRoot) ? ["-w"] : [],
8637
+ packageSpecifier
8638
+ ],
8639
+ cwd: projectRoot
8640
+ };
8641
+ };
8642
+ const defaultInstallDependencyRunner = (input) => {
8643
+ execFileSync(input.command, [...input.args], {
8644
+ cwd: input.cwd,
8645
+ stdio: "inherit",
8646
+ env: {
8647
+ ...process.env,
8648
+ REACT_DOCTOR_INSTALL: "1"
8649
+ }
8650
+ });
8651
+ };
8652
+ const installReactDoctorDependency = async (options) => {
8653
+ const packageJson = readPackageJson(options.projectRoot);
8654
+ if (!isRecord(packageJson)) return {
8655
+ dependencyStatus: "skipped",
8656
+ dependencyReason: "missing-or-invalid-package-json"
8657
+ };
8658
+ if (hasDoctorDependency(packageJson)) return { dependencyStatus: "existing" };
8659
+ if (packageJson.devDependencies !== void 0 && !isRecord(packageJson.devDependencies)) return {
8660
+ dependencyStatus: "skipped",
8661
+ dependencyReason: "invalid-dev-dependencies"
8662
+ };
8663
+ const runnerInput = buildInstallCommand(options.projectRoot);
8664
+ await (options.runner ?? defaultInstallDependencyRunner)(runnerInput);
8665
+ return { dependencyStatus: "created" };
8666
+ };
8213
8667
  const buildManualGitHookTarget = (hookPath, projectRoot) => ({
8214
8668
  hookPath,
8215
8669
  runnerRoot: projectRoot,
8216
8670
  kind: "git"
8217
8671
  });
8218
- const hasNativeAgentHookTarget = (agents) => agents.some((agent) => NATIVE_AGENT_HOOK_AGENTS.has(agent));
8219
8672
  const formatGitHookInstallMessage = (hookResult) => {
8220
8673
  if (CONFIG_ONLY_GIT_HOOK_KINDS.has(hookResult.kind)) return `React Doctor pre-commit config ${hookResult.status} at ${hookResult.hookPath}. Run your hook manager's install command if hooks are not already installed.`;
8221
8674
  return `React Doctor pre-commit hook ${hookResult.status} at ${hookResult.hookPath}.`;
8222
8675
  };
8223
8676
  const formatDoctorScriptInstallMessage = (scriptResult) => {
8224
8677
  const messages = [];
8225
- if (scriptResult.scriptStatus === "created") messages.push("Added package script: doctor.");
8226
- else if (scriptResult.scriptStatus === "existing") messages.push("Package script already exists: doctor.");
8678
+ const scriptName = scriptResult.scriptName ?? "doctor";
8679
+ if (scriptResult.scriptStatus === "created") messages.push(`Added package script: ${scriptName}.`);
8680
+ else if (scriptResult.scriptStatus === "existing") messages.push(`Package script already exists: ${scriptName}.`);
8681
+ else if (scriptResult.scriptReason === "script-names-taken") messages.push("Skipped package script: doctor and react-doctor are already taken.");
8682
+ else if (scriptResult.scriptReason === "doctor-script-taken") messages.push("Skipped package script: doctor and react-doctor scripts already exist.");
8227
8683
  else if (scriptResult.scriptReason === "invalid-scripts") messages.push(`Skipped package script: scripts field is not an object.`);
8228
8684
  else messages.push("Skipped package script: package.json missing or invalid.");
8229
- if (scriptResult.dependencyStatus === "created") messages.push("Added dev dependency: react-doctor.");
8230
- else if (scriptResult.dependencyStatus === "existing") messages.push("React Doctor dependency already exists.");
8231
- else if (scriptResult.dependencyReason === "invalid-dev-dependencies") messages.push("Skipped dev dependency: devDependencies field is not an object.");
8232
- else messages.push("Skipped dev dependency: package.json missing or invalid.");
8233
8685
  return messages.join(" ");
8234
8686
  };
8235
- const installReactDoctorPackageSetup = (projectRoot) => {
8236
- const scriptSpinner = spinner("Installing React Doctor package setup...").start();
8687
+ const formatDependencyInstallMessage = (result) => {
8688
+ if (result.dependencyStatus === "created") return "Installed dev dependency: react-doctor.";
8689
+ if (result.dependencyStatus === "existing") return "React Doctor dependency already exists.";
8690
+ if (result.dependencyReason === "invalid-dev-dependencies") return "Skipped dev dependency install: devDependencies field is not an object.";
8691
+ return "Skipped dev dependency install: package.json missing or invalid.";
8692
+ };
8693
+ const installReactDoctorPackageSetup = async (projectRoot, dependencyRunner) => {
8694
+ const scriptSpinner = spinner("Installing React Doctor package script...").start();
8237
8695
  try {
8238
8696
  const scriptResult = installDoctorScript({ projectRoot });
8239
8697
  scriptSpinner.succeed(formatDoctorScriptInstallMessage(scriptResult));
8240
8698
  } catch (error) {
8241
- scriptSpinner.fail("Failed to install React Doctor package setup.");
8699
+ scriptSpinner.fail("Failed to install React Doctor package script.");
8700
+ throw error;
8701
+ }
8702
+ const dependencySpinner = spinner("Installing React Doctor package...").start();
8703
+ try {
8704
+ const dependencyResult = await installReactDoctorDependency({
8705
+ projectRoot,
8706
+ runner: dependencyRunner
8707
+ });
8708
+ if (dependencyResult.dependencyStatus === "skipped") {
8709
+ dependencySpinner.fail(formatDependencyInstallMessage(dependencyResult));
8710
+ return;
8711
+ }
8712
+ dependencySpinner.succeed(formatDependencyInstallMessage(dependencyResult));
8713
+ } catch (error) {
8714
+ dependencySpinner.fail("Failed to install React Doctor package.");
8242
8715
  throw error;
8243
8716
  }
8244
8717
  };
@@ -8247,9 +8720,9 @@ const getSkillSourceDirectory = () => {
8247
8720
  return path.join(distDirectory, "skills", SKILL_NAME);
8248
8721
  };
8249
8722
  const runInstallSkill = async (options = {}) => {
8250
- const projectRoot = options.projectRoot ?? process.cwd();
8723
+ const requestedProjectRoot = options.projectRoot ?? process.cwd();
8724
+ const projectRoot = findNearestPackageDirectory(requestedProjectRoot) ?? requestedProjectRoot;
8251
8725
  const sourceDir = options.sourceDir ?? getSkillSourceDirectory();
8252
- if (!options.dryRun) installReactDoctorPackageSetup(projectRoot);
8253
8726
  if (!existsSync(path.join(sourceDir, SKILL_MANIFEST_FILE))) {
8254
8727
  cliLogger.error(`Could not locate the ${SKILL_NAME} skill bundled with this package.`);
8255
8728
  process.exitCode = 1;
@@ -8270,7 +8743,7 @@ const runInstallSkill = async (options = {}) => {
8270
8743
  const selectedAgents = skipPrompts ? detectedAgents : (await prompts({
8271
8744
  type: "multiselect",
8272
8745
  name: "agents",
8273
- message: `Install the ${highlighter.info("react-doctor")} skill for:`,
8746
+ message: `Install the ${highlighter.info(`/react-doctor`)} skill for:`,
8274
8747
  choices: detectedAgents.map((agent) => ({
8275
8748
  title: getSkillAgentConfig(agent).displayName,
8276
8749
  value: agent,
@@ -8283,20 +8756,15 @@ const runInstallSkill = async (options = {}) => {
8283
8756
  const shouldInstallGitHook = gitHookPath !== null && gitHookPath !== void 0 && (Boolean(options.yes) || !skipPrompts && Boolean((await prompts({
8284
8757
  type: "confirm",
8285
8758
  name: "installGitHook",
8286
- message: "Run React Doctor on staged files before commits? (non-blocking git hook)",
8759
+ message: "Check for issues before each commit?",
8287
8760
  initial: true
8288
8761
  }, promptOptions)).installGitHook));
8289
- const shouldInstallAgentHooks = Boolean(options.agentHooks) || !skipPrompts && hasNativeAgentHookTarget(selectedAgents) && Boolean((await prompts({
8290
- type: "confirm",
8291
- name: "installAgentHooks",
8292
- message: "Install native agent hooks after file edits? (Claude Code / Cursor)",
8293
- initial: false
8294
- }, promptOptions)).installAgentHooks);
8762
+ const shouldInstallAgentHooks = Boolean(options.agentHooks);
8295
8763
  if (options.dryRun) {
8296
8764
  cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
8297
8765
  for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
8298
8766
  cliLogger.dim(` Source: ${sourceDir}`);
8299
- cliLogger.dim(" Package script: doctor");
8767
+ cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
8300
8768
  cliLogger.dim(" Dev dependency: react-doctor");
8301
8769
  if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
8302
8770
  if (shouldInstallAgentHooks) cliLogger.dim(" Agent hooks: Claude Code / Cursor when selected");
@@ -8317,6 +8785,7 @@ const runInstallSkill = async (options = {}) => {
8317
8785
  installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
8318
8786
  throw error;
8319
8787
  }
8788
+ await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
8320
8789
  if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
8321
8790
  const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
8322
8791
  try {
@@ -8346,6 +8815,49 @@ const runInstallSkill = async (options = {}) => {
8346
8815
  throw error;
8347
8816
  }
8348
8817
  }
8818
+ const workflowsDirectory = path.join(projectRoot, ".github", "workflows");
8819
+ const workflowTargetPath = path.join(workflowsDirectory, "react-doctor.yml");
8820
+ if (!existsSync(workflowTargetPath) && !skipPrompts) {
8821
+ const hasExistingWorkflows = existsSync(workflowsDirectory);
8822
+ const { shouldInstallWorkflow } = await prompts({
8823
+ type: "confirm",
8824
+ name: "shouldInstallWorkflow",
8825
+ message: "Add a GitHub Actions workflow to scan PRs?",
8826
+ initial: hasExistingWorkflows
8827
+ }, promptOptions);
8828
+ if (shouldInstallWorkflow) {
8829
+ if (!hasExistingWorkflows) mkdirSync(workflowsDirectory, { recursive: true });
8830
+ const workflowSpinner = spinner("Adding GitHub Actions workflow...").start();
8831
+ try {
8832
+ writeFileSync(workflowTargetPath, [
8833
+ "name: React Doctor",
8834
+ "",
8835
+ "on:",
8836
+ " pull_request:",
8837
+ " branches: [main]",
8838
+ "",
8839
+ "permissions:",
8840
+ " contents: read",
8841
+ " pull-requests: write",
8842
+ "",
8843
+ "jobs:",
8844
+ " react-doctor:",
8845
+ " runs-on: ubuntu-latest",
8846
+ " steps:",
8847
+ " - uses: actions/checkout@v4",
8848
+ " - uses: millionco/react-doctor@main",
8849
+ " with:",
8850
+ " github-token: ${{ secrets.GITHUB_TOKEN }}",
8851
+ " diff: main",
8852
+ ""
8853
+ ].join("\n"));
8854
+ workflowSpinner.succeed(`GitHub Actions workflow added at ${path.relative(projectRoot, workflowTargetPath)}.`);
8855
+ } catch (error) {
8856
+ workflowSpinner.fail("Failed to add GitHub Actions workflow.");
8857
+ throw error;
8858
+ }
8859
+ }
8860
+ }
8349
8861
  };
8350
8862
  //#endregion
8351
8863
  //#region src/cli/commands/install.ts
@@ -8366,13 +8878,10 @@ const installAction = async (options, command) => {
8366
8878
  //#endregion
8367
8879
  //#region src/cli/utils/exit-gracefully.ts
8368
8880
  const exitGracefully = () => {
8369
- if (isJsonModeActive()) {
8370
- writeJsonErrorReport(/* @__PURE__ */ new Error("Scan cancelled by user (SIGINT/SIGTERM)"));
8371
- process.exit(130);
8372
- }
8373
- cliLogger.break();
8374
- cliLogger.log("Cancelled.");
8375
- cliLogger.break();
8881
+ try {
8882
+ if (isJsonModeActive()) writeJsonErrorReport(/* @__PURE__ */ new Error("Scan cancelled by user (SIGINT/SIGTERM)"));
8883
+ else console.log("\nCancelled.\n");
8884
+ } catch {}
8376
8885
  process.exit(130);
8377
8886
  };
8378
8887
  //#endregion