react-doctor 0.2.6 → 0.2.8
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-logger-Iz5pfDnL.js → cli-logger-C35LXalM.js} +887 -616
- package/dist/cli.js +1145 -636
- package/dist/index.d.ts +33 -16
- package/dist/index.js +938 -625
- package/dist/skills/react-doctor/SKILL.md +16 -2
- package/package.json +6 -14
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
|
|
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/
|
|
6072
|
+
//#region src/cli/utils/build-runtime-layers.ts
|
|
6300
6073
|
/**
|
|
6301
|
-
*
|
|
6302
|
-
*
|
|
6303
|
-
*
|
|
6304
|
-
*
|
|
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
|
|
6310
|
-
spinner(
|
|
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
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
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/
|
|
6327
|
-
const
|
|
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-
|
|
6334
|
-
const
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
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
|
|
6352
|
-
const
|
|
6353
|
-
const [
|
|
6354
|
-
|
|
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
|
|
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
|
|
6365
|
-
const scoreLabel = colorizeByScore(scoreResult.label, scoreResult.score);
|
|
6380
|
+
const shouldAnimate = !isSpinnerSilent() && isSpinnerInteractive(process.stdout);
|
|
6366
6381
|
const rightColumnLines = [
|
|
6367
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
6416
|
-
|
|
6417
|
-
|
|
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.
|
|
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
|
|
6451
|
+
yield* Console.log(` ${highlighter.bold("→ Share:")} ${highlighter.info(shareUrl)}`);
|
|
6437
6452
|
yield* Console.log("");
|
|
6438
6453
|
}
|
|
6439
6454
|
});
|
|
@@ -6546,10 +6561,10 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
|
|
|
6546
6561
|
const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
|
|
6547
6562
|
//#endregion
|
|
6548
6563
|
//#region src/cli/utils/version.ts
|
|
6549
|
-
const VERSION = "0.2.
|
|
6564
|
+
const VERSION = "0.2.8";
|
|
6550
6565
|
//#endregion
|
|
6551
6566
|
//#region src/inspect.ts
|
|
6552
|
-
const silentConsole =
|
|
6567
|
+
const silentConsole = makeNoopConsole();
|
|
6553
6568
|
const runConsole = (effect) => {
|
|
6554
6569
|
Effect.runSync(effect);
|
|
6555
6570
|
};
|
|
@@ -6565,6 +6580,8 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
6565
6580
|
scoreOnly: inputOptions.scoreOnly ?? false,
|
|
6566
6581
|
noScore: inputOptions.noScore ?? userConfig?.noScore ?? false,
|
|
6567
6582
|
isCi: inputOptions.isCi ?? false,
|
|
6583
|
+
isCiOrCodingAgentEnvironment: isCiOrCodingAgentEnvironment(),
|
|
6584
|
+
isNonInteractiveEnvironment: isNonInteractiveEnvironment(),
|
|
6568
6585
|
silent: inputOptions.silent ?? false,
|
|
6569
6586
|
includePaths: inputOptions.includePaths ?? [],
|
|
6570
6587
|
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
@@ -6572,39 +6589,24 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
|
|
|
6572
6589
|
respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
|
|
6573
6590
|
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
6574
6591
|
ignoredTags: buildIgnoredTags(userConfig),
|
|
6575
|
-
outputSurface: inputOptions.outputSurface ?? "cli"
|
|
6592
|
+
outputSurface: inputOptions.outputSurface ?? "cli",
|
|
6593
|
+
suppressRendering: inputOptions.suppressRendering ?? false
|
|
6576
6594
|
});
|
|
6577
|
-
/**
|
|
6578
|
-
* Tagged-reason → legacy-class dispatch for the public `inspect()`
|
|
6579
|
-
* contract. Each case converts a `ReactDoctorError` reason into the
|
|
6580
|
-
* historical thrown class (`NoReactDependencyError`, …) via
|
|
6581
|
-
* `Effect.die`, which `Effect.runPromise` re-throws unchanged.
|
|
6582
|
-
* Unmatched reasons (GitInvocationFailed, OxlintSpawnFailed, …)
|
|
6583
|
-
* flow through as the original tagged `ReactDoctorError` instance.
|
|
6584
|
-
*
|
|
6585
|
-
* Adding a new public thrown class is one new entry on this object
|
|
6586
|
-
* — no `instanceof` checks, no `switch` ladder. The function form
|
|
6587
|
-
* (vs. the standalone constant) is required so `Effect.catchReasons`
|
|
6588
|
-
* gets the surrounding Effect's error channel for type inference.
|
|
6589
|
-
*/
|
|
6590
|
-
const restoreLegacyThrow = (effect) => effect.pipe(Effect.catchReasons("ReactDoctorError", {
|
|
6591
|
-
NoReactDependency: (reason) => Effect.die(new NoReactDependencyError(reason.directory)),
|
|
6592
|
-
ProjectNotFound: (reason) => Effect.die(new ProjectNotFoundError(reason.directory)),
|
|
6593
|
-
AmbiguousProject: (reason) => Effect.die(new AmbiguousProjectError(reason.directory, [...reason.candidates]))
|
|
6594
|
-
}, (_reason, error) => Effect.die(new Error(error.message))));
|
|
6595
6595
|
const inspect = async (directory, inputOptions = {}) => {
|
|
6596
6596
|
const startTime = performance.now();
|
|
6597
6597
|
const hasConfigOverride = inputOptions.configOverride !== void 0;
|
|
6598
|
-
let scanDirectory
|
|
6598
|
+
let scanDirectory;
|
|
6599
6599
|
let userConfig;
|
|
6600
|
-
let configSourceDirectory
|
|
6601
|
-
if (hasConfigOverride)
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6600
|
+
let configSourceDirectory;
|
|
6601
|
+
if (hasConfigOverride) {
|
|
6602
|
+
scanDirectory = directory;
|
|
6603
|
+
userConfig = inputOptions.configOverride ?? null;
|
|
6604
|
+
configSourceDirectory = null;
|
|
6605
|
+
} else {
|
|
6606
|
+
const scanTarget = resolveScanTarget(directory);
|
|
6607
|
+
scanDirectory = scanTarget.resolvedDirectory;
|
|
6608
|
+
userConfig = scanTarget.userConfig;
|
|
6609
|
+
configSourceDirectory = scanTarget.configSourceDirectory;
|
|
6608
6610
|
}
|
|
6609
6611
|
const options = mergeInspectOptions(inputOptions, userConfig);
|
|
6610
6612
|
const wasSpinnerSilent = isSpinnerSilent();
|
|
@@ -6619,79 +6621,50 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
|
|
|
6619
6621
|
const isDiffMode = options.includePaths.length > 0;
|
|
6620
6622
|
const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
|
|
6621
6623
|
const lintBindingMissing = options.lint && !resolvedNodeBinaryPath;
|
|
6624
|
+
const shouldShowProgressSpinners = !options.isCiOrCodingAgentEnvironment && !options.silent && !options.scoreOnly && options.lint && Boolean(resolvedNodeBinaryPath);
|
|
6622
6625
|
const layers = buildRuntimeLayers({
|
|
6623
6626
|
directory,
|
|
6624
6627
|
hasConfigOverride,
|
|
6625
6628
|
userConfig,
|
|
6626
6629
|
configSourceDirectory,
|
|
6627
6630
|
shouldSkipLint: !options.lint || lintBindingMissing,
|
|
6628
|
-
shouldRunDeadCode: options.deadCode
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
const spinnerRef = yield* Ref.make(null);
|
|
6632
|
-
return {
|
|
6633
|
-
output: yield* runInspect({
|
|
6634
|
-
directory,
|
|
6635
|
-
includePaths: options.includePaths,
|
|
6636
|
-
customRulesOnly: options.customRulesOnly,
|
|
6637
|
-
respectInlineDisables: options.respectInlineDisables,
|
|
6638
|
-
adoptExistingLintConfig: options.adoptExistingLintConfig,
|
|
6639
|
-
ignoredTags: options.ignoredTags,
|
|
6640
|
-
nodeBinaryPath: resolvedNodeBinaryPath ?? void 0,
|
|
6641
|
-
runDeadCode: options.deadCode,
|
|
6642
|
-
isCi: options.isCi,
|
|
6643
|
-
doctorVersion: VERSION,
|
|
6644
|
-
resolveLocalGithubViewerPermission: !options.noScore
|
|
6645
|
-
}, {
|
|
6646
|
-
beforeLint: (projectInfo, lintIncludePaths) => Effect.gen(function* () {
|
|
6647
|
-
const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
|
|
6648
|
-
if (!options.scoreOnly) yield* printProjectDetection({
|
|
6649
|
-
projectInfo,
|
|
6650
|
-
userConfig,
|
|
6651
|
-
isDiffMode,
|
|
6652
|
-
includePaths: options.includePaths,
|
|
6653
|
-
lintSourceFileCount
|
|
6654
|
-
});
|
|
6655
|
-
if (options.lint && resolvedNodeBinaryPath && !options.scoreOnly && !options.silent) {
|
|
6656
|
-
const handle = spinner("Running lint checks...").start();
|
|
6657
|
-
yield* Ref.set(spinnerRef, {
|
|
6658
|
-
succeed: (text) => handle.succeed(text),
|
|
6659
|
-
fail: (text) => handle.fail(text)
|
|
6660
|
-
});
|
|
6661
|
-
}
|
|
6662
|
-
}),
|
|
6663
|
-
afterLint: (didFail) => Effect.gen(function* () {
|
|
6664
|
-
const handle = yield* Ref.get(spinnerRef);
|
|
6665
|
-
if (handle && !didFail) handle.succeed("Running lint checks.");
|
|
6666
|
-
})
|
|
6667
|
-
}),
|
|
6668
|
-
finalHandle: yield* Ref.get(spinnerRef)
|
|
6669
|
-
};
|
|
6631
|
+
shouldRunDeadCode: options.deadCode,
|
|
6632
|
+
shouldComputeScore: !options.noScore,
|
|
6633
|
+
shouldShowProgressSpinners
|
|
6670
6634
|
});
|
|
6635
|
+
const program = runInspect({
|
|
6636
|
+
directory,
|
|
6637
|
+
includePaths: options.includePaths,
|
|
6638
|
+
customRulesOnly: options.customRulesOnly,
|
|
6639
|
+
respectInlineDisables: options.respectInlineDisables,
|
|
6640
|
+
adoptExistingLintConfig: options.adoptExistingLintConfig,
|
|
6641
|
+
ignoredTags: options.ignoredTags,
|
|
6642
|
+
nodeBinaryPath: resolvedNodeBinaryPath ?? void 0,
|
|
6643
|
+
runDeadCode: options.deadCode,
|
|
6644
|
+
isCi: options.isCi,
|
|
6645
|
+
doctorVersion: VERSION,
|
|
6646
|
+
resolveLocalGithubViewerPermission: !options.noScore
|
|
6647
|
+
}, { beforeLint: (projectInfo, lintIncludePaths) => Effect.gen(function* () {
|
|
6648
|
+
if (options.scoreOnly || options.suppressRendering) return;
|
|
6649
|
+
const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
|
|
6650
|
+
yield* printProjectDetection({
|
|
6651
|
+
projectInfo,
|
|
6652
|
+
userConfig,
|
|
6653
|
+
isDiffMode,
|
|
6654
|
+
includePaths: options.includePaths,
|
|
6655
|
+
lintSourceFileCount
|
|
6656
|
+
});
|
|
6657
|
+
}) });
|
|
6671
6658
|
const programWithLayers = options.silent ? program.pipe(Effect.provide(layers), Effect.provideService(Console.Console, silentConsole), Effect.provide(layerOtlp)) : program.pipe(Effect.provide(layers), Effect.provide(layerOtlp));
|
|
6672
|
-
const
|
|
6659
|
+
const output = await Effect.runPromise(restoreLegacyThrow(programWithLayers));
|
|
6673
6660
|
const didLintFail = lintBindingMissing || output.didLintFail;
|
|
6674
6661
|
const lintFailureReason = lintBindingMissing ? `oxlint native binding not found for Node ${process.version}; expected one matching ${OXLINT_NODE_REQUIREMENT}` : output.lintFailureReason;
|
|
6675
6662
|
const lintFailureReasonTag = output.lintFailureReasonTag;
|
|
6676
6663
|
const isNativeBindingFailure = lintFailureReasonTag === "OxlintUnavailable" || lintFailureReasonTag === "OxlintSpawnFailed";
|
|
6677
|
-
if (!options.scoreOnly && !lintBindingMissing && output.didLintFail &&
|
|
6678
|
-
|
|
6679
|
-
runConsole(Console.log(highlighter.gray(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`)));
|
|
6680
|
-
} else {
|
|
6681
|
-
finalSpinnerHandle.fail("Lint checks failed (non-fatal, skipping).");
|
|
6682
|
-
runConsole(Console.error(highlighter.error(lintFailureReason)));
|
|
6683
|
-
}
|
|
6684
|
-
if (!options.scoreOnly && !options.silent && options.deadCode && !isDiffMode) {
|
|
6685
|
-
const deadCodeSpinner = spinner("Analyzing dead code...").start();
|
|
6686
|
-
if (output.didDeadCodeFail) deadCodeSpinner.fail("Dead-code analysis failed (non-fatal, skipping).");
|
|
6687
|
-
else deadCodeSpinner.succeed("Analyzing dead code.");
|
|
6688
|
-
}
|
|
6664
|
+
if (!options.scoreOnly && !lintBindingMissing && output.didLintFail && lintFailureReason !== null) if (isNativeBindingFailure && /native binding/.test(lintFailureReason)) runConsole(Console.log(highlighter.gray(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`)));
|
|
6665
|
+
else runConsole(Console.error(highlighter.error(lintFailureReason)));
|
|
6689
6666
|
const inspectDiagnostics = output.diagnostics;
|
|
6690
|
-
const
|
|
6691
|
-
const score = didLintFail || options.noScore ? null : await calculateScore([...scoreDiagnostics], {
|
|
6692
|
-
isCi: options.isCi,
|
|
6693
|
-
metadata: output.scoreMetadata
|
|
6694
|
-
});
|
|
6667
|
+
const score = didLintFail ? null : output.score;
|
|
6695
6668
|
const finalizeInput = {
|
|
6696
6669
|
options,
|
|
6697
6670
|
elapsedMilliseconds: performance.now() - startTime,
|
|
@@ -6727,6 +6700,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
6727
6700
|
project,
|
|
6728
6701
|
elapsedMilliseconds
|
|
6729
6702
|
});
|
|
6703
|
+
if (options.suppressRendering) return buildResult();
|
|
6730
6704
|
if (options.scoreOnly) {
|
|
6731
6705
|
if (score) yield* Console.log(`${score.score}`);
|
|
6732
6706
|
else yield* Console.log(highlighter.gray(noScoreMessage));
|
|
@@ -6751,6 +6725,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
6751
6725
|
}
|
|
6752
6726
|
yield* Console.log("");
|
|
6753
6727
|
yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory);
|
|
6728
|
+
if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
|
|
6754
6729
|
if (demotedDiagnosticCount > 0) {
|
|
6755
6730
|
yield* Console.log(highlighter.gray(` ${demotedDiagnosticCount} demoted from the ${options.outputSurface} surface (e.g. design cleanup) — run \`npx react-doctor@latest .\` locally for the full list.`));
|
|
6756
6731
|
yield* Console.log("");
|
|
@@ -6763,7 +6738,8 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
|
|
|
6763
6738
|
projectName: project.projectName,
|
|
6764
6739
|
totalSourceFileCount: lintSourceFileCount,
|
|
6765
6740
|
noScoreMessage,
|
|
6766
|
-
isOffline: !shouldShowShareLink
|
|
6741
|
+
isOffline: !shouldShowShareLink,
|
|
6742
|
+
verbose: options.verbose
|
|
6767
6743
|
});
|
|
6768
6744
|
if (hasSkippedChecks) {
|
|
6769
6745
|
const skippedLabel = skippedChecks.join(" and ");
|
|
@@ -6804,6 +6780,58 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
|
|
|
6804
6780
|
};
|
|
6805
6781
|
//#endregion
|
|
6806
6782
|
//#region src/cli/utils/handle-error.ts
|
|
6783
|
+
const OTLP_ENDPOINT_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_ENDPOINT";
|
|
6784
|
+
const OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE = "REACT_DOCTOR_OTLP_AUTH_HEADER";
|
|
6785
|
+
const formatErrorForReport = (error) => isReactDoctorError(error) ? formatReactDoctorError(error) : formatErrorChain(error);
|
|
6786
|
+
const formatSingleLine = (text) => text.replaceAll(/\s+/g, " ").trim();
|
|
6787
|
+
const getErrorReportContext = () => ({
|
|
6788
|
+
cwd: process.cwd(),
|
|
6789
|
+
command: process.argv.join(" "),
|
|
6790
|
+
nodeVersion: process.version,
|
|
6791
|
+
platform: process.platform,
|
|
6792
|
+
architecture: process.arch,
|
|
6793
|
+
isOtlpEndpointConfigured: Boolean(process.env[OTLP_ENDPOINT_ENVIRONMENT_VARIABLE]),
|
|
6794
|
+
isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE])
|
|
6795
|
+
});
|
|
6796
|
+
const formatConfiguredState = (isConfigured) => isConfigured ? "yes" : "no";
|
|
6797
|
+
const buildErrorIssueBody = (error, context) => {
|
|
6798
|
+
const formattedError = formatErrorForReport(error) || "(empty error)";
|
|
6799
|
+
const isOtlpExporterEnabled = context.isOtlpEndpointConfigured && context.isOtlpAuthHeaderConfigured;
|
|
6800
|
+
return [
|
|
6801
|
+
"## Error",
|
|
6802
|
+
"",
|
|
6803
|
+
"```text",
|
|
6804
|
+
formattedError,
|
|
6805
|
+
"```",
|
|
6806
|
+
"",
|
|
6807
|
+
"## Runtime",
|
|
6808
|
+
"",
|
|
6809
|
+
`- react-doctor version: ${VERSION}`,
|
|
6810
|
+
`- node: ${context.nodeVersion}`,
|
|
6811
|
+
`- platform: ${context.platform} ${context.architecture}`,
|
|
6812
|
+
`- cwd: ${context.cwd}`,
|
|
6813
|
+
`- command: ${context.command}`,
|
|
6814
|
+
"",
|
|
6815
|
+
"## OpenTelemetry",
|
|
6816
|
+
"",
|
|
6817
|
+
`- ${OTLP_ENDPOINT_ENVIRONMENT_VARIABLE} configured: ${formatConfiguredState(context.isOtlpEndpointConfigured)}`,
|
|
6818
|
+
`- ${OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE} configured: ${formatConfiguredState(context.isOtlpAuthHeaderConfigured)} (value redacted)`,
|
|
6819
|
+
`- OTLP exporter enabled: ${formatConfiguredState(isOtlpExporterEnabled)}`,
|
|
6820
|
+
"- trace/span link, if exported: ",
|
|
6821
|
+
"",
|
|
6822
|
+
"## Notes",
|
|
6823
|
+
"",
|
|
6824
|
+
"Please add reproduction steps and any relevant repository details."
|
|
6825
|
+
].join("\n");
|
|
6826
|
+
};
|
|
6827
|
+
const buildErrorIssueUrl = (error) => {
|
|
6828
|
+
const formattedError = formatSingleLine(formatErrorForReport(error));
|
|
6829
|
+
const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
|
|
6830
|
+
issueUrl.searchParams.set("title", formattedError ? `CLI error: ${formattedError}` : "CLI error");
|
|
6831
|
+
issueUrl.searchParams.set("labels", "bug");
|
|
6832
|
+
issueUrl.searchParams.set("body", buildErrorIssueBody(error, getErrorReportContext()));
|
|
6833
|
+
return issueUrl.toString();
|
|
6834
|
+
};
|
|
6807
6835
|
/**
|
|
6808
6836
|
* Effect-typed renderer: every message routes through `Console.error`
|
|
6809
6837
|
* so test runs can swap `Console` to a capture sink and the output
|
|
@@ -6814,9 +6842,9 @@ const materializeStagedFiles = async (directory, stagedFiles, tempDirectory) =>
|
|
|
6814
6842
|
const handleErrorEffect = (error) => Effect.gen(function* () {
|
|
6815
6843
|
yield* Console.error("");
|
|
6816
6844
|
yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
|
|
6817
|
-
yield* Console.error(highlighter.error(`If the problem persists, please open
|
|
6845
|
+
yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
|
|
6818
6846
|
yield* Console.error("");
|
|
6819
|
-
yield* Console.error(highlighter.error(
|
|
6847
|
+
yield* Console.error(highlighter.error(formatErrorForReport(error)));
|
|
6820
6848
|
yield* Console.error("");
|
|
6821
6849
|
});
|
|
6822
6850
|
/**
|
|
@@ -6833,19 +6861,30 @@ const handleError = (error, options = { shouldExit: true }) => {
|
|
|
6833
6861
|
//#region src/cli/utils/json-mode.ts
|
|
6834
6862
|
let context = null;
|
|
6835
6863
|
/**
|
|
6836
|
-
* JSON mode writes the report payload to stdout; any incidental
|
|
6837
|
-
*
|
|
6838
|
-
*
|
|
6839
|
-
*
|
|
6840
|
-
*
|
|
6864
|
+
* JSON mode writes the report payload to stdout; any incidental log
|
|
6865
|
+
* line printed by an Effect program would corrupt the JSON. Effect's
|
|
6866
|
+
* `Console` module resolves to `globalThis.console` by default (see
|
|
6867
|
+
* `effect/internal/effect.ts` → `ConsoleRef`), so copying the methods
|
|
6868
|
+
* from `makeNoopConsole()` onto the global is enough to silence every
|
|
6841
6869
|
* `yield* Console.log(...)` and `cliLogger.*` call sourced from
|
|
6842
|
-
* react-doctor or its services.
|
|
6843
|
-
*
|
|
6844
|
-
*
|
|
6870
|
+
* react-doctor or its services.
|
|
6871
|
+
*
|
|
6872
|
+
* We use the same `makeNoopConsole()` source as the `--silent` path
|
|
6873
|
+
* (which provides the Effect Console via
|
|
6874
|
+
* `Effect.provideService(Console.Console, makeNoopConsole())`) — one
|
|
6875
|
+
* canonical "no-op console" definition shared by the two silent
|
|
6876
|
+
* mechanisms. The two routes still differ in how they install the
|
|
6877
|
+
* noop: silent mode swaps the Effect Console reference inside the
|
|
6878
|
+
* program; JSON mode patches the global because the surrounding CLI
|
|
6879
|
+
* command body is still imperative. Both will collapse into the
|
|
6880
|
+
* Effect-typed route once the command body finishes its migration.
|
|
6881
|
+
*
|
|
6882
|
+
* JSON mode is one-shot per CLI invocation, so we never restore.
|
|
6845
6883
|
*/
|
|
6846
6884
|
const installSilentConsole = () => {
|
|
6847
|
-
const
|
|
6848
|
-
const
|
|
6885
|
+
const noopConsole = makeNoopConsole();
|
|
6886
|
+
const target = globalThis.console;
|
|
6887
|
+
const source = noopConsole;
|
|
6849
6888
|
for (const key of [
|
|
6850
6889
|
"log",
|
|
6851
6890
|
"error",
|
|
@@ -6853,7 +6892,7 @@ const installSilentConsole = () => {
|
|
|
6853
6892
|
"info",
|
|
6854
6893
|
"debug",
|
|
6855
6894
|
"trace"
|
|
6856
|
-
])
|
|
6895
|
+
]) target[key] = source[key];
|
|
6857
6896
|
};
|
|
6858
6897
|
const enableJsonMode = ({ compact, directory }) => {
|
|
6859
6898
|
context = {
|
|
@@ -6919,6 +6958,168 @@ const printBrandedHeader = Effect.gen(function* () {
|
|
|
6919
6958
|
yield* Console.log("");
|
|
6920
6959
|
});
|
|
6921
6960
|
//#endregion
|
|
6961
|
+
//#region src/cli/utils/copy-issues-to-clipboard.ts
|
|
6962
|
+
const MAX_RULES_SHOWN = 10;
|
|
6963
|
+
const MAX_FILES_PER_RULE = 3;
|
|
6964
|
+
const buildIssuesSummary = (input) => {
|
|
6965
|
+
const lines = [];
|
|
6966
|
+
lines.push(`# React Doctor: ${input.projectName}`);
|
|
6967
|
+
if (input.score) lines.push(`Score: ${input.score.score}/100`);
|
|
6968
|
+
lines.push(`${input.diagnostics.length} issues found`);
|
|
6969
|
+
lines.push("");
|
|
6970
|
+
const sortedRules = [...groupBy([...input.diagnostics], (diagnostic) => diagnostic.rule).entries()].sort(([, diagnosticsA], [, diagnosticsB]) => diagnosticsB.length - diagnosticsA.length);
|
|
6971
|
+
const visibleRules = sortedRules.slice(0, MAX_RULES_SHOWN);
|
|
6972
|
+
for (const [rule, ruleDiagnostics] of visibleRules) {
|
|
6973
|
+
const severity = ruleDiagnostics[0].severity;
|
|
6974
|
+
const uniqueFiles = [...new Set(ruleDiagnostics.map((diagnostic) => diagnostic.filePath))];
|
|
6975
|
+
const shownFiles = uniqueFiles.slice(0, MAX_FILES_PER_RULE);
|
|
6976
|
+
const remainingFileCount = uniqueFiles.length - shownFiles.length;
|
|
6977
|
+
lines.push(`${severity === "error" ? "ERROR" : "WARN"} ${rule} (×${ruleDiagnostics.length})`);
|
|
6978
|
+
lines.push(` ${ruleDiagnostics[0].message}`);
|
|
6979
|
+
for (const filePath of shownFiles) {
|
|
6980
|
+
const firstSite = ruleDiagnostics.find((diagnostic) => diagnostic.filePath === filePath && diagnostic.line > 0);
|
|
6981
|
+
lines.push(` - ${filePath}${firstSite ? `:${firstSite.line}` : ""}`);
|
|
6982
|
+
}
|
|
6983
|
+
if (remainingFileCount > 0) lines.push(` - +${remainingFileCount} more files`);
|
|
6984
|
+
}
|
|
6985
|
+
const hiddenRuleCount = sortedRules.length - visibleRules.length;
|
|
6986
|
+
if (hiddenRuleCount > 0) {
|
|
6987
|
+
lines.push("");
|
|
6988
|
+
lines.push(`+${hiddenRuleCount} more rules`);
|
|
6989
|
+
}
|
|
6990
|
+
try {
|
|
6991
|
+
const diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
|
|
6992
|
+
lines.push("");
|
|
6993
|
+
lines.push(`Full trace: ${diagnosticsDirectory}`);
|
|
6994
|
+
} catch {}
|
|
6995
|
+
lines.push("");
|
|
6996
|
+
lines.push("## How to fix");
|
|
6997
|
+
lines.push("1. Run `npx react-doctor@latest --verbose` to see full details");
|
|
6998
|
+
lines.push("2. Fix errors first, then warnings. Start with high-count rules.");
|
|
6999
|
+
lines.push("3. Read the code before acting. Treat findings as hypotheses, not commands.");
|
|
7000
|
+
lines.push("4. Fix root causes, not symptoms. Don't suppress rules without evidence.");
|
|
7001
|
+
lines.push("5. Run `npx react-doctor@latest --verbose --diff` after changes to verify.");
|
|
7002
|
+
lines.push("6. Split unrelated fixes into separate PRs.");
|
|
7003
|
+
return lines.join("\n");
|
|
7004
|
+
};
|
|
7005
|
+
const copyToClipboard = (text) => {
|
|
7006
|
+
const platform = os.platform();
|
|
7007
|
+
try {
|
|
7008
|
+
if (platform === "darwin") {
|
|
7009
|
+
execSync("pbcopy", {
|
|
7010
|
+
input: text,
|
|
7011
|
+
stdio: [
|
|
7012
|
+
"pipe",
|
|
7013
|
+
"ignore",
|
|
7014
|
+
"ignore"
|
|
7015
|
+
]
|
|
7016
|
+
});
|
|
7017
|
+
return true;
|
|
7018
|
+
}
|
|
7019
|
+
if (platform === "win32") {
|
|
7020
|
+
execSync("clip", {
|
|
7021
|
+
input: text,
|
|
7022
|
+
stdio: [
|
|
7023
|
+
"pipe",
|
|
7024
|
+
"ignore",
|
|
7025
|
+
"ignore"
|
|
7026
|
+
]
|
|
7027
|
+
});
|
|
7028
|
+
return true;
|
|
7029
|
+
}
|
|
7030
|
+
execSync("xclip -selection clipboard", {
|
|
7031
|
+
input: text,
|
|
7032
|
+
stdio: [
|
|
7033
|
+
"pipe",
|
|
7034
|
+
"ignore",
|
|
7035
|
+
"ignore"
|
|
7036
|
+
]
|
|
7037
|
+
});
|
|
7038
|
+
return true;
|
|
7039
|
+
} catch {
|
|
7040
|
+
return false;
|
|
7041
|
+
}
|
|
7042
|
+
};
|
|
7043
|
+
const promptCopyIssues = async (input) => {
|
|
7044
|
+
if (input.diagnostics.length === 0) return;
|
|
7045
|
+
const { shouldCopy } = await prompts({
|
|
7046
|
+
type: "confirm",
|
|
7047
|
+
name: "shouldCopy",
|
|
7048
|
+
message: "Copy issues to clipboard?",
|
|
7049
|
+
initial: true
|
|
7050
|
+
}, { onCancel: () => true });
|
|
7051
|
+
if (!shouldCopy) return;
|
|
7052
|
+
const issuesSummary = buildIssuesSummary(input);
|
|
7053
|
+
if (copyToClipboard(issuesSummary)) cliLogger.log(" Copied to clipboard.");
|
|
7054
|
+
else cliLogger.log(issuesSummary);
|
|
7055
|
+
};
|
|
7056
|
+
//#endregion
|
|
7057
|
+
//#region src/cli/utils/render-multi-project-summary.ts
|
|
7058
|
+
const SUMMARY_BAR_WIDTH_CHARS = 20;
|
|
7059
|
+
const buildMiniBar = (score) => {
|
|
7060
|
+
const filledCount = Math.round(score / 100 * SUMMARY_BAR_WIDTH_CHARS);
|
|
7061
|
+
const emptyCount = SUMMARY_BAR_WIDTH_CHARS - filledCount;
|
|
7062
|
+
return colorizeByScore("█".repeat(filledCount), score) + highlighter.dim("░".repeat(emptyCount));
|
|
7063
|
+
};
|
|
7064
|
+
const getScoreLabel = (score) => {
|
|
7065
|
+
if (score >= 75) return "Great";
|
|
7066
|
+
if (score >= 50) return "OK";
|
|
7067
|
+
return "Needs work";
|
|
7068
|
+
};
|
|
7069
|
+
const buildSummaryLine = (entry, longestProjectNameLength) => {
|
|
7070
|
+
const paddedName = entry.projectName.padEnd(longestProjectNameLength);
|
|
7071
|
+
const nameRendering = entry.score !== null ? colorizeByScore(paddedName, entry.score) : highlighter.dim(paddedName);
|
|
7072
|
+
if (entry.score === null) {
|
|
7073
|
+
const issueLabel = `${entry.issueCount} ${entry.issueCount === 1 ? "issue" : "issues"}`;
|
|
7074
|
+
return ` ${nameRendering} ${highlighter.dim("—".repeat(SUMMARY_BAR_WIDTH_CHARS))} ${highlighter.dim("no score")} ${highlighter.dim(issueLabel)}`;
|
|
7075
|
+
}
|
|
7076
|
+
const scoreRendering = colorizeByScore(String(entry.score).padStart(3), entry.score);
|
|
7077
|
+
const bar = buildMiniBar(entry.score);
|
|
7078
|
+
const label = colorizeByScore(getScoreLabel(entry.score), entry.score);
|
|
7079
|
+
const issuesParts = [];
|
|
7080
|
+
if (entry.errorCount > 0) issuesParts.push(highlighter.error(`${entry.errorCount} ${entry.errorCount === 1 ? "error" : "errors"}`));
|
|
7081
|
+
const warningCount = entry.issueCount - entry.errorCount;
|
|
7082
|
+
if (warningCount > 0) issuesParts.push(highlighter.warn(`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`));
|
|
7083
|
+
return ` ${nameRendering} ${scoreRendering} ${bar} ${label} ${issuesParts.length > 0 ? issuesParts.join(highlighter.dim(", ")) : ""}`;
|
|
7084
|
+
};
|
|
7085
|
+
const computeAggregateScore = (completedScans) => {
|
|
7086
|
+
const scoredScans = completedScans.filter((scan) => scan.result.score !== null);
|
|
7087
|
+
if (scoredScans.length === 0) return null;
|
|
7088
|
+
return scoredScans.reduce((worst, scan) => scan.result.score.score < worst.result.score.score ? scan : worst).result.score;
|
|
7089
|
+
};
|
|
7090
|
+
const printMultiProjectSummary = (input) => Effect.gen(function* () {
|
|
7091
|
+
const { completedScans, userConfig, verbose } = input;
|
|
7092
|
+
const surfaceDiagnostics = filterDiagnosticsForSurface(completedScans.flatMap((scan) => scan.result.diagnostics), "cli", userConfig);
|
|
7093
|
+
if (surfaceDiagnostics.length > 0) {
|
|
7094
|
+
yield* Console.log("");
|
|
7095
|
+
yield* printDiagnostics(surfaceDiagnostics, verbose, "");
|
|
7096
|
+
}
|
|
7097
|
+
const aggregateScore = computeAggregateScore(completedScans);
|
|
7098
|
+
const totalSourceFileCount = completedScans.reduce((sum, scan) => sum + scan.result.project.sourceFileCount, 0);
|
|
7099
|
+
yield* printSummary({
|
|
7100
|
+
diagnostics: surfaceDiagnostics,
|
|
7101
|
+
elapsedMilliseconds: completedScans.reduce((sum, scan) => sum + scan.result.elapsedMilliseconds, 0),
|
|
7102
|
+
scoreResult: aggregateScore,
|
|
7103
|
+
projectName: completedScans.map((scan) => scan.result.project.projectName).join(", "),
|
|
7104
|
+
totalSourceFileCount,
|
|
7105
|
+
noScoreMessage: "Score unavailable.",
|
|
7106
|
+
isOffline: true,
|
|
7107
|
+
verbose
|
|
7108
|
+
});
|
|
7109
|
+
const entries = completedScans.map((scan) => {
|
|
7110
|
+
const errorCount = scan.result.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
7111
|
+
return {
|
|
7112
|
+
projectName: scan.result.project.projectName,
|
|
7113
|
+
score: scan.result.score?.score ?? null,
|
|
7114
|
+
issueCount: scan.result.diagnostics.length,
|
|
7115
|
+
errorCount
|
|
7116
|
+
};
|
|
7117
|
+
});
|
|
7118
|
+
const longestProjectNameLength = Math.max(...entries.map((entry) => entry.projectName.length));
|
|
7119
|
+
for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
|
|
7120
|
+
yield* Console.log("");
|
|
7121
|
+
});
|
|
7122
|
+
//#endregion
|
|
6922
7123
|
//#region src/cli/utils/git-hook-shared.ts
|
|
6923
7124
|
const HOOK_FILE_NAME = "pre-commit";
|
|
6924
7125
|
const HOOK_RELATIVE_PATH = "hooks/pre-commit";
|
|
@@ -6993,7 +7194,8 @@ const ensureTrailingNewline = (content) => content.endsWith("\n") ? content : `$
|
|
|
6993
7194
|
//#endregion
|
|
6994
7195
|
//#region src/cli/utils/install-doctor-script.ts
|
|
6995
7196
|
const DOCTOR_SCRIPT_NAME = "doctor";
|
|
6996
|
-
const
|
|
7197
|
+
const FALLBACK_DOCTOR_SCRIPT_NAME = "react-doctor";
|
|
7198
|
+
const DOCTOR_SCRIPT_COMMAND = "npx react-doctor@latest";
|
|
6997
7199
|
const DOCTOR_PACKAGE_NAME = "react-doctor";
|
|
6998
7200
|
const DEPENDENCY_FIELD_NAMES = [
|
|
6999
7201
|
"dependencies",
|
|
@@ -7001,50 +7203,87 @@ const DEPENDENCY_FIELD_NAMES = [
|
|
|
7001
7203
|
"optionalDependencies",
|
|
7002
7204
|
"peerDependencies"
|
|
7003
7205
|
];
|
|
7004
|
-
const
|
|
7206
|
+
const isReactDoctorScriptCommand = (value) => typeof value === "string" && /\breact-doctor\b/.test(value);
|
|
7207
|
+
const findNearestPackageDirectory = (startDirectory, stopDirectory) => {
|
|
7208
|
+
let currentDirectory = path.resolve(startDirectory);
|
|
7209
|
+
const resolvedStopDirectory = stopDirectory === void 0 ? void 0 : path.resolve(stopDirectory);
|
|
7210
|
+
while (true) {
|
|
7211
|
+
if (existsSync(getPackageJsonPath(currentDirectory))) return currentDirectory;
|
|
7212
|
+
if (currentDirectory === resolvedStopDirectory) return null;
|
|
7213
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
7214
|
+
if (parentDirectory === currentDirectory) return null;
|
|
7215
|
+
currentDirectory = parentDirectory;
|
|
7216
|
+
}
|
|
7217
|
+
};
|
|
7005
7218
|
const hasDoctorScript = (projectRoot) => {
|
|
7006
|
-
const packageJson = readPackageJson(projectRoot);
|
|
7219
|
+
const packageJson = readPackageJson(findNearestPackageDirectory(projectRoot) ?? projectRoot);
|
|
7007
7220
|
if (!isRecord(packageJson)) return false;
|
|
7008
7221
|
const scripts = packageJson.scripts;
|
|
7009
|
-
|
|
7222
|
+
if (!isRecord(scripts)) return false;
|
|
7223
|
+
return isReactDoctorScriptCommand(scripts["doctor"]) || isReactDoctorScriptCommand(scripts["react-doctor"]);
|
|
7010
7224
|
};
|
|
7011
7225
|
const hasDoctorDependency = (packageJson) => DEPENDENCY_FIELD_NAMES.some((fieldName) => {
|
|
7012
7226
|
const dependencies = packageJson[fieldName];
|
|
7013
7227
|
return isRecord(dependencies) && Object.hasOwn(dependencies, "react-doctor");
|
|
7014
7228
|
});
|
|
7015
7229
|
const installDoctorScript = (options) => {
|
|
7016
|
-
const
|
|
7017
|
-
const
|
|
7230
|
+
const packageDirectory = findNearestPackageDirectory(options.projectRoot) ?? options.projectRoot;
|
|
7231
|
+
const packageJsonPath = getPackageJsonPath(packageDirectory);
|
|
7232
|
+
const packageJson = readPackageJson(packageDirectory);
|
|
7018
7233
|
if (!isRecord(packageJson)) return {
|
|
7019
7234
|
packageJsonPath,
|
|
7020
7235
|
scriptStatus: "skipped",
|
|
7021
|
-
|
|
7022
|
-
scriptReason: "missing-or-invalid-package-json",
|
|
7023
|
-
dependencyReason: "missing-or-invalid-package-json"
|
|
7236
|
+
scriptReason: "missing-or-invalid-package-json"
|
|
7024
7237
|
};
|
|
7025
7238
|
const scripts = packageJson.scripts;
|
|
7026
|
-
const
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7239
|
+
const scriptTarget = (() => {
|
|
7240
|
+
if (scripts !== void 0 && !isRecord(scripts)) return {
|
|
7241
|
+
status: "skipped",
|
|
7242
|
+
reason: "invalid-scripts"
|
|
7243
|
+
};
|
|
7244
|
+
const scriptRecord = isRecord(scripts) ? scripts : {};
|
|
7245
|
+
if (isReactDoctorScriptCommand(scriptRecord["doctor"])) return {
|
|
7246
|
+
scriptName: DOCTOR_SCRIPT_NAME,
|
|
7247
|
+
status: "existing"
|
|
7248
|
+
};
|
|
7249
|
+
if (!Object.hasOwn(scriptRecord, "doctor")) {
|
|
7250
|
+
if (isReactDoctorScriptCommand(scriptRecord["react-doctor"])) return {
|
|
7251
|
+
scriptName: FALLBACK_DOCTOR_SCRIPT_NAME,
|
|
7252
|
+
status: "existing"
|
|
7253
|
+
};
|
|
7254
|
+
return {
|
|
7255
|
+
scriptName: DOCTOR_SCRIPT_NAME,
|
|
7256
|
+
status: "created"
|
|
7257
|
+
};
|
|
7258
|
+
}
|
|
7259
|
+
if (isReactDoctorScriptCommand(scriptRecord["react-doctor"])) return {
|
|
7260
|
+
scriptName: FALLBACK_DOCTOR_SCRIPT_NAME,
|
|
7261
|
+
status: "existing",
|
|
7262
|
+
reason: "doctor-script-taken"
|
|
7263
|
+
};
|
|
7264
|
+
if (Object.hasOwn(scriptRecord, "react-doctor")) return {
|
|
7265
|
+
status: "skipped",
|
|
7266
|
+
reason: "script-names-taken"
|
|
7267
|
+
};
|
|
7268
|
+
return {
|
|
7269
|
+
scriptName: FALLBACK_DOCTOR_SCRIPT_NAME,
|
|
7270
|
+
status: "created",
|
|
7271
|
+
reason: "doctor-script-taken"
|
|
7272
|
+
};
|
|
7273
|
+
})();
|
|
7274
|
+
const scriptStatus = scriptTarget.status;
|
|
7275
|
+
if (scriptStatus === "created") writeJsonFile$1(packageJsonPath, {
|
|
7032
7276
|
...packageJson,
|
|
7033
|
-
|
|
7277
|
+
scripts: {
|
|
7034
7278
|
...isRecord(scripts) ? scripts : {},
|
|
7035
|
-
[
|
|
7036
|
-
}
|
|
7037
|
-
...didCreateDependency ? { devDependencies: {
|
|
7038
|
-
...isRecord(devDependencies) ? devDependencies : {},
|
|
7039
|
-
[DOCTOR_PACKAGE_NAME]: getDoctorDependencyVersion()
|
|
7040
|
-
} } : {}
|
|
7279
|
+
[scriptTarget.scriptName ?? "doctor"]: DOCTOR_SCRIPT_COMMAND
|
|
7280
|
+
}
|
|
7041
7281
|
});
|
|
7042
7282
|
return {
|
|
7043
7283
|
packageJsonPath,
|
|
7284
|
+
...scriptTarget.scriptName !== void 0 ? { scriptName: scriptTarget.scriptName } : {},
|
|
7044
7285
|
scriptStatus,
|
|
7045
|
-
|
|
7046
|
-
...scriptStatus === "skipped" ? { scriptReason: "invalid-scripts" } : {},
|
|
7047
|
-
...dependencyStatus === "skipped" ? { dependencyReason: "invalid-dev-dependencies" } : {}
|
|
7286
|
+
...scriptTarget.reason !== void 0 ? { scriptReason: scriptTarget.reason } : {}
|
|
7048
7287
|
};
|
|
7049
7288
|
};
|
|
7050
7289
|
const SETUP_PROMPT_CHOICE_NEVER = "never";
|
|
@@ -7077,9 +7316,20 @@ const shouldPromptInstallSetup = (options) => {
|
|
|
7077
7316
|
if (options.isScoreOnly) return false;
|
|
7078
7317
|
if (options.isStaged) return false;
|
|
7079
7318
|
if (options.skipPrompts) return false;
|
|
7319
|
+
if (isCiOrCodingAgentEnvironment()) return false;
|
|
7080
7320
|
if (hasDisabledSetupPrompt(options.projectRoot, options.store)) return false;
|
|
7081
7321
|
return !hasDoctorScript(options.projectRoot);
|
|
7082
7322
|
};
|
|
7323
|
+
const resolveInstallSetupProjectRoot = (options) => {
|
|
7324
|
+
if (options.completedScanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
|
|
7325
|
+
const packageDirectories = /* @__PURE__ */ new Set();
|
|
7326
|
+
for (const scanDirectory of options.completedScanDirectories) {
|
|
7327
|
+
const packageDirectory = findNearestPackageDirectory(scanDirectory, options.scanRoot) ?? findNearestPackageDirectory(scanDirectory) ?? scanDirectory;
|
|
7328
|
+
packageDirectories.add(packageDirectory);
|
|
7329
|
+
}
|
|
7330
|
+
if (packageDirectories.size !== 1) return null;
|
|
7331
|
+
return [...packageDirectories][0] ?? null;
|
|
7332
|
+
};
|
|
7083
7333
|
const defaultWait = (milliseconds) => new Promise((resolve) => {
|
|
7084
7334
|
setTimeout(resolve, milliseconds);
|
|
7085
7335
|
});
|
|
@@ -7088,20 +7338,15 @@ const defaultSelect = async (message) => {
|
|
|
7088
7338
|
type: "select",
|
|
7089
7339
|
name: "setupReactDoctorChoice",
|
|
7090
7340
|
message,
|
|
7091
|
-
choices: [
|
|
7092
|
-
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
{
|
|
7101
|
-
title: "No, never ask again for this project",
|
|
7102
|
-
value: SETUP_PROMPT_CHOICE_NEVER
|
|
7103
|
-
}
|
|
7104
|
-
],
|
|
7341
|
+
choices: [{
|
|
7342
|
+
title: "Yes (recommended)",
|
|
7343
|
+
description: "Use agents to automatically fix issues",
|
|
7344
|
+
value: "yes"
|
|
7345
|
+
}, {
|
|
7346
|
+
title: "Skip",
|
|
7347
|
+
description: "Not recommended. Issues may go unfixed.",
|
|
7348
|
+
value: SETUP_PROMPT_CHOICE_NEVER
|
|
7349
|
+
}],
|
|
7105
7350
|
initial: 0
|
|
7106
7351
|
}, { onCancel: () => true });
|
|
7107
7352
|
return setupReactDoctorChoice ?? "no";
|
|
@@ -7109,15 +7354,6 @@ const defaultSelect = async (message) => {
|
|
|
7109
7354
|
const defaultWriteLine = (line = "") => {
|
|
7110
7355
|
console.log(line);
|
|
7111
7356
|
};
|
|
7112
|
-
const buildInstallSetupPitchLines = (issueCount) => {
|
|
7113
|
-
const issueLabel = `${issueCount} ${issueCount === 1 ? "issue" : "issues"}`;
|
|
7114
|
-
return [
|
|
7115
|
-
"",
|
|
7116
|
-
issueCount > 0 ? `React Doctor found ${issueLabel}! Do you want to add React Doctor to this project? It will help humans and agents keep working through those fixes after this scan.` : "React Doctor did not find issues this time! Do you want to add React Doctor to this project? It will help humans and agents catch future regressions early.",
|
|
7117
|
-
"Setup will add a `doctor` package script, install React Doctor skills for your coding agents, and offer optional hooks for pre-commit and post-edit checks.",
|
|
7118
|
-
""
|
|
7119
|
-
];
|
|
7120
|
-
};
|
|
7121
7357
|
const formatSetupPromptFailure = (error) => error instanceof Error ? error.message : String(error);
|
|
7122
7358
|
const warnSetupPromptFailure = async (options, error) => {
|
|
7123
7359
|
const message = `React Doctor setup prompt skipped: ${formatSetupPromptFailure(error)}`;
|
|
@@ -7126,22 +7362,22 @@ const warnSetupPromptFailure = async (options, error) => {
|
|
|
7126
7362
|
return;
|
|
7127
7363
|
}
|
|
7128
7364
|
try {
|
|
7129
|
-
const { cliLogger } = await import("./cli-logger-
|
|
7365
|
+
const { cliLogger } = await import("./cli-logger-C35LXalM.js").then((n) => n.n);
|
|
7130
7366
|
cliLogger.warn(message);
|
|
7131
7367
|
} catch {}
|
|
7132
7368
|
};
|
|
7133
7369
|
const promptInstallSetup = async (options) => {
|
|
7134
7370
|
try {
|
|
7135
7371
|
if (!shouldPromptInstallSetup(options)) return;
|
|
7136
|
-
await (options.wait ?? defaultWait)(
|
|
7137
|
-
const writeLine = options.writeLine ?? defaultWriteLine;
|
|
7138
|
-
for (const line of buildInstallSetupPitchLines(options.issueCount)) writeLine(line);
|
|
7372
|
+
await (options.wait ?? defaultWait)(100);
|
|
7139
7373
|
const setupReactDoctorChoice = await (options.select ?? defaultSelect)("Set up React Doctor for this project?");
|
|
7140
|
-
if (setupReactDoctorChoice
|
|
7141
|
-
disableSetupPrompt(options.projectRoot, options.store);
|
|
7374
|
+
if (setupReactDoctorChoice !== "yes") {
|
|
7375
|
+
if (setupReactDoctorChoice === "never") disableSetupPrompt(options.projectRoot, options.store);
|
|
7376
|
+
const writeLine = options.writeLine ?? defaultWriteLine;
|
|
7377
|
+
writeLine("");
|
|
7378
|
+
writeLine("You can always run `npx react-doctor@latest install` to set it up later.");
|
|
7142
7379
|
return;
|
|
7143
7380
|
}
|
|
7144
|
-
if (setupReactDoctorChoice !== "yes") return;
|
|
7145
7381
|
const install = options.install ?? (await Promise.resolve().then(() => install_skill_exports)).runInstallSkill;
|
|
7146
7382
|
const previousExitCode = process.exitCode;
|
|
7147
7383
|
let setupExitCode;
|
|
@@ -7160,25 +7396,50 @@ const promptInstallSetup = async (options) => {
|
|
|
7160
7396
|
await warnSetupPromptFailure(options, error);
|
|
7161
7397
|
}
|
|
7162
7398
|
};
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7399
|
+
const shouldShowAgentInstallHint = (options) => {
|
|
7400
|
+
if (!options.hasScoredScan) return false;
|
|
7401
|
+
if (options.isJsonMode) return false;
|
|
7402
|
+
if (options.isScoreOnly) return false;
|
|
7403
|
+
if (options.isStaged) return false;
|
|
7404
|
+
if (!(options.isCodingAgent ?? isCodingAgentEnvironment())) return false;
|
|
7405
|
+
if (hasDisabledSetupPrompt(options.projectRoot, options.store)) return false;
|
|
7406
|
+
return !hasDoctorScript(options.projectRoot);
|
|
7407
|
+
};
|
|
7408
|
+
const AGENT_INSTALL_HINT_LINES = [
|
|
7409
|
+
"React Doctor is not installed in this project.",
|
|
7410
|
+
"Ask the user if they would like to set it up, then run:",
|
|
7411
|
+
"",
|
|
7412
|
+
" npx react-doctor install --yes",
|
|
7413
|
+
"",
|
|
7414
|
+
"This adds agent skill files, a `doctor` package script, and react-doctor as a dev dependency."
|
|
7169
7415
|
];
|
|
7170
|
-
const
|
|
7416
|
+
const printAgentInstallHint = (writeLine = defaultWriteLine) => {
|
|
7417
|
+
writeLine("");
|
|
7418
|
+
for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
|
|
7419
|
+
};
|
|
7171
7420
|
//#endregion
|
|
7172
7421
|
//#region src/cli/utils/resolve-cli-inspect-options.ts
|
|
7422
|
+
/**
|
|
7423
|
+
* Translates CLI flags into the `InspectOptions` contract `inspect()`
|
|
7424
|
+
* accepts. Flag-specific computed fields (`scoreOnly`, `noScore`,
|
|
7425
|
+
* `silent`, `outputSurface`, `isCi`) live here — there's no
|
|
7426
|
+
* `userConfig` knob for them, only flag derivation. The remaining
|
|
7427
|
+
* boolean knobs (`lint`, `deadCode`, `verbose`, `respectInlineDisables`)
|
|
7428
|
+
* pass through unchanged: `inspect()` owns the userConfig-fallback
|
|
7429
|
+
* layer so the merge logic isn't duplicated. The shell still hands
|
|
7430
|
+
* `userConfig` in via `configOverride` and `noScore` so this resolver
|
|
7431
|
+
* can apply the one flag-and-config rule that flags own
|
|
7432
|
+
* (`--score false` wins, otherwise inherit `userConfig.noScore`).
|
|
7433
|
+
*/
|
|
7173
7434
|
const resolveCliInspectOptions = (flags, userConfig) => ({
|
|
7174
|
-
lint: flags.lint
|
|
7175
|
-
deadCode: flags.deadCode
|
|
7176
|
-
verbose: flags.verbose
|
|
7435
|
+
lint: flags.lint,
|
|
7436
|
+
deadCode: flags.deadCode,
|
|
7437
|
+
verbose: flags.verbose,
|
|
7438
|
+
respectInlineDisables: flags.respectInlineDisables,
|
|
7177
7439
|
scoreOnly: flags.score === true,
|
|
7178
7440
|
noScore: flags.score === false || (userConfig?.noScore ?? false),
|
|
7179
7441
|
isCi: isCiEnvironment(),
|
|
7180
7442
|
silent: Boolean(flags.json),
|
|
7181
|
-
respectInlineDisables: flags.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
|
|
7182
7443
|
outputSurface: flags.prComment ? "prComment" : "cli"
|
|
7183
7444
|
});
|
|
7184
7445
|
//#endregion
|
|
@@ -7198,14 +7459,20 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQui
|
|
|
7198
7459
|
if (changedSourceFiles.length === 0) return false;
|
|
7199
7460
|
if (shouldSkipPrompts) return false;
|
|
7200
7461
|
if (isQuiet) return false;
|
|
7201
|
-
const
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7462
|
+
const { scanScope } = await prompts({
|
|
7463
|
+
type: "select",
|
|
7464
|
+
name: "scanScope",
|
|
7465
|
+
message: "Select",
|
|
7466
|
+
choices: [{
|
|
7467
|
+
title: "Full codebase",
|
|
7468
|
+
value: "full"
|
|
7469
|
+
}, {
|
|
7470
|
+
title: `Changed files (${changedSourceFiles.length})`,
|
|
7471
|
+
value: "branch"
|
|
7472
|
+
}],
|
|
7473
|
+
initial: 0
|
|
7207
7474
|
});
|
|
7208
|
-
return
|
|
7475
|
+
return scanScope === "branch";
|
|
7209
7476
|
};
|
|
7210
7477
|
//#endregion
|
|
7211
7478
|
//#region src/cli/utils/coerce-diff-value.ts
|
|
@@ -7259,6 +7526,40 @@ const resolveProjectDiffIncludePaths = (rootDirectory, projectDirectory, diffInf
|
|
|
7259
7526
|
});
|
|
7260
7527
|
};
|
|
7261
7528
|
//#endregion
|
|
7529
|
+
//#region src/cli/utils/build-diagnostic-issue-url.ts
|
|
7530
|
+
const formatRuleIdentifier = (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
7531
|
+
const buildDiagnosticIssueBody = (input) => {
|
|
7532
|
+
const { diagnostic, relativeFilePath } = input;
|
|
7533
|
+
const lines = [
|
|
7534
|
+
"## Diagnostic",
|
|
7535
|
+
"",
|
|
7536
|
+
`- Rule: ${formatRuleIdentifier(diagnostic)}`,
|
|
7537
|
+
`- Severity: ${diagnostic.severity}`,
|
|
7538
|
+
`- Category: ${diagnostic.category}`,
|
|
7539
|
+
`- Location: ${relativeFilePath}:${diagnostic.line}`,
|
|
7540
|
+
"",
|
|
7541
|
+
"## Message",
|
|
7542
|
+
"",
|
|
7543
|
+
"```text",
|
|
7544
|
+
diagnostic.message,
|
|
7545
|
+
"```"
|
|
7546
|
+
];
|
|
7547
|
+
if (diagnostic.help) lines.push("", "## Suggested Fix", "", "```text", diagnostic.help, "```");
|
|
7548
|
+
lines.push("", "## Why this looks wrong or needs follow-up", "", "Please explain why this should be changed, suppressed, or treated as a false positive.");
|
|
7549
|
+
return lines.join("\n");
|
|
7550
|
+
};
|
|
7551
|
+
const buildDiagnosticIssueUrl = (input) => {
|
|
7552
|
+
const { diagnostic, relativeFilePath } = input;
|
|
7553
|
+
const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
|
|
7554
|
+
issueUrl.searchParams.set("title", `Diagnostic follow-up: ${formatRuleIdentifier(diagnostic)}`);
|
|
7555
|
+
issueUrl.searchParams.set("labels", "bug");
|
|
7556
|
+
issueUrl.searchParams.set("body", buildDiagnosticIssueBody({
|
|
7557
|
+
diagnostic,
|
|
7558
|
+
relativeFilePath
|
|
7559
|
+
}));
|
|
7560
|
+
return issueUrl.toString();
|
|
7561
|
+
};
|
|
7562
|
+
//#endregion
|
|
7262
7563
|
//#region src/cli/utils/find-owning-project.ts
|
|
7263
7564
|
const findOwningProjectDirectory = (rootDirectory, filePath) => {
|
|
7264
7565
|
const absoluteFile = path.isAbsolute(filePath) ? filePath : path.resolve(rootDirectory, filePath);
|
|
@@ -7297,7 +7598,10 @@ const parseFileLineArgument = (rawArgument) => {
|
|
|
7297
7598
|
//#region src/cli/utils/select-projects.ts
|
|
7298
7599
|
const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
7299
7600
|
let packages = listWorkspacePackages(rootDirectory);
|
|
7300
|
-
if (packages.length === 0)
|
|
7601
|
+
if (packages.length === 0) {
|
|
7602
|
+
if (!isMonorepoRoot(rootDirectory)) return [rootDirectory];
|
|
7603
|
+
packages = discoverReactSubprojects(rootDirectory);
|
|
7604
|
+
}
|
|
7301
7605
|
if (packages.length === 0) return [rootDirectory];
|
|
7302
7606
|
if (packages.length === 1) {
|
|
7303
7607
|
cliLogger.log(`${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${packages[0].name}`);
|
|
@@ -7375,6 +7679,10 @@ const runExplain = async (fileLineArgument, context) => {
|
|
|
7375
7679
|
cliLogger.log(`${severitySymbol} ${colorizedRule} ${highlighter.dim(`(${severityLabel})`)} — ${diagnostic.message}`);
|
|
7376
7680
|
if (diagnostic.category) cliLogger.dim(` Category: ${diagnostic.category}`);
|
|
7377
7681
|
if (diagnostic.help) cliLogger.dim(` ${diagnostic.help}`);
|
|
7682
|
+
cliLogger.dim(` If this needs follow-up or looks like a false positive, open: ${buildDiagnosticIssueUrl({
|
|
7683
|
+
diagnostic,
|
|
7684
|
+
relativeFilePath: requestedRelativePath
|
|
7685
|
+
})}`);
|
|
7378
7686
|
if (diagnostic.suppressionHint) {
|
|
7379
7687
|
cliLogger.break();
|
|
7380
7688
|
cliLogger.log(` Suppression diagnosis: ${diagnostic.suppressionHint}`);
|
|
@@ -7407,6 +7715,26 @@ const validateModeFlags = (flags) => {
|
|
|
7407
7715
|
};
|
|
7408
7716
|
//#endregion
|
|
7409
7717
|
//#region src/cli/commands/inspect.ts
|
|
7718
|
+
/**
|
|
7719
|
+
* Post-scan finalization shared by the staged-arm and project-loop
|
|
7720
|
+
* paths of `inspectAction`: emit the JSON report (when in JSON mode),
|
|
7721
|
+
* print PR annotations (when `--annotations`), and set
|
|
7722
|
+
* `process.exitCode = 1` when the configured fail-on threshold is
|
|
7723
|
+
* crossed. Both arms previously inlined the same four-step shape.
|
|
7724
|
+
*/
|
|
7725
|
+
const finalizeScans = (input) => {
|
|
7726
|
+
if (input.isJsonMode) writeJsonReport(buildJsonReport({
|
|
7727
|
+
version: VERSION,
|
|
7728
|
+
directory: input.resolvedDirectory,
|
|
7729
|
+
mode: input.mode,
|
|
7730
|
+
diff: input.diff,
|
|
7731
|
+
scans: input.completedScans,
|
|
7732
|
+
totalElapsedMilliseconds: performance.now() - input.startTime
|
|
7733
|
+
}));
|
|
7734
|
+
if (input.flags.annotations) printAnnotations(input.diagnostics, input.isJsonMode);
|
|
7735
|
+
const ciFailureDiagnostics = filterDiagnosticsForSurface(input.diagnostics, "ciFailure", input.userConfig);
|
|
7736
|
+
if (!input.isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(input.flags, input.userConfig))) process.exitCode = 1;
|
|
7737
|
+
};
|
|
7410
7738
|
const inspectAction = async (directory, flags) => {
|
|
7411
7739
|
const isScoreOnly = Boolean(flags.score);
|
|
7412
7740
|
const isJsonMode = Boolean(flags.json);
|
|
@@ -7419,12 +7747,11 @@ const inspectAction = async (directory, flags) => {
|
|
|
7419
7747
|
});
|
|
7420
7748
|
try {
|
|
7421
7749
|
validateModeFlags(flags);
|
|
7422
|
-
const
|
|
7423
|
-
const userConfig =
|
|
7424
|
-
const
|
|
7425
|
-
const resolvedDirectory = redirectedDirectory ?? requestedDirectory;
|
|
7750
|
+
const scanTarget = resolveScanTarget(requestedDirectory);
|
|
7751
|
+
const userConfig = scanTarget.userConfig;
|
|
7752
|
+
const resolvedDirectory = scanTarget.resolvedDirectory;
|
|
7426
7753
|
setJsonReportDirectory(resolvedDirectory);
|
|
7427
|
-
if (
|
|
7754
|
+
if (scanTarget.didRedirectViaRootDir && !isQuiet) {
|
|
7428
7755
|
cliLogger.dim(`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`);
|
|
7429
7756
|
cliLogger.break();
|
|
7430
7757
|
}
|
|
@@ -7475,12 +7802,9 @@ const inspectAction = async (directory, flags) => {
|
|
|
7475
7802
|
...diagnostic,
|
|
7476
7803
|
filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
|
|
7477
7804
|
}));
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
mode: "staged",
|
|
7482
|
-
diff: null,
|
|
7483
|
-
scans: [{
|
|
7805
|
+
finalizeScans({
|
|
7806
|
+
diagnostics: remappedDiagnostics,
|
|
7807
|
+
completedScans: [{
|
|
7484
7808
|
directory: resolvedDirectory,
|
|
7485
7809
|
result: {
|
|
7486
7810
|
...scanResult,
|
|
@@ -7491,11 +7815,15 @@ const inspectAction = async (directory, flags) => {
|
|
|
7491
7815
|
}
|
|
7492
7816
|
}
|
|
7493
7817
|
}],
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7818
|
+
mode: "staged",
|
|
7819
|
+
diff: null,
|
|
7820
|
+
isJsonMode,
|
|
7821
|
+
isScoreOnly,
|
|
7822
|
+
flags,
|
|
7823
|
+
userConfig,
|
|
7824
|
+
resolvedDirectory,
|
|
7825
|
+
startTime
|
|
7826
|
+
});
|
|
7499
7827
|
} finally {
|
|
7500
7828
|
snapshot.cleanup();
|
|
7501
7829
|
}
|
|
@@ -7516,6 +7844,7 @@ const inspectAction = async (directory, flags) => {
|
|
|
7516
7844
|
}
|
|
7517
7845
|
const allDiagnostics = [];
|
|
7518
7846
|
const completedScans = [];
|
|
7847
|
+
const isMultiProject = projectDirectories.length > 1;
|
|
7519
7848
|
for (const projectDirectory of projectDirectories) {
|
|
7520
7849
|
let includePaths;
|
|
7521
7850
|
if (isDiffMode) {
|
|
@@ -7529,42 +7858,68 @@ const inspectAction = async (directory, flags) => {
|
|
|
7529
7858
|
}
|
|
7530
7859
|
includePaths = changedSourceFiles;
|
|
7531
7860
|
}
|
|
7532
|
-
if (!isQuiet)
|
|
7533
|
-
cliLogger.dim(`Scanning ${projectDirectory}...`);
|
|
7534
|
-
cliLogger.break();
|
|
7535
|
-
}
|
|
7861
|
+
if (!isQuiet && !isMultiProject) cliLogger.dim(" ");
|
|
7536
7862
|
const scanResult = await inspect(projectDirectory, {
|
|
7537
7863
|
...scanOptions,
|
|
7538
7864
|
includePaths,
|
|
7539
|
-
configOverride: userConfig
|
|
7865
|
+
configOverride: userConfig,
|
|
7866
|
+
suppressRendering: isMultiProject
|
|
7540
7867
|
});
|
|
7541
7868
|
allDiagnostics.push(...scanResult.diagnostics);
|
|
7542
7869
|
completedScans.push({
|
|
7543
7870
|
directory: projectDirectory,
|
|
7544
7871
|
result: scanResult
|
|
7545
7872
|
});
|
|
7546
|
-
if (!isQuiet) cliLogger.break();
|
|
7873
|
+
if (!isQuiet && !isMultiProject) cliLogger.break();
|
|
7547
7874
|
}
|
|
7548
|
-
if (
|
|
7549
|
-
|
|
7550
|
-
|
|
7875
|
+
if (!isQuiet && isMultiProject && completedScans.length > 0) await Effect.runPromise(printMultiProjectSummary({
|
|
7876
|
+
completedScans,
|
|
7877
|
+
userConfig,
|
|
7878
|
+
verbose: Boolean(flags.verbose)
|
|
7879
|
+
}));
|
|
7880
|
+
finalizeScans({
|
|
7881
|
+
diagnostics: allDiagnostics,
|
|
7882
|
+
completedScans,
|
|
7551
7883
|
mode: isDiffMode ? "diff" : "full",
|
|
7552
7884
|
diff: isDiffMode ? diffInfo : null,
|
|
7553
|
-
scans: completedScans,
|
|
7554
|
-
totalElapsedMilliseconds: performance.now() - startTime
|
|
7555
|
-
}));
|
|
7556
|
-
if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
|
|
7557
|
-
const ciFailureDiagnostics = filterDiagnosticsForSurface(allDiagnostics, "ciFailure", userConfig);
|
|
7558
|
-
if (!isScoreOnly && shouldFailForDiagnostics(ciFailureDiagnostics, resolveFailOnLevel(flags, userConfig))) process.exitCode = 1;
|
|
7559
|
-
await promptInstallSetup({
|
|
7560
|
-
projectRoot: resolvedDirectory,
|
|
7561
|
-
hasScoredScan: completedScans.some((scan) => scan.result.score !== null),
|
|
7562
|
-
issueCount: filterDiagnosticsForSurface(allDiagnostics, scanOptions.outputSurface ?? "cli", userConfig).length,
|
|
7563
7885
|
isJsonMode,
|
|
7564
7886
|
isScoreOnly,
|
|
7565
|
-
|
|
7566
|
-
|
|
7887
|
+
flags,
|
|
7888
|
+
userConfig,
|
|
7889
|
+
resolvedDirectory,
|
|
7890
|
+
startTime
|
|
7891
|
+
});
|
|
7892
|
+
const setupProjectRoot = resolveInstallSetupProjectRoot({
|
|
7893
|
+
scanRoot: resolvedDirectory,
|
|
7894
|
+
completedScanDirectories: completedScans.map((scan) => scan.directory)
|
|
7567
7895
|
});
|
|
7896
|
+
if (setupProjectRoot !== null) {
|
|
7897
|
+
const hasScoredScan = completedScans.some((scan) => scan.result.score !== null);
|
|
7898
|
+
await promptInstallSetup({
|
|
7899
|
+
projectRoot: setupProjectRoot,
|
|
7900
|
+
hasScoredScan,
|
|
7901
|
+
issueCount: filterDiagnosticsForSurface(allDiagnostics, scanOptions.outputSurface ?? "cli", userConfig).length,
|
|
7902
|
+
isJsonMode,
|
|
7903
|
+
isScoreOnly,
|
|
7904
|
+
isStaged: Boolean(flags.staged),
|
|
7905
|
+
skipPrompts
|
|
7906
|
+
});
|
|
7907
|
+
if (shouldShowAgentInstallHint({
|
|
7908
|
+
projectRoot: setupProjectRoot,
|
|
7909
|
+
hasScoredScan,
|
|
7910
|
+
isJsonMode,
|
|
7911
|
+
isScoreOnly,
|
|
7912
|
+
isStaged: Boolean(flags.staged)
|
|
7913
|
+
})) printAgentInstallHint();
|
|
7914
|
+
}
|
|
7915
|
+
if (!skipPrompts && !isQuiet && allDiagnostics.length > 0) {
|
|
7916
|
+
const lastScan = completedScans[completedScans.length - 1];
|
|
7917
|
+
await promptCopyIssues({
|
|
7918
|
+
diagnostics: allDiagnostics,
|
|
7919
|
+
score: lastScan?.result.score ?? null,
|
|
7920
|
+
projectName: lastScan?.result.project.projectName ?? path.basename(resolvedDirectory)
|
|
7921
|
+
});
|
|
7922
|
+
}
|
|
7568
7923
|
} catch (error) {
|
|
7569
7924
|
if (isJsonMode) {
|
|
7570
7925
|
writeJsonErrorReport(error);
|
|
@@ -7765,7 +8120,7 @@ const buildAgentHookScript = () => [
|
|
|
7765
8120
|
"const input = readInput();",
|
|
7766
8121
|
"const scanOutput = fs.readFileSync(outputPath, 'utf8').trim();",
|
|
7767
8122
|
"if (!scanOutput) process.exit(0);",
|
|
7768
|
-
"const message = `React Doctor found issues in the changed files. Review this output and fix the regressions before finishing
|
|
8123
|
+
"const message = `React Doctor found issues in the changed files. Review this output and fix the regressions before finishing. For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.\\n\\n${scanOutput}`;",
|
|
7769
8124
|
"if (input.hook_event_name === 'PostToolBatch') {",
|
|
7770
8125
|
" console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: message } }));",
|
|
7771
8126
|
"} else {",
|
|
@@ -7881,24 +8236,6 @@ const installDirectGitHook = (options) => {
|
|
|
7881
8236
|
};
|
|
7882
8237
|
//#endregion
|
|
7883
8238
|
//#region src/cli/utils/install-git-hook-config-managers.ts
|
|
7884
|
-
const installSimpleGitHooks = (options) => {
|
|
7885
|
-
const packageJsonPath = getPackageJsonPath(options.projectRoot);
|
|
7886
|
-
const didHookExist = existsSync(packageJsonPath);
|
|
7887
|
-
const packageJson = readPackageJson(options.projectRoot);
|
|
7888
|
-
const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
|
|
7889
|
-
const existingConfig = nextPackageJson["simple-git-hooks"];
|
|
7890
|
-
const nextConfig = isRecord(existingConfig) ? { ...existingConfig } : {};
|
|
7891
|
-
const existingPreCommit = typeof nextConfig["pre-commit"] === "string" ? nextConfig["pre-commit"] : "";
|
|
7892
|
-
nextConfig["pre-commit"] = existingPreCommit.includes("react-doctor --staged --fail-on warning") ? existingPreCommit : [existingPreCommit, NON_BLOCKING_REACT_DOCTOR_COMMAND].filter(Boolean).join("\n");
|
|
7893
|
-
nextPackageJson["simple-git-hooks"] = nextConfig;
|
|
7894
|
-
writeJsonFile$1(packageJsonPath, nextPackageJson);
|
|
7895
|
-
removeLegacyManagedRunner(options.projectRoot);
|
|
7896
|
-
return {
|
|
7897
|
-
hookPath: packageJsonPath,
|
|
7898
|
-
kind: "simple-git-hooks",
|
|
7899
|
-
status: didHookExist ? "updated" : "created"
|
|
7900
|
-
};
|
|
7901
|
-
};
|
|
7902
8239
|
const appendStringCommand = (existingCommand) => {
|
|
7903
8240
|
const existingCommandText = typeof existingCommand === "string" ? existingCommand : Array.isArray(existingCommand) ? existingCommand.filter((entry) => typeof entry === "string").join("\n") : "";
|
|
7904
8241
|
return existingCommandText.includes("react-doctor --staged --fail-on warning") ? existingCommandText : [existingCommandText, NON_BLOCKING_REACT_DOCTOR_COMMAND].filter(Boolean).join("\n");
|
|
@@ -7907,58 +8244,63 @@ const appendArrayCommand = (existingCommands) => {
|
|
|
7907
8244
|
const commands = Array.isArray(existingCommands) ? existingCommands.filter((entry) => typeof entry === "string") : typeof existingCommands === "string" ? [existingCommands] : [];
|
|
7908
8245
|
return commands.some((command) => command.includes("react-doctor --staged --fail-on warning")) ? commands : [...commands, NON_BLOCKING_REACT_DOCTOR_COMMAND];
|
|
7909
8246
|
};
|
|
7910
|
-
const
|
|
7911
|
-
const packageJsonPath = getPackageJsonPath(options.projectRoot);
|
|
7912
|
-
const didHookExist = existsSync(packageJsonPath);
|
|
7913
|
-
const packageJson = readPackageJson(options.projectRoot);
|
|
7914
|
-
const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
|
|
7915
|
-
const existingConfig = nextPackageJson[configKey];
|
|
7916
|
-
const nextConfig = isRecord(existingConfig) ? { ...existingConfig } : {};
|
|
7917
|
-
nextConfig["pre-commit"] = appendStringCommand(nextConfig["pre-commit"]);
|
|
7918
|
-
nextPackageJson[configKey] = nextConfig;
|
|
7919
|
-
writeJsonFile$1(packageJsonPath, nextPackageJson);
|
|
7920
|
-
removeLegacyManagedRunner(options.projectRoot);
|
|
7921
|
-
return {
|
|
7922
|
-
hookPath: packageJsonPath,
|
|
7923
|
-
kind,
|
|
7924
|
-
status: didHookExist ? "updated" : "created"
|
|
7925
|
-
};
|
|
7926
|
-
};
|
|
7927
|
-
const installGhooks = (options) => {
|
|
7928
|
-
const packageJsonPath = getPackageJsonPath(options.projectRoot);
|
|
7929
|
-
const didHookExist = existsSync(packageJsonPath);
|
|
7930
|
-
const packageJson = readPackageJson(options.projectRoot);
|
|
7931
|
-
const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
|
|
7932
|
-
const existingConfig = nextPackageJson.config;
|
|
7933
|
-
const nextConfig = isRecord(existingConfig) ? { ...existingConfig } : {};
|
|
7934
|
-
const existingGhooks = nextConfig.ghooks;
|
|
7935
|
-
const nextGhooks = isRecord(existingGhooks) ? { ...existingGhooks } : {};
|
|
7936
|
-
nextGhooks["pre-commit"] = appendStringCommand(nextGhooks["pre-commit"]);
|
|
7937
|
-
nextConfig.ghooks = nextGhooks;
|
|
7938
|
-
nextPackageJson.config = nextConfig;
|
|
7939
|
-
writeJsonFile$1(packageJsonPath, nextPackageJson);
|
|
7940
|
-
removeLegacyManagedRunner(options.projectRoot);
|
|
7941
|
-
return {
|
|
7942
|
-
hookPath: packageJsonPath,
|
|
7943
|
-
kind: "ghooks",
|
|
7944
|
-
status: didHookExist ? "updated" : "created"
|
|
7945
|
-
};
|
|
7946
|
-
};
|
|
7947
|
-
const installPreCommitNpm = (options) => {
|
|
8247
|
+
const installPackageJsonHook = (options, strategy) => {
|
|
7948
8248
|
const packageJsonPath = getPackageJsonPath(options.projectRoot);
|
|
7949
8249
|
const didHookExist = existsSync(packageJsonPath);
|
|
7950
8250
|
const packageJson = readPackageJson(options.projectRoot);
|
|
7951
8251
|
const nextPackageJson = isRecord(packageJson) ? { ...packageJson } : {};
|
|
7952
|
-
|
|
8252
|
+
const parentKeys = strategy.path.slice(0, -1);
|
|
8253
|
+
const leafKey = strategy.path[strategy.path.length - 1];
|
|
8254
|
+
let parent = nextPackageJson;
|
|
8255
|
+
for (const key of parentKeys) {
|
|
8256
|
+
const existing = parent[key];
|
|
8257
|
+
const cloned = isRecord(existing) ? { ...existing } : {};
|
|
8258
|
+
parent[key] = cloned;
|
|
8259
|
+
parent = cloned;
|
|
8260
|
+
}
|
|
8261
|
+
parent[leafKey] = strategy.leafShape === "array" ? appendArrayCommand(parent[leafKey]) : appendStringCommand(parent[leafKey]);
|
|
7953
8262
|
writeJsonFile$1(packageJsonPath, nextPackageJson);
|
|
7954
8263
|
removeLegacyManagedRunner(options.projectRoot);
|
|
7955
8264
|
return {
|
|
7956
8265
|
hookPath: packageJsonPath,
|
|
7957
|
-
kind:
|
|
8266
|
+
kind: strategy.kind,
|
|
7958
8267
|
status: didHookExist ? "updated" : "created"
|
|
7959
8268
|
};
|
|
7960
8269
|
};
|
|
7961
|
-
const
|
|
8270
|
+
const installSimpleGitHooks = (options) => installPackageJsonHook(options, {
|
|
8271
|
+
kind: "simple-git-hooks",
|
|
8272
|
+
path: ["simple-git-hooks", "pre-commit"],
|
|
8273
|
+
leafShape: "string"
|
|
8274
|
+
});
|
|
8275
|
+
const installGhooks = (options) => installPackageJsonHook(options, {
|
|
8276
|
+
kind: "ghooks",
|
|
8277
|
+
path: [
|
|
8278
|
+
"config",
|
|
8279
|
+
"ghooks",
|
|
8280
|
+
"pre-commit"
|
|
8281
|
+
],
|
|
8282
|
+
leafShape: "string"
|
|
8283
|
+
});
|
|
8284
|
+
const installPreCommitNpm = (options) => installPackageJsonHook(options, {
|
|
8285
|
+
kind: "pre-commit-npm",
|
|
8286
|
+
path: ["pre-commit"],
|
|
8287
|
+
leafShape: "array"
|
|
8288
|
+
});
|
|
8289
|
+
const installPrettyQuick = (options) => installPackageJsonHook(options, {
|
|
8290
|
+
kind: "pretty-quick",
|
|
8291
|
+
path: ["gitHooks", "pre-commit"],
|
|
8292
|
+
leafShape: "string"
|
|
8293
|
+
});
|
|
8294
|
+
const installYorkie = (options) => installPackageJsonHook(options, {
|
|
8295
|
+
kind: "yorkie",
|
|
8296
|
+
path: ["gitHooks", "pre-commit"],
|
|
8297
|
+
leafShape: "string"
|
|
8298
|
+
});
|
|
8299
|
+
const installGitHooksJs = (options) => installPackageJsonHook(options, {
|
|
8300
|
+
kind: "git-hooks-js",
|
|
8301
|
+
path: ["git-hooks", "pre-commit"],
|
|
8302
|
+
leafShape: "string"
|
|
8303
|
+
});
|
|
7962
8304
|
const appendIndentedBlockToTopLevelSection = (content, sectionName, block) => {
|
|
7963
8305
|
const normalizedContent = ensureTrailingNewline(content);
|
|
7964
8306
|
const match = new RegExp(`^${sectionName}:\\s*$`, "m").exec(normalizedContent);
|
|
@@ -8188,9 +8530,9 @@ const installReactDoctorGitHook = (options) => {
|
|
|
8188
8530
|
if (options.kind === "lefthook") return installLefthook(options);
|
|
8189
8531
|
if (options.kind === "pre-commit") return installPreCommit(options);
|
|
8190
8532
|
if (options.kind === "overcommit") return installOvercommit(options);
|
|
8191
|
-
if (options.kind === "yorkie") return
|
|
8533
|
+
if (options.kind === "yorkie") return installYorkie(options);
|
|
8192
8534
|
if (options.kind === "ghooks") return installGhooks(options);
|
|
8193
|
-
if (options.kind === "git-hooks-js") return
|
|
8535
|
+
if (options.kind === "git-hooks-js") return installGitHooksJs(options);
|
|
8194
8536
|
if (options.kind === "pre-commit-npm") return installPreCommitNpm(options);
|
|
8195
8537
|
if (options.kind === "pretty-quick") return installPrettyQuick(options);
|
|
8196
8538
|
return installDirectGitHook(options);
|
|
@@ -8198,7 +8540,6 @@ const installReactDoctorGitHook = (options) => {
|
|
|
8198
8540
|
//#endregion
|
|
8199
8541
|
//#region src/cli/utils/install-skill.ts
|
|
8200
8542
|
var install_skill_exports = /* @__PURE__ */ __exportAll({ runInstallSkill: () => runInstallSkill });
|
|
8201
|
-
const NATIVE_AGENT_HOOK_AGENTS = new Set(["claude-code", "cursor"]);
|
|
8202
8543
|
const CONFIG_ONLY_GIT_HOOK_KINDS = new Set([
|
|
8203
8544
|
"ghooks",
|
|
8204
8545
|
"git-hooks-js",
|
|
@@ -8210,35 +8551,167 @@ const CONFIG_ONLY_GIT_HOOK_KINDS = new Set([
|
|
|
8210
8551
|
"simple-git-hooks",
|
|
8211
8552
|
"yorkie"
|
|
8212
8553
|
]);
|
|
8554
|
+
const PACKAGE_MANAGER_LOCKFILES = [
|
|
8555
|
+
{
|
|
8556
|
+
packageManager: "pnpm",
|
|
8557
|
+
fileName: "pnpm-lock.yaml"
|
|
8558
|
+
},
|
|
8559
|
+
{
|
|
8560
|
+
packageManager: "yarn",
|
|
8561
|
+
fileName: "yarn.lock"
|
|
8562
|
+
},
|
|
8563
|
+
{
|
|
8564
|
+
packageManager: "bun",
|
|
8565
|
+
fileName: "bun.lockb"
|
|
8566
|
+
},
|
|
8567
|
+
{
|
|
8568
|
+
packageManager: "bun",
|
|
8569
|
+
fileName: "bun.lock"
|
|
8570
|
+
},
|
|
8571
|
+
{
|
|
8572
|
+
packageManager: "npm",
|
|
8573
|
+
fileName: "package-lock.json"
|
|
8574
|
+
}
|
|
8575
|
+
];
|
|
8576
|
+
const findNearestFileDirectory = (startDirectory, fileNames) => {
|
|
8577
|
+
let currentDirectory = path.resolve(startDirectory);
|
|
8578
|
+
while (true) {
|
|
8579
|
+
if (fileNames.some((fileName) => existsSync(path.join(currentDirectory, fileName)))) return currentDirectory;
|
|
8580
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
8581
|
+
if (parentDirectory === currentDirectory) return null;
|
|
8582
|
+
currentDirectory = parentDirectory;
|
|
8583
|
+
}
|
|
8584
|
+
};
|
|
8585
|
+
const detectPackageManager = (projectRoot) => {
|
|
8586
|
+
let currentDirectory = path.resolve(projectRoot);
|
|
8587
|
+
while (true) {
|
|
8588
|
+
const packageJson = readPackageJson(currentDirectory);
|
|
8589
|
+
if (isRecord(packageJson) && typeof packageJson.packageManager === "string") {
|
|
8590
|
+
const packageManagerName = packageJson.packageManager.split("@")[0];
|
|
8591
|
+
if (packageManagerName === "pnpm" || packageManagerName === "yarn" || packageManagerName === "bun" || packageManagerName === "npm") return packageManagerName;
|
|
8592
|
+
}
|
|
8593
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
8594
|
+
if (parentDirectory === currentDirectory) break;
|
|
8595
|
+
currentDirectory = parentDirectory;
|
|
8596
|
+
}
|
|
8597
|
+
const lockfileDirectory = findNearestFileDirectory(projectRoot, PACKAGE_MANAGER_LOCKFILES.map((lockfile) => lockfile.fileName));
|
|
8598
|
+
return PACKAGE_MANAGER_LOCKFILES.find((lockfile) => lockfileDirectory !== null && existsSync(path.join(lockfileDirectory, lockfile.fileName)))?.packageManager ?? "npm";
|
|
8599
|
+
};
|
|
8600
|
+
const packageManagerNeedsWorkspaceFlag = (projectRoot) => existsSync(path.join(projectRoot, "pnpm-workspace.yaml")) || findNearestFileDirectory(projectRoot, ["pnpm-workspace.yaml"]) !== null;
|
|
8601
|
+
const buildInstallCommand = (projectRoot) => {
|
|
8602
|
+
const packageManager = detectPackageManager(projectRoot);
|
|
8603
|
+
const packageSpecifier = `${DOCTOR_PACKAGE_NAME}@latest`;
|
|
8604
|
+
if (packageManager === "npm") return {
|
|
8605
|
+
command: "npm",
|
|
8606
|
+
args: [
|
|
8607
|
+
"install",
|
|
8608
|
+
"--save-dev",
|
|
8609
|
+
packageSpecifier
|
|
8610
|
+
],
|
|
8611
|
+
cwd: projectRoot
|
|
8612
|
+
};
|
|
8613
|
+
if (packageManager === "yarn") return {
|
|
8614
|
+
command: "yarn",
|
|
8615
|
+
args: [
|
|
8616
|
+
"add",
|
|
8617
|
+
"--dev",
|
|
8618
|
+
packageSpecifier
|
|
8619
|
+
],
|
|
8620
|
+
cwd: projectRoot
|
|
8621
|
+
};
|
|
8622
|
+
if (packageManager === "bun") return {
|
|
8623
|
+
command: "bun",
|
|
8624
|
+
args: [
|
|
8625
|
+
"add",
|
|
8626
|
+
"--dev",
|
|
8627
|
+
packageSpecifier
|
|
8628
|
+
],
|
|
8629
|
+
cwd: projectRoot
|
|
8630
|
+
};
|
|
8631
|
+
return {
|
|
8632
|
+
command: "pnpm",
|
|
8633
|
+
args: [
|
|
8634
|
+
"add",
|
|
8635
|
+
"--save-dev",
|
|
8636
|
+
...packageManagerNeedsWorkspaceFlag(projectRoot) ? ["-w"] : [],
|
|
8637
|
+
packageSpecifier
|
|
8638
|
+
],
|
|
8639
|
+
cwd: projectRoot
|
|
8640
|
+
};
|
|
8641
|
+
};
|
|
8642
|
+
const defaultInstallDependencyRunner = (input) => {
|
|
8643
|
+
execFileSync(input.command, [...input.args], {
|
|
8644
|
+
cwd: input.cwd,
|
|
8645
|
+
stdio: "inherit",
|
|
8646
|
+
env: {
|
|
8647
|
+
...process.env,
|
|
8648
|
+
REACT_DOCTOR_INSTALL: "1"
|
|
8649
|
+
}
|
|
8650
|
+
});
|
|
8651
|
+
};
|
|
8652
|
+
const installReactDoctorDependency = async (options) => {
|
|
8653
|
+
const packageJson = readPackageJson(options.projectRoot);
|
|
8654
|
+
if (!isRecord(packageJson)) return {
|
|
8655
|
+
dependencyStatus: "skipped",
|
|
8656
|
+
dependencyReason: "missing-or-invalid-package-json"
|
|
8657
|
+
};
|
|
8658
|
+
if (hasDoctorDependency(packageJson)) return { dependencyStatus: "existing" };
|
|
8659
|
+
if (packageJson.devDependencies !== void 0 && !isRecord(packageJson.devDependencies)) return {
|
|
8660
|
+
dependencyStatus: "skipped",
|
|
8661
|
+
dependencyReason: "invalid-dev-dependencies"
|
|
8662
|
+
};
|
|
8663
|
+
const runnerInput = buildInstallCommand(options.projectRoot);
|
|
8664
|
+
await (options.runner ?? defaultInstallDependencyRunner)(runnerInput);
|
|
8665
|
+
return { dependencyStatus: "created" };
|
|
8666
|
+
};
|
|
8213
8667
|
const buildManualGitHookTarget = (hookPath, projectRoot) => ({
|
|
8214
8668
|
hookPath,
|
|
8215
8669
|
runnerRoot: projectRoot,
|
|
8216
8670
|
kind: "git"
|
|
8217
8671
|
});
|
|
8218
|
-
const hasNativeAgentHookTarget = (agents) => agents.some((agent) => NATIVE_AGENT_HOOK_AGENTS.has(agent));
|
|
8219
8672
|
const formatGitHookInstallMessage = (hookResult) => {
|
|
8220
8673
|
if (CONFIG_ONLY_GIT_HOOK_KINDS.has(hookResult.kind)) return `React Doctor pre-commit config ${hookResult.status} at ${hookResult.hookPath}. Run your hook manager's install command if hooks are not already installed.`;
|
|
8221
8674
|
return `React Doctor pre-commit hook ${hookResult.status} at ${hookResult.hookPath}.`;
|
|
8222
8675
|
};
|
|
8223
8676
|
const formatDoctorScriptInstallMessage = (scriptResult) => {
|
|
8224
8677
|
const messages = [];
|
|
8225
|
-
|
|
8226
|
-
|
|
8678
|
+
const scriptName = scriptResult.scriptName ?? "doctor";
|
|
8679
|
+
if (scriptResult.scriptStatus === "created") messages.push(`Added package script: ${scriptName}.`);
|
|
8680
|
+
else if (scriptResult.scriptStatus === "existing") messages.push(`Package script already exists: ${scriptName}.`);
|
|
8681
|
+
else if (scriptResult.scriptReason === "script-names-taken") messages.push("Skipped package script: doctor and react-doctor are already taken.");
|
|
8682
|
+
else if (scriptResult.scriptReason === "doctor-script-taken") messages.push("Skipped package script: doctor and react-doctor scripts already exist.");
|
|
8227
8683
|
else if (scriptResult.scriptReason === "invalid-scripts") messages.push(`Skipped package script: scripts field is not an object.`);
|
|
8228
8684
|
else messages.push("Skipped package script: package.json missing or invalid.");
|
|
8229
|
-
if (scriptResult.dependencyStatus === "created") messages.push("Added dev dependency: react-doctor.");
|
|
8230
|
-
else if (scriptResult.dependencyStatus === "existing") messages.push("React Doctor dependency already exists.");
|
|
8231
|
-
else if (scriptResult.dependencyReason === "invalid-dev-dependencies") messages.push("Skipped dev dependency: devDependencies field is not an object.");
|
|
8232
|
-
else messages.push("Skipped dev dependency: package.json missing or invalid.");
|
|
8233
8685
|
return messages.join(" ");
|
|
8234
8686
|
};
|
|
8235
|
-
const
|
|
8236
|
-
|
|
8687
|
+
const formatDependencyInstallMessage = (result) => {
|
|
8688
|
+
if (result.dependencyStatus === "created") return "Installed dev dependency: react-doctor.";
|
|
8689
|
+
if (result.dependencyStatus === "existing") return "React Doctor dependency already exists.";
|
|
8690
|
+
if (result.dependencyReason === "invalid-dev-dependencies") return "Skipped dev dependency install: devDependencies field is not an object.";
|
|
8691
|
+
return "Skipped dev dependency install: package.json missing or invalid.";
|
|
8692
|
+
};
|
|
8693
|
+
const installReactDoctorPackageSetup = async (projectRoot, dependencyRunner) => {
|
|
8694
|
+
const scriptSpinner = spinner("Installing React Doctor package script...").start();
|
|
8237
8695
|
try {
|
|
8238
8696
|
const scriptResult = installDoctorScript({ projectRoot });
|
|
8239
8697
|
scriptSpinner.succeed(formatDoctorScriptInstallMessage(scriptResult));
|
|
8240
8698
|
} catch (error) {
|
|
8241
|
-
scriptSpinner.fail("Failed to install React Doctor package
|
|
8699
|
+
scriptSpinner.fail("Failed to install React Doctor package script.");
|
|
8700
|
+
throw error;
|
|
8701
|
+
}
|
|
8702
|
+
const dependencySpinner = spinner("Installing React Doctor package...").start();
|
|
8703
|
+
try {
|
|
8704
|
+
const dependencyResult = await installReactDoctorDependency({
|
|
8705
|
+
projectRoot,
|
|
8706
|
+
runner: dependencyRunner
|
|
8707
|
+
});
|
|
8708
|
+
if (dependencyResult.dependencyStatus === "skipped") {
|
|
8709
|
+
dependencySpinner.fail(formatDependencyInstallMessage(dependencyResult));
|
|
8710
|
+
return;
|
|
8711
|
+
}
|
|
8712
|
+
dependencySpinner.succeed(formatDependencyInstallMessage(dependencyResult));
|
|
8713
|
+
} catch (error) {
|
|
8714
|
+
dependencySpinner.fail("Failed to install React Doctor package.");
|
|
8242
8715
|
throw error;
|
|
8243
8716
|
}
|
|
8244
8717
|
};
|
|
@@ -8247,9 +8720,9 @@ const getSkillSourceDirectory = () => {
|
|
|
8247
8720
|
return path.join(distDirectory, "skills", SKILL_NAME);
|
|
8248
8721
|
};
|
|
8249
8722
|
const runInstallSkill = async (options = {}) => {
|
|
8250
|
-
const
|
|
8723
|
+
const requestedProjectRoot = options.projectRoot ?? process.cwd();
|
|
8724
|
+
const projectRoot = findNearestPackageDirectory(requestedProjectRoot) ?? requestedProjectRoot;
|
|
8251
8725
|
const sourceDir = options.sourceDir ?? getSkillSourceDirectory();
|
|
8252
|
-
if (!options.dryRun) installReactDoctorPackageSetup(projectRoot);
|
|
8253
8726
|
if (!existsSync(path.join(sourceDir, SKILL_MANIFEST_FILE))) {
|
|
8254
8727
|
cliLogger.error(`Could not locate the ${SKILL_NAME} skill bundled with this package.`);
|
|
8255
8728
|
process.exitCode = 1;
|
|
@@ -8270,7 +8743,7 @@ const runInstallSkill = async (options = {}) => {
|
|
|
8270
8743
|
const selectedAgents = skipPrompts ? detectedAgents : (await prompts({
|
|
8271
8744
|
type: "multiselect",
|
|
8272
8745
|
name: "agents",
|
|
8273
|
-
message: `Install the ${highlighter.info(
|
|
8746
|
+
message: `Install the ${highlighter.info(`/react-doctor`)} skill for:`,
|
|
8274
8747
|
choices: detectedAgents.map((agent) => ({
|
|
8275
8748
|
title: getSkillAgentConfig(agent).displayName,
|
|
8276
8749
|
value: agent,
|
|
@@ -8283,20 +8756,15 @@ const runInstallSkill = async (options = {}) => {
|
|
|
8283
8756
|
const shouldInstallGitHook = gitHookPath !== null && gitHookPath !== void 0 && (Boolean(options.yes) || !skipPrompts && Boolean((await prompts({
|
|
8284
8757
|
type: "confirm",
|
|
8285
8758
|
name: "installGitHook",
|
|
8286
|
-
message: "
|
|
8759
|
+
message: "Check for issues before each commit?",
|
|
8287
8760
|
initial: true
|
|
8288
8761
|
}, promptOptions)).installGitHook));
|
|
8289
|
-
const shouldInstallAgentHooks = Boolean(options.agentHooks)
|
|
8290
|
-
type: "confirm",
|
|
8291
|
-
name: "installAgentHooks",
|
|
8292
|
-
message: "Install native agent hooks after file edits? (Claude Code / Cursor)",
|
|
8293
|
-
initial: false
|
|
8294
|
-
}, promptOptions)).installAgentHooks);
|
|
8762
|
+
const shouldInstallAgentHooks = Boolean(options.agentHooks);
|
|
8295
8763
|
if (options.dryRun) {
|
|
8296
8764
|
cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
|
|
8297
8765
|
for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
|
|
8298
8766
|
cliLogger.dim(` Source: ${sourceDir}`);
|
|
8299
|
-
cliLogger.dim(" Package script: doctor");
|
|
8767
|
+
cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
|
|
8300
8768
|
cliLogger.dim(" Dev dependency: react-doctor");
|
|
8301
8769
|
if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
|
|
8302
8770
|
if (shouldInstallAgentHooks) cliLogger.dim(" Agent hooks: Claude Code / Cursor when selected");
|
|
@@ -8317,6 +8785,7 @@ const runInstallSkill = async (options = {}) => {
|
|
|
8317
8785
|
installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
|
|
8318
8786
|
throw error;
|
|
8319
8787
|
}
|
|
8788
|
+
await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
|
|
8320
8789
|
if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
|
|
8321
8790
|
const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
|
|
8322
8791
|
try {
|
|
@@ -8346,6 +8815,49 @@ const runInstallSkill = async (options = {}) => {
|
|
|
8346
8815
|
throw error;
|
|
8347
8816
|
}
|
|
8348
8817
|
}
|
|
8818
|
+
const workflowsDirectory = path.join(projectRoot, ".github", "workflows");
|
|
8819
|
+
const workflowTargetPath = path.join(workflowsDirectory, "react-doctor.yml");
|
|
8820
|
+
if (!existsSync(workflowTargetPath) && !skipPrompts) {
|
|
8821
|
+
const hasExistingWorkflows = existsSync(workflowsDirectory);
|
|
8822
|
+
const { shouldInstallWorkflow } = await prompts({
|
|
8823
|
+
type: "confirm",
|
|
8824
|
+
name: "shouldInstallWorkflow",
|
|
8825
|
+
message: "Add a GitHub Actions workflow to scan PRs?",
|
|
8826
|
+
initial: hasExistingWorkflows
|
|
8827
|
+
}, promptOptions);
|
|
8828
|
+
if (shouldInstallWorkflow) {
|
|
8829
|
+
if (!hasExistingWorkflows) mkdirSync(workflowsDirectory, { recursive: true });
|
|
8830
|
+
const workflowSpinner = spinner("Adding GitHub Actions workflow...").start();
|
|
8831
|
+
try {
|
|
8832
|
+
writeFileSync(workflowTargetPath, [
|
|
8833
|
+
"name: React Doctor",
|
|
8834
|
+
"",
|
|
8835
|
+
"on:",
|
|
8836
|
+
" pull_request:",
|
|
8837
|
+
" branches: [main]",
|
|
8838
|
+
"",
|
|
8839
|
+
"permissions:",
|
|
8840
|
+
" contents: read",
|
|
8841
|
+
" pull-requests: write",
|
|
8842
|
+
"",
|
|
8843
|
+
"jobs:",
|
|
8844
|
+
" react-doctor:",
|
|
8845
|
+
" runs-on: ubuntu-latest",
|
|
8846
|
+
" steps:",
|
|
8847
|
+
" - uses: actions/checkout@v4",
|
|
8848
|
+
" - uses: millionco/react-doctor@main",
|
|
8849
|
+
" with:",
|
|
8850
|
+
" github-token: ${{ secrets.GITHUB_TOKEN }}",
|
|
8851
|
+
" diff: main",
|
|
8852
|
+
""
|
|
8853
|
+
].join("\n"));
|
|
8854
|
+
workflowSpinner.succeed(`GitHub Actions workflow added at ${path.relative(projectRoot, workflowTargetPath)}.`);
|
|
8855
|
+
} catch (error) {
|
|
8856
|
+
workflowSpinner.fail("Failed to add GitHub Actions workflow.");
|
|
8857
|
+
throw error;
|
|
8858
|
+
}
|
|
8859
|
+
}
|
|
8860
|
+
}
|
|
8349
8861
|
};
|
|
8350
8862
|
//#endregion
|
|
8351
8863
|
//#region src/cli/commands/install.ts
|
|
@@ -8366,13 +8878,10 @@ const installAction = async (options, command) => {
|
|
|
8366
8878
|
//#endregion
|
|
8367
8879
|
//#region src/cli/utils/exit-gracefully.ts
|
|
8368
8880
|
const exitGracefully = () => {
|
|
8369
|
-
|
|
8370
|
-
writeJsonErrorReport(/* @__PURE__ */ new Error("Scan cancelled by user (SIGINT/SIGTERM)"));
|
|
8371
|
-
|
|
8372
|
-
}
|
|
8373
|
-
cliLogger.break();
|
|
8374
|
-
cliLogger.log("Cancelled.");
|
|
8375
|
-
cliLogger.break();
|
|
8881
|
+
try {
|
|
8882
|
+
if (isJsonModeActive()) writeJsonErrorReport(/* @__PURE__ */ new Error("Scan cancelled by user (SIGINT/SIGTERM)"));
|
|
8883
|
+
else console.log("\nCancelled.\n");
|
|
8884
|
+
} catch {}
|
|
8376
8885
|
process.exit(130);
|
|
8377
8886
|
};
|
|
8378
8887
|
//#endregion
|