react-doctor 0.2.5 → 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-CISyjOAb.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
  });
@@ -6545,8 +6560,11 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
6545
6560
  });
6546
6561
  const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
6547
6562
  //#endregion
6563
+ //#region src/cli/utils/version.ts
6564
+ const VERSION = "0.2.7";
6565
+ //#endregion
6548
6566
  //#region src/inspect.ts
6549
- const silentConsole = new Proxy({}, { get: () => () => void 0 });
6567
+ const silentConsole = makeNoopConsole();
6550
6568
  const runConsole = (effect) => {
6551
6569
  Effect.runSync(effect);
6552
6570
  };
@@ -6562,6 +6580,8 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
6562
6580
  scoreOnly: inputOptions.scoreOnly ?? false,
6563
6581
  noScore: inputOptions.noScore ?? userConfig?.noScore ?? false,
6564
6582
  isCi: inputOptions.isCi ?? false,
6583
+ isCiOrCodingAgentEnvironment: isCiOrCodingAgentEnvironment(),
6584
+ isNonInteractiveEnvironment: isNonInteractiveEnvironment(),
6565
6585
  silent: inputOptions.silent ?? false,
6566
6586
  includePaths: inputOptions.includePaths ?? [],
6567
6587
  customRulesOnly: userConfig?.customRulesOnly ?? false,
@@ -6569,39 +6589,24 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
6569
6589
  respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
6570
6590
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
6571
6591
  ignoredTags: buildIgnoredTags(userConfig),
6572
- outputSurface: inputOptions.outputSurface ?? "cli"
6592
+ outputSurface: inputOptions.outputSurface ?? "cli",
6593
+ suppressRendering: inputOptions.suppressRendering ?? false
6573
6594
  });
6574
- /**
6575
- * Tagged-reason → legacy-class dispatch for the public `inspect()`
6576
- * contract. Each case converts a `ReactDoctorError` reason into the
6577
- * historical thrown class (`NoReactDependencyError`, …) via
6578
- * `Effect.die`, which `Effect.runPromise` re-throws unchanged.
6579
- * Unmatched reasons (GitInvocationFailed, OxlintSpawnFailed, …)
6580
- * flow through as the original tagged `ReactDoctorError` instance.
6581
- *
6582
- * Adding a new public thrown class is one new entry on this object
6583
- * — no `instanceof` checks, no `switch` ladder. The function form
6584
- * (vs. the standalone constant) is required so `Effect.catchReasons`
6585
- * gets the surrounding Effect's error channel for type inference.
6586
- */
6587
- const restoreLegacyThrow = (effect) => effect.pipe(Effect.catchReasons("ReactDoctorError", {
6588
- NoReactDependency: (reason) => Effect.die(new NoReactDependencyError(reason.directory)),
6589
- ProjectNotFound: (reason) => Effect.die(new ProjectNotFoundError(reason.directory)),
6590
- AmbiguousProject: (reason) => Effect.die(new AmbiguousProjectError(reason.directory, [...reason.candidates]))
6591
- }, (_reason, error) => Effect.die(new Error(error.message))));
6592
6595
  const inspect = async (directory, inputOptions = {}) => {
6593
6596
  const startTime = performance.now();
6594
6597
  const hasConfigOverride = inputOptions.configOverride !== void 0;
6595
- let scanDirectory = directory;
6598
+ let scanDirectory;
6596
6599
  let userConfig;
6597
- let configSourceDirectory = null;
6598
- if (hasConfigOverride) userConfig = inputOptions.configOverride ?? null;
6599
- else {
6600
- const loadedConfig = loadConfigWithSource(directory);
6601
- const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
6602
- if (redirectedDirectory) scanDirectory = redirectedDirectory;
6603
- userConfig = loadedConfig?.config ?? null;
6604
- 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;
6605
6610
  }
6606
6611
  const options = mergeInspectOptions(inputOptions, userConfig);
6607
6612
  const wasSpinnerSilent = isSpinnerSilent();
@@ -6616,77 +6621,50 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
6616
6621
  const isDiffMode = options.includePaths.length > 0;
6617
6622
  const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
6618
6623
  const lintBindingMissing = options.lint && !resolvedNodeBinaryPath;
6624
+ const shouldShowProgressSpinners = !options.isCiOrCodingAgentEnvironment && !options.silent && !options.scoreOnly && options.lint && Boolean(resolvedNodeBinaryPath);
6619
6625
  const layers = buildRuntimeLayers({
6620
6626
  directory,
6621
6627
  hasConfigOverride,
6622
6628
  userConfig,
6623
6629
  configSourceDirectory,
6624
6630
  shouldSkipLint: !options.lint || lintBindingMissing,
6625
- shouldRunDeadCode: options.deadCode
6626
- });
6627
- const program = Effect.gen(function* () {
6628
- const spinnerRef = yield* Ref.make(null);
6629
- return {
6630
- output: yield* runInspect({
6631
- directory,
6632
- includePaths: options.includePaths,
6633
- customRulesOnly: options.customRulesOnly,
6634
- respectInlineDisables: options.respectInlineDisables,
6635
- adoptExistingLintConfig: options.adoptExistingLintConfig,
6636
- ignoredTags: options.ignoredTags,
6637
- nodeBinaryPath: resolvedNodeBinaryPath ?? void 0,
6638
- runDeadCode: options.deadCode,
6639
- isCi: options.isCi
6640
- }, {
6641
- beforeLint: (projectInfo, lintIncludePaths) => Effect.gen(function* () {
6642
- const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
6643
- if (!options.scoreOnly) yield* printProjectDetection({
6644
- projectInfo,
6645
- userConfig,
6646
- isDiffMode,
6647
- includePaths: options.includePaths,
6648
- lintSourceFileCount
6649
- });
6650
- if (options.lint && resolvedNodeBinaryPath && !options.scoreOnly && !options.silent) {
6651
- const handle = spinner("Running lint checks...").start();
6652
- yield* Ref.set(spinnerRef, {
6653
- succeed: (text) => handle.succeed(text),
6654
- fail: (text) => handle.fail(text)
6655
- });
6656
- }
6657
- }),
6658
- afterLint: (didFail) => Effect.gen(function* () {
6659
- const handle = yield* Ref.get(spinnerRef);
6660
- if (handle && !didFail) handle.succeed("Running lint checks.");
6661
- })
6662
- }),
6663
- finalHandle: yield* Ref.get(spinnerRef)
6664
- };
6631
+ shouldRunDeadCode: options.deadCode,
6632
+ shouldComputeScore: !options.noScore,
6633
+ shouldShowProgressSpinners
6665
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
+ }) });
6666
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));
6667
- const { output, finalHandle: finalSpinnerHandle } = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
6659
+ const output = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
6668
6660
  const didLintFail = lintBindingMissing || output.didLintFail;
6669
6661
  const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
6670
6662
  const lintFailureReasonTag = output.lintFailureReasonTag;
6671
6663
  const isNativeBindingFailure = lintFailureReasonTag === "OxlintUnavailable" || lintFailureReasonTag === "OxlintSpawnFailed";
6672
- if (!options.scoreOnly && !lintBindingMissing && output.didLintFail && finalSpinnerHandle !== null && lintFailureReason !== null) if (isNativeBindingFailure && /native binding/.test(lintFailureReason)) {
6673
- finalSpinnerHandle.fail(`Lint checks failed — oxlint native binding not found (Node ${process.version}).`);
6674
- runConsole(Console.log(highlighter.gray(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`)));
6675
- } else {
6676
- finalSpinnerHandle.fail("Lint checks failed (non-fatal, skipping).");
6677
- runConsole(Console.error(highlighter.error(lintFailureReason)));
6678
- }
6679
- if (!options.scoreOnly && !options.silent && options.deadCode && !isDiffMode) {
6680
- const deadCodeSpinner = spinner("Analyzing dead code...").start();
6681
- if (output.didDeadCodeFail) deadCodeSpinner.fail("Dead-code analysis failed (non-fatal, skipping).");
6682
- else deadCodeSpinner.succeed("Analyzing dead code.");
6683
- }
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)));
6684
6666
  const inspectDiagnostics = output.diagnostics;
6685
- const scoreDiagnostics = filterDiagnosticsForSurface([...inspectDiagnostics], "score", output.userConfig);
6686
- const score = didLintFail || options.noScore ? null : await calculateScore([...scoreDiagnostics], {
6687
- isCi: options.isCi,
6688
- metadata: output.scoreMetadata
6689
- });
6667
+ const score = didLintFail ? null : output.score;
6690
6668
  const finalizeInput = {
6691
6669
  options,
6692
6670
  elapsedMilliseconds: performance.now() - startTime,
@@ -6722,6 +6700,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
6722
6700
  project,
6723
6701
  elapsedMilliseconds
6724
6702
  });
6703
+ if (options.suppressRendering) return buildResult();
6725
6704
  if (options.scoreOnly) {
6726
6705
  if (score) yield* Console.log(`${score.score}`);
6727
6706
  else yield* Console.log(highlighter.gray(noScoreMessage));
@@ -6746,6 +6725,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
6746
6725
  }
6747
6726
  yield* Console.log("");
6748
6727
  yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory);
6728
+ if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
6749
6729
  if (demotedDiagnosticCount > 0) {
6750
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.`));
6751
6731
  yield* Console.log("");
@@ -6758,7 +6738,8 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
6758
6738
  projectName: project.projectName,
6759
6739
  totalSourceFileCount: lintSourceFileCount,
6760
6740
  noScoreMessage,
6761
- isOffline: !shouldShowShareLink
6741
+ isOffline: !shouldShowShareLink,
6742
+ verbose: options.verbose
6762
6743
  });
6763
6744
  if (hasSkippedChecks) {
6764
6745
  const skippedLabel = skippedChecks.join(" and ");
@@ -6799,6 +6780,58 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
6799
6780
  };
6800
6781
  //#endregion
6801
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
+ };
6802
6835
  /**
6803
6836
  * Effect-typed renderer: every message routes through `Console.error`
6804
6837
  * so test runs can swap `Console` to a capture sink and the output
@@ -6809,9 +6842,9 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
6809
6842
  const handleErrorEffect = (error) => Effect.gen(function* () {
6810
6843
  yield* Console.error("");
6811
6844
  yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
6812
- 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)}`));
6813
6846
  yield* Console.error("");
6814
- yield* Console.error(highlighter.error(isReactDoctorError(error) ? formatReactDoctorError(error) : formatErrorChain(error)));
6847
+ yield* Console.error(highlighter.error(formatErrorForReport(error)));
6815
6848
  yield* Console.error("");
6816
6849
  });
6817
6850
  /**
@@ -6825,25 +6858,33 @@ const handleError = (error, options = { shouldExit: true }) => {
6825
6858
  process.exitCode = 1;
6826
6859
  };
6827
6860
  //#endregion
6828
- //#region src/cli/utils/version.ts
6829
- const VERSION = "0.2.5";
6830
- //#endregion
6831
6861
  //#region src/cli/utils/json-mode.ts
6832
6862
  let context = null;
6833
6863
  /**
6834
- * JSON mode writes the report payload to stdout; any incidental
6835
- * log line printed by an Effect program would corrupt the JSON.
6836
- * Effect's `Console` module resolves to `globalThis.console` by
6837
- * default (see `effect/internal/effect.ts` → `ConsoleRef`), so
6838
- * 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
6839
6869
  * `yield* Console.log(...)` and `cliLogger.*` call sourced from
6840
- * react-doctor or its services. We snapshot the originals (used
6841
- * by `writeJsonReport` → `process.stdout.write`) and never need
6842
- * 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.
6843
6883
  */
6844
6884
  const installSilentConsole = () => {
6845
- const noop = () => {};
6846
- const console = globalThis.console;
6885
+ const noopConsole = makeNoopConsole();
6886
+ const target = globalThis.console;
6887
+ const source = noopConsole;
6847
6888
  for (const key of [
6848
6889
  "log",
6849
6890
  "error",
@@ -6851,7 +6892,7 @@ const installSilentConsole = () => {
6851
6892
  "info",
6852
6893
  "debug",
6853
6894
  "trace"
6854
- ]) console[key] = noop;
6895
+ ]) target[key] = source[key];
6855
6896
  };
6856
6897
  const enableJsonMode = ({ compact, directory }) => {
6857
6898
  context = {
@@ -6917,6 +6958,168 @@ const printBrandedHeader = Effect.gen(function* () {
6917
6958
  yield* Console.log("");
6918
6959
  });
6919
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
6920
7123
  //#region src/cli/utils/git-hook-shared.ts
6921
7124
  const HOOK_FILE_NAME = "pre-commit";
6922
7125
  const HOOK_RELATIVE_PATH = "hooks/pre-commit";
@@ -6991,7 +7194,8 @@ const ensureTrailingNewline = (content) => content.endsWith("\n") ? content : `$
6991
7194
  //#endregion
6992
7195
  //#region src/cli/utils/install-doctor-script.ts
6993
7196
  const DOCTOR_SCRIPT_NAME = "doctor";
6994
- const DOCTOR_SCRIPT_COMMAND = "react-doctor";
7197
+ const FALLBACK_DOCTOR_SCRIPT_NAME = "react-doctor";
7198
+ const DOCTOR_SCRIPT_COMMAND = "npx react-doctor@latest";
6995
7199
  const DOCTOR_PACKAGE_NAME = "react-doctor";
6996
7200
  const DEPENDENCY_FIELD_NAMES = [
6997
7201
  "dependencies",
@@ -6999,50 +7203,87 @@ const DEPENDENCY_FIELD_NAMES = [
6999
7203
  "optionalDependencies",
7000
7204
  "peerDependencies"
7001
7205
  ];
7002
- 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
+ };
7003
7218
  const hasDoctorScript = (projectRoot) => {
7004
- const packageJson = readPackageJson(projectRoot);
7219
+ const packageJson = readPackageJson(findNearestPackageDirectory(projectRoot) ?? projectRoot);
7005
7220
  if (!isRecord(packageJson)) return false;
7006
7221
  const scripts = packageJson.scripts;
7007
- return isRecord(scripts) && Object.hasOwn(scripts, "doctor");
7222
+ if (!isRecord(scripts)) return false;
7223
+ return isReactDoctorScriptCommand(scripts["doctor"]) || isReactDoctorScriptCommand(scripts["react-doctor"]);
7008
7224
  };
7009
7225
  const hasDoctorDependency = (packageJson) => DEPENDENCY_FIELD_NAMES.some((fieldName) => {
7010
7226
  const dependencies = packageJson[fieldName];
7011
7227
  return isRecord(dependencies) && Object.hasOwn(dependencies, "react-doctor");
7012
7228
  });
7013
7229
  const installDoctorScript = (options) => {
7014
- const packageJsonPath = getPackageJsonPath(options.projectRoot);
7015
- const packageJson = readPackageJson(options.projectRoot);
7230
+ const packageDirectory = findNearestPackageDirectory(options.projectRoot) ?? options.projectRoot;
7231
+ const packageJsonPath = getPackageJsonPath(packageDirectory);
7232
+ const packageJson = readPackageJson(packageDirectory);
7016
7233
  if (!isRecord(packageJson)) return {
7017
7234
  packageJsonPath,
7018
7235
  scriptStatus: "skipped",
7019
- dependencyStatus: "skipped",
7020
- scriptReason: "missing-or-invalid-package-json",
7021
- dependencyReason: "missing-or-invalid-package-json"
7236
+ scriptReason: "missing-or-invalid-package-json"
7022
7237
  };
7023
7238
  const scripts = packageJson.scripts;
7024
- const devDependencies = packageJson.devDependencies;
7025
- const scriptStatus = isRecord(scripts) && Object.hasOwn(scripts, "doctor") ? "existing" : scripts !== void 0 && !isRecord(scripts) ? "skipped" : "created";
7026
- const dependencyStatus = hasDoctorDependency(packageJson) ? "existing" : devDependencies !== void 0 && !isRecord(devDependencies) ? "skipped" : "created";
7027
- const didCreateScript = scriptStatus === "created";
7028
- const didCreateDependency = dependencyStatus === "created";
7029
- 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, {
7030
7276
  ...packageJson,
7031
- ...didCreateScript ? { scripts: {
7277
+ scripts: {
7032
7278
  ...isRecord(scripts) ? scripts : {},
7033
- [DOCTOR_SCRIPT_NAME]: DOCTOR_SCRIPT_COMMAND
7034
- } } : {},
7035
- ...didCreateDependency ? { devDependencies: {
7036
- ...isRecord(devDependencies) ? devDependencies : {},
7037
- [DOCTOR_PACKAGE_NAME]: getDoctorDependencyVersion()
7038
- } } : {}
7279
+ [scriptTarget.scriptName ?? "doctor"]: DOCTOR_SCRIPT_COMMAND
7280
+ }
7039
7281
  });
7040
7282
  return {
7041
7283
  packageJsonPath,
7284
+ ...scriptTarget.scriptName !== void 0 ? { scriptName: scriptTarget.scriptName } : {},
7042
7285
  scriptStatus,
7043
- dependencyStatus,
7044
- ...scriptStatus === "skipped" ? { scriptReason: "invalid-scripts" } : {},
7045
- ...dependencyStatus === "skipped" ? { dependencyReason: "invalid-dev-dependencies" } : {}
7286
+ ...scriptTarget.reason !== void 0 ? { scriptReason: scriptTarget.reason } : {}
7046
7287
  };
7047
7288
  };
7048
7289
  const SETUP_PROMPT_CHOICE_NEVER = "never";
@@ -7075,9 +7316,20 @@ const shouldPromptInstallSetup = (options) => {
7075
7316
  if (options.isScoreOnly) return false;
7076
7317
  if (options.isStaged) return false;
7077
7318
  if (options.skipPrompts) return false;
7319
+ if (isCiOrCodingAgentEnvironment()) return false;
7078
7320
  if (hasDisabledSetupPrompt(options.projectRoot, options.store)) return false;
7079
7321
  return !hasDoctorScript(options.projectRoot);
7080
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
+ };
7081
7333
  const defaultWait = (milliseconds) => new Promise((resolve) => {
7082
7334
  setTimeout(resolve, milliseconds);
7083
7335
  });
@@ -7086,20 +7338,15 @@ const defaultSelect = async (message) => {
7086
7338
  type: "select",
7087
7339
  name: "setupReactDoctorChoice",
7088
7340
  message,
7089
- choices: [
7090
- {
7091
- title: "Yes",
7092
- value: "yes"
7093
- },
7094
- {
7095
- title: "No",
7096
- value: "no"
7097
- },
7098
- {
7099
- title: "No, never ask again for this project",
7100
- value: SETUP_PROMPT_CHOICE_NEVER
7101
- }
7102
- ],
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
+ }],
7103
7350
  initial: 0
7104
7351
  }, { onCancel: () => true });
7105
7352
  return setupReactDoctorChoice ?? "no";
@@ -7107,15 +7354,6 @@ const defaultSelect = async (message) => {
7107
7354
  const defaultWriteLine = (line = "") => {
7108
7355
  console.log(line);
7109
7356
  };
7110
- const buildInstallSetupPitchLines = (issueCount) => {
7111
- const issueLabel = `${issueCount} ${issueCount === 1 ? "issue" : "issues"}`;
7112
- return [
7113
- "",
7114
- 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.",
7115
- "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.",
7116
- ""
7117
- ];
7118
- };
7119
7357
  const formatSetupPromptFailure = (error) => error instanceof Error ? error.message : String(error);
7120
7358
  const warnSetupPromptFailure = async (options, error) => {
7121
7359
  const message = `React Doctor setup prompt skipped: ${formatSetupPromptFailure(error)}`;
@@ -7124,22 +7362,22 @@ const warnSetupPromptFailure = async (options, error) => {
7124
7362
  return;
7125
7363
  }
7126
7364
  try {
7127
- const { cliLogger } = await import("./cli-logger-CISyjOAb.js").then((n) => n.n);
7365
+ const { cliLogger } = await import("./cli-logger-C35LXalM.js").then((n) => n.n);
7128
7366
  cliLogger.warn(message);
7129
7367
  } catch {}
7130
7368
  };
7131
7369
  const promptInstallSetup = async (options) => {
7132
7370
  try {
7133
7371
  if (!shouldPromptInstallSetup(options)) return;
7134
- await (options.wait ?? defaultWait)(300);
7135
- const writeLine = options.writeLine ?? defaultWriteLine;
7136
- for (const line of buildInstallSetupPitchLines(options.issueCount)) writeLine(line);
7372
+ await (options.wait ?? defaultWait)(100);
7137
7373
  const setupReactDoctorChoice = await (options.select ?? defaultSelect)("Set up React Doctor for this project?");
7138
- if (setupReactDoctorChoice === "never") {
7139
- 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.");
7140
7379
  return;
7141
7380
  }
7142
- if (setupReactDoctorChoice !== "yes") return;
7143
7381
  const install = options.install ?? (await Promise.resolve().then(() => install_skill_exports)).runInstallSkill;
7144
7382
  const previousExitCode = process.exitCode;
7145
7383
  let setupExitCode;
@@ -7158,25 +7396,50 @@ const promptInstallSetup = async (options) => {
7158
7396
  await warnSetupPromptFailure(options, error);
7159
7397
  }
7160
7398
  };
7161
- //#endregion
7162
- //#region src/cli/utils/is-ci-environment.ts
7163
- const CI_ENVIRONMENT_VARIABLES = [
7164
- "GITHUB_ACTIONS",
7165
- "GITLAB_CI",
7166
- "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."
7167
7415
  ];
7168
- 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
+ };
7169
7420
  //#endregion
7170
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
+ */
7171
7434
  const resolveCliInspectOptions = (flags, userConfig) => ({
7172
- lint: flags.lint ?? userConfig?.lint ?? true,
7173
- deadCode: flags.deadCode ?? userConfig?.deadCode ?? true,
7174
- verbose: flags.verbose ?? userConfig?.verbose ?? false,
7435
+ lint: flags.lint,
7436
+ deadCode: flags.deadCode,
7437
+ verbose: flags.verbose,
7438
+ respectInlineDisables: flags.respectInlineDisables,
7175
7439
  scoreOnly: flags.score === true,
7176
7440
  noScore: flags.score === false || (userConfig?.noScore ?? false),
7177
7441
  isCi: isCiEnvironment(),
7178
7442
  silent: Boolean(flags.json),
7179
- respectInlineDisables: flags.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
7180
7443
  outputSurface: flags.prComment ? "prComment" : "cli"
7181
7444
  });
7182
7445
  //#endregion
@@ -7196,14 +7459,20 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQui
7196
7459
  if (changedSourceFiles.length === 0) return false;
7197
7460
  if (shouldSkipPrompts) return false;
7198
7461
  if (isQuiet) return false;
7199
- const currentBranchLabel = diffInfo.currentBranch ?? "(detached HEAD)";
7200
- const { shouldScanChangedOnly } = await prompts({
7201
- type: "confirm",
7202
- name: "shouldScanChangedOnly",
7203
- 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?`,
7204
- 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
7205
7474
  });
7206
- return Boolean(shouldScanChangedOnly);
7475
+ return scanScope === "branch";
7207
7476
  };
7208
7477
  //#endregion
7209
7478
  //#region src/cli/utils/coerce-diff-value.ts
@@ -7257,6 +7526,40 @@ const resolveProjectDiffIncludePaths = (rootDirectory, projectDirectory, diffInf
7257
7526
  });
7258
7527
  };
7259
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
7260
7563
  //#region src/cli/utils/find-owning-project.ts
7261
7564
  const findOwningProjectDirectory = (rootDirectory, filePath) => {
7262
7565
  const absoluteFile = path.isAbsolute(filePath) ? filePath : path.resolve(rootDirectory, filePath);
@@ -7295,7 +7598,10 @@ const parseFileLineArgument = (rawArgument) => {
7295
7598
  //#region src/cli/utils/select-projects.ts
7296
7599
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
7297
7600
  let packages = listWorkspacePackages(rootDirectory);
7298
- if (packages.length === 0) packages = discoverReactSubprojects(rootDirectory);
7601
+ if (packages.length === 0) {
7602
+ if (!isMonorepoRoot(rootDirectory)) return [rootDirectory];
7603
+ packages = discoverReactSubprojects(rootDirectory);
7604
+ }
7299
7605
  if (packages.length === 0) return [rootDirectory];
7300
7606
  if (packages.length === 1) {
7301
7607
  cliLogger.log(`${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${packages[0].name}`);
@@ -7373,6 +7679,10 @@ const runExplain = async (fileLineArgument, context) => {
7373
7679
  cliLogger.log(`${severitySymbol} ${colorizedRule} ${highlighter.dim(`(${severityLabel})`)} — ${diagnostic.message}`);
7374
7680
  if (diagnostic.category) cliLogger.dim(` Category: ${diagnostic.category}`);
7375
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
+ })}`);
7376
7686
  if (diagnostic.suppressionHint) {
7377
7687
  cliLogger.break();
7378
7688
  cliLogger.log(` Suppression diagnosis: ${diagnostic.suppressionHint}`);
@@ -7405,6 +7715,26 @@ const validateModeFlags = (flags) => {
7405
7715
  };
7406
7716
  //#endregion
7407
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
+ };
7408
7738
  const inspectAction = async (directory, flags) => {
7409
7739
  const isScoreOnly = Boolean(flags.score);
7410
7740
  const isJsonMode = Boolean(flags.json);
@@ -7417,12 +7747,11 @@ const inspectAction = async (directory, flags) => {
7417
7747
  });
7418
7748
  try {
7419
7749
  validateModeFlags(flags);
7420
- const loadedConfig = loadConfigWithSource(requestedDirectory);
7421
- const userConfig = loadedConfig?.config ?? null;
7422
- const redirectedDirectory = resolveConfigRootDir(loadedConfig?.config ?? null, loadedConfig?.sourceDirectory ?? null);
7423
- const resolvedDirectory = redirectedDirectory ?? requestedDirectory;
7750
+ const scanTarget = resolveScanTarget(requestedDirectory);
7751
+ const userConfig = scanTarget.userConfig;
7752
+ const resolvedDirectory = scanTarget.resolvedDirectory;
7424
7753
  setJsonReportDirectory(resolvedDirectory);
7425
- if (redirectedDirectory && !isQuiet) {
7754
+ if (scanTarget.didRedirectViaRootDir && !isQuiet) {
7426
7755
  cliLogger.dim(`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`);
7427
7756
  cliLogger.break();
7428
7757
  }
@@ -7473,12 +7802,9 @@ const inspectAction = async (directory, flags) => {
7473
7802
  ...diagnostic,
7474
7803
  filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
7475
7804
  }));
7476
- if (isJsonMode) writeJsonReport(buildJsonReport({
7477
- version: VERSION,
7478
- directory: resolvedDirectory,
7479
- mode: "staged",
7480
- diff: null,
7481
- scans: [{
7805
+ finalizeScans({
7806
+ diagnostics: remappedDiagnostics,
7807
+ completedScans: [{
7482
7808
  directory: resolvedDirectory,
7483
7809
  result: {
7484
7810
  ...scanResult,
@@ -7489,11 +7815,15 @@ const inspectAction = async (directory, flags) => {
7489
7815
  }
7490
7816
  }
7491
7817
  }],
7492
- totalElapsedMilliseconds: performance.now() - startTime
7493
- }));
7494
- if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
7495
- const ciFailureDiagnostics = filterDiagnosticsForSurface(remappedDiagnostics, "ciFailure", userConfig);
7496
- 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
+ });
7497
7827
  } finally {
7498
7828
  snapshot.cleanup();
7499
7829
  }
@@ -7514,6 +7844,7 @@ const inspectAction = async (directory, flags) => {
7514
7844
  }
7515
7845
  const allDiagnostics = [];
7516
7846
  const completedScans = [];
7847
+ const isMultiProject = projectDirectories.length > 1;
7517
7848
  for (const projectDirectory of projectDirectories) {
7518
7849
  let includePaths;
7519
7850
  if (isDiffMode) {
@@ -7527,42 +7858,68 @@ const inspectAction = async (directory, flags) => {
7527
7858
  }
7528
7859
  includePaths = changedSourceFiles;
7529
7860
  }
7530
- if (!isQuiet) {
7531
- cliLogger.dim(`Scanning ${projectDirectory}...`);
7532
- cliLogger.break();
7533
- }
7861
+ if (!isQuiet && !isMultiProject) cliLogger.dim(" ");
7534
7862
  const scanResult = await inspect(projectDirectory, {
7535
7863
  ...scanOptions,
7536
7864
  includePaths,
7537
- configOverride: userConfig
7865
+ configOverride: userConfig,
7866
+ suppressRendering: isMultiProject
7538
7867
  });
7539
7868
  allDiagnostics.push(...scanResult.diagnostics);
7540
7869
  completedScans.push({
7541
7870
  directory: projectDirectory,
7542
7871
  result: scanResult
7543
7872
  });
7544
- if (!isQuiet) cliLogger.break();
7873
+ if (!isQuiet && !isMultiProject) cliLogger.break();
7545
7874
  }
7546
- if (isJsonMode) writeJsonReport(buildJsonReport({
7547
- version: VERSION,
7548
- 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,
7549
7883
  mode: isDiffMode ? "diff" : "full",
7550
7884
  diff: isDiffMode ? diffInfo : null,
7551
- scans: completedScans,
7552
- totalElapsedMilliseconds: performance.now() - startTime
7553
- }));
7554
- if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
7555
- const ciFailureDiagnostics = filterDiagnosticsForSurface(allDiagnostics, "ciFailure", userConfig);
7556
- if (!isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
7557
- await promptInstallSetup({
7558
- projectRoot: resolvedDirectory,
7559
- hasScoredScan: completedScans.some((scan) => scan.result.score !== null),
7560
- issueCount: filterDiagnosticsForSurface(allDiagnostics, scanOptions.outputSurface ?? "cli", userConfig).length,
7561
7885
  isJsonMode,
7562
7886
  isScoreOnly,
7563
- isStaged: Boolean(flags.staged),
7564
- skipPrompts
7887
+ flags,
7888
+ userConfig,
7889
+ resolvedDirectory,
7890
+ startTime
7891
+ });
7892
+ const setupProjectRoot = resolveInstallSetupProjectRoot({
7893
+ scanRoot: resolvedDirectory,
7894
+ completedScanDirectories: completedScans.map((scan) => scan.directory)
7565
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
+ }
7566
7923
  } catch (error) {
7567
7924
  if (isJsonMode) {
7568
7925
  writeJsonErrorReport(error);
@@ -7763,7 +8120,7 @@ const buildAgentHookScript = () => [
7763
8120
  "const input = readInput();",
7764
8121
  "const scanOutput = fs.readFileSync(outputPath, 'utf8').trim();",
7765
8122
  "if (!scanOutput) process.exit(0);",
7766
- "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}`;",
7767
8124
  "if (input.hook_event_name === 'PostToolBatch') {",
7768
8125
  " console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: message } }));",
7769
8126
  "} else {",
@@ -7879,24 +8236,6 @@ const installDirectGitHook = (options) => {
7879
8236
  };
7880
8237
  //#endregion
7881
8238
  //#region src/cli/utils/install-git-hook-config-managers.ts
7882
- const installSimpleGitHooks = (options) => {
7883
- const packageJsonPath = getPackageJsonPath(options.projectRoot);
7884
- const didHookExist = existsSync(packageJsonPath);
7885
- const packageJson = readPackageJson(options.projectRoot);
7886
- const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
7887
- const existingConfig = nextPackageJson["simple-git-hooks"];
7888
- const nextConfig = isRecord(existingConfig) ? { ...existingConfig } : {};
7889
- const existingPreCommit = typeof nextConfig["pre-commit"] === "string" ? nextConfig["pre-commit"] : "";
7890
- nextConfig["pre-commit"] = existingPreCommit.includes("react-doctor --staged --fail-on warning") ? existingPreCommit : [existingPreCommit, NON_BLOCKING_REACT_DOCTOR_COMMAND].filter(Boolean).join("\n");
7891
- nextPackageJson["simple-git-hooks"] = nextConfig;
7892
- writeJsonFile$1(packageJsonPath, nextPackageJson);
7893
- removeLegacyManagedRunner(options.projectRoot);
7894
- return {
7895
- hookPath: packageJsonPath,
7896
- kind: "simple-git-hooks",
7897
- status: didHookExist ? "updated" : "created"
7898
- };
7899
- };
7900
8239
  const appendStringCommand = (existingCommand) => {
7901
8240
  const existingCommandText = typeof existingCommand === "string" ? existingCommand : Array.isArray(existingCommand) ? existingCommand.filter((entry) => typeof entry === "string").join("\n") : "";
7902
8241
  return existingCommandText.includes("react-doctor --staged --fail-on warning") ? existingCommandText : [existingCommandText, NON_BLOCKING_REACT_DOCTOR_COMMAND].filter(Boolean).join("\n");
@@ -7905,58 +8244,63 @@ const appendArrayCommand = (existingCommands) => {
7905
8244
  const commands = Array.isArray(existingCommands) ? existingCommands.filter((entry) => typeof entry === "string") : typeof existingCommands === "string" ? [existingCommands] : [];
7906
8245
  return commands.some((command) => command.includes("react-doctor --staged --fail-on warning")) ? commands : [...commands, NON_BLOCKING_REACT_DOCTOR_COMMAND];
7907
8246
  };
7908
- const installPackageJsonPreCommitString = (options, kind, configKey) => {
7909
- const packageJsonPath = getPackageJsonPath(options.projectRoot);
7910
- const didHookExist = existsSync(packageJsonPath);
7911
- const packageJson = readPackageJson(options.projectRoot);
7912
- const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
7913
- const existingConfig = nextPackageJson[configKey];
7914
- const nextConfig = isRecord(existingConfig) ? { ...existingConfig } : {};
7915
- nextConfig["pre-commit"] = appendStringCommand(nextConfig["pre-commit"]);
7916
- nextPackageJson[configKey] = nextConfig;
7917
- writeJsonFile$1(packageJsonPath, nextPackageJson);
7918
- removeLegacyManagedRunner(options.projectRoot);
7919
- return {
7920
- hookPath: packageJsonPath,
7921
- kind,
7922
- status: didHookExist ? "updated" : "created"
7923
- };
7924
- };
7925
- const installGhooks = (options) => {
7926
- const packageJsonPath = getPackageJsonPath(options.projectRoot);
7927
- const didHookExist = existsSync(packageJsonPath);
7928
- const packageJson = readPackageJson(options.projectRoot);
7929
- const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
7930
- const existingConfig = nextPackageJson.config;
7931
- const nextConfig = isRecord(existingConfig) ? { ...existingConfig } : {};
7932
- const existingGhooks = nextConfig.ghooks;
7933
- const nextGhooks = isRecord(existingGhooks) ? { ...existingGhooks } : {};
7934
- nextGhooks["pre-commit"] = appendStringCommand(nextGhooks["pre-commit"]);
7935
- nextConfig.ghooks = nextGhooks;
7936
- nextPackageJson.config = nextConfig;
7937
- writeJsonFile$1(packageJsonPath, nextPackageJson);
7938
- removeLegacyManagedRunner(options.projectRoot);
7939
- return {
7940
- hookPath: packageJsonPath,
7941
- kind: "ghooks",
7942
- status: didHookExist ? "updated" : "created"
7943
- };
7944
- };
7945
- const installPreCommitNpm = (options) => {
8247
+ const installPackageJsonHook = (options, strategy) => {
7946
8248
  const packageJsonPath = getPackageJsonPath(options.projectRoot);
7947
8249
  const didHookExist = existsSync(packageJsonPath);
7948
8250
  const packageJson = readPackageJson(options.projectRoot);
7949
8251
  const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
7950
- 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]);
7951
8262
  writeJsonFile$1(packageJsonPath, nextPackageJson);
7952
8263
  removeLegacyManagedRunner(options.projectRoot);
7953
8264
  return {
7954
8265
  hookPath: packageJsonPath,
7955
- kind: "pre-commit-npm",
8266
+ kind: strategy.kind,
7956
8267
  status: didHookExist ? "updated" : "created"
7957
8268
  };
7958
8269
  };
7959
- 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
+ });
7960
8304
  const appendIndentedBlockToTopLevelSection = (content, sectionName, block) => {
7961
8305
  const normalizedContent = ensureTrailingNewline(content);
7962
8306
  const match = new RegExp(`^${sectionName}:\\s*$`, "m").exec(normalizedContent);
@@ -8186,9 +8530,9 @@ const installReactDoctorGitHook = (options) => {
8186
8530
  if (options.kind === "lefthook") return installLefthook(options);
8187
8531
  if (options.kind === "pre-commit") return installPreCommit(options);
8188
8532
  if (options.kind === "overcommit") return installOvercommit(options);
8189
- if (options.kind === "yorkie") return installPackageJsonPreCommitString(options, "yorkie", "gitHooks");
8533
+ if (options.kind === "yorkie") return installYorkie(options);
8190
8534
  if (options.kind === "ghooks") return installGhooks(options);
8191
- if (options.kind === "git-hooks-js") return installPackageJsonPreCommitString(options, "git-hooks-js", "git-hooks");
8535
+ if (options.kind === "git-hooks-js") return installGitHooksJs(options);
8192
8536
  if (options.kind === "pre-commit-npm") return installPreCommitNpm(options);
8193
8537
  if (options.kind === "pretty-quick") return installPrettyQuick(options);
8194
8538
  return installDirectGitHook(options);
@@ -8196,7 +8540,6 @@ const installReactDoctorGitHook = (options) => {
8196
8540
  //#endregion
8197
8541
  //#region src/cli/utils/install-skill.ts
8198
8542
  var install_skill_exports = /* @__PURE__ */ __exportAll({ runInstallSkill: () => runInstallSkill });
8199
- const NATIVE_AGENT_HOOK_AGENTS = new Set(["claude-code", "cursor"]);
8200
8543
  const CONFIG_ONLY_GIT_HOOK_KINDS = new Set([
8201
8544
  "ghooks",
8202
8545
  "git-hooks-js",
@@ -8208,35 +8551,167 @@ const CONFIG_ONLY_GIT_HOOK_KINDS = new Set([
8208
8551
  "simple-git-hooks",
8209
8552
  "yorkie"
8210
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
+ };
8211
8667
  const buildManualGitHookTarget = (hookPath, projectRoot) => ({
8212
8668
  hookPath,
8213
8669
  runnerRoot: projectRoot,
8214
8670
  kind: "git"
8215
8671
  });
8216
- const hasNativeAgentHookTarget = (agents) => agents.some((agent) => NATIVE_AGENT_HOOK_AGENTS.has(agent));
8217
8672
  const formatGitHookInstallMessage = (hookResult) => {
8218
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.`;
8219
8674
  return `React Doctor pre-commit hook ${hookResult.status} at ${hookResult.hookPath}.`;
8220
8675
  };
8221
8676
  const formatDoctorScriptInstallMessage = (scriptResult) => {
8222
8677
  const messages = [];
8223
- if (scriptResult.scriptStatus === "created") messages.push("Added package script: doctor.");
8224
- 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.");
8225
8683
  else if (scriptResult.scriptReason === "invalid-scripts") messages.push(`Skipped package script: scripts field is not an object.`);
8226
8684
  else messages.push("Skipped package script: package.json missing or invalid.");
8227
- if (scriptResult.dependencyStatus === "created") messages.push("Added dev dependency: react-doctor.");
8228
- else if (scriptResult.dependencyStatus === "existing") messages.push("React Doctor dependency already exists.");
8229
- else if (scriptResult.dependencyReason === "invalid-dev-dependencies") messages.push("Skipped dev dependency: devDependencies field is not an object.");
8230
- else messages.push("Skipped dev dependency: package.json missing or invalid.");
8231
8685
  return messages.join(" ");
8232
8686
  };
8233
- const installReactDoctorPackageSetup = (projectRoot) => {
8234
- 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();
8235
8695
  try {
8236
8696
  const scriptResult = installDoctorScript({ projectRoot });
8237
8697
  scriptSpinner.succeed(formatDoctorScriptInstallMessage(scriptResult));
8238
8698
  } catch (error) {
8239
- 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.");
8240
8715
  throw error;
8241
8716
  }
8242
8717
  };
@@ -8245,9 +8720,9 @@ const getSkillSourceDirectory = () => {
8245
8720
  return path.join(distDirectory, "skills", SKILL_NAME);
8246
8721
  };
8247
8722
  const runInstallSkill = async (options = {}) => {
8248
- const projectRoot = options.projectRoot ?? process.cwd();
8723
+ const requestedProjectRoot = options.projectRoot ?? process.cwd();
8724
+ const projectRoot = findNearestPackageDirectory(requestedProjectRoot) ?? requestedProjectRoot;
8249
8725
  const sourceDir = options.sourceDir ?? getSkillSourceDirectory();
8250
- if (!options.dryRun) installReactDoctorPackageSetup(projectRoot);
8251
8726
  if (!existsSync(path.join(sourceDir, SKILL_MANIFEST_FILE))) {
8252
8727
  cliLogger.error(`Could not locate the ${SKILL_NAME} skill bundled with this package.`);
8253
8728
  process.exitCode = 1;
@@ -8268,7 +8743,7 @@ const runInstallSkill = async (options = {}) => {
8268
8743
  const selectedAgents = skipPrompts ? detectedAgents : (await prompts({
8269
8744
  type: "multiselect",
8270
8745
  name: "agents",
8271
- message: `Install the ${highlighter.info("react-doctor")} skill for:`,
8746
+ message: `Install the ${highlighter.info(`/react-doctor`)} skill for:`,
8272
8747
  choices: detectedAgents.map((agent) => ({
8273
8748
  title: getSkillAgentConfig(agent).displayName,
8274
8749
  value: agent,
@@ -8281,20 +8756,15 @@ const runInstallSkill = async (options = {}) => {
8281
8756
  const shouldInstallGitHook = gitHookPath !== null && gitHookPath !== void 0 && (Boolean(options.yes) || !skipPrompts && Boolean((await prompts({
8282
8757
  type: "confirm",
8283
8758
  name: "installGitHook",
8284
- message: "Run React Doctor on staged files before commits? (non-blocking git hook)",
8759
+ message: "Check for issues before each commit?",
8285
8760
  initial: true
8286
8761
  }, promptOptions)).installGitHook));
8287
- const shouldInstallAgentHooks = Boolean(options.agentHooks) || !skipPrompts && hasNativeAgentHookTarget(selectedAgents) && Boolean((await prompts({
8288
- type: "confirm",
8289
- name: "installAgentHooks",
8290
- message: "Install native agent hooks after file edits? (Claude Code / Cursor)",
8291
- initial: false
8292
- }, promptOptions)).installAgentHooks);
8762
+ const shouldInstallAgentHooks = Boolean(options.agentHooks);
8293
8763
  if (options.dryRun) {
8294
8764
  cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
8295
8765
  for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
8296
8766
  cliLogger.dim(` Source: ${sourceDir}`);
8297
- cliLogger.dim(" Package script: doctor");
8767
+ cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
8298
8768
  cliLogger.dim(" Dev dependency: react-doctor");
8299
8769
  if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
8300
8770
  if (shouldInstallAgentHooks) cliLogger.dim(" Agent hooks: Claude Code / Cursor when selected");
@@ -8315,6 +8785,7 @@ const runInstallSkill = async (options = {}) => {
8315
8785
  installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
8316
8786
  throw error;
8317
8787
  }
8788
+ await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
8318
8789
  if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
8319
8790
  const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
8320
8791
  try {
@@ -8344,6 +8815,49 @@ const runInstallSkill = async (options = {}) => {
8344
8815
  throw error;
8345
8816
  }
8346
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
+ }
8347
8861
  };
8348
8862
  //#endregion
8349
8863
  //#region src/cli/commands/install.ts
@@ -8364,13 +8878,10 @@ const installAction = async (options, command) => {
8364
8878
  //#endregion
8365
8879
  //#region src/cli/utils/exit-gracefully.ts
8366
8880
  const exitGracefully = () => {
8367
- if (isJsonModeActive()) {
8368
- writeJsonErrorReport(/* @__PURE__ */ new Error("Scan cancelled by user (SIGINT/SIGTERM)"));
8369
- process.exit(130);
8370
- }
8371
- cliLogger.break();
8372
- cliLogger.log("Cancelled.");
8373
- 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 {}
8374
8885
  process.exit(130);
8375
8886
  };
8376
8887
  //#endregion