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