react-doctor 0.9.7 → 0.9.8-dev.7f028ea

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.
@@ -0,0 +1,1443 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="0567ce41-4a3d-5220-9609-7ec2ee490a60")}catch(e){}}();
3
+ import { A as STAGED_SNAPSHOT_ADDITIONAL_CONFIG_FILENAMES, B as isCiOrCodingAgentEnvironment, Hn as gen, I as isDebugFlagEnabled, It as detectAiTrainingEnvironment, Jn as runPromise, Jt as getDiffInfo, Kn as provide$1, Kt as getChangedLineRanges, Lt as discoverReactSubprojects, Mn as toRelativePath, N as CliInputError, Qt as hasReactRuntime, St as StagedFiles, Tn as resolveScanTarget, Tt as buildJsonReport, U as CANONICAL_GITHUB_URL, V as isCodingAgentEnvironment, Vt as findLegacyConfig, Wt as getBaselineDiffPlan, an as isPathInsideDirectory, bn as remainingDeadlineBudgetMs, c as enableJsonMode, d as setJsonReportMode, en as highlighter, f as writeJsonErrorReport, hn as mapWithConcurrency, k as STAGED_FILES_TEMP_DIR_PREFIX, m as VERSION, n as recordDistribution, nn as isErrnoException, nt as Git, on as isPlainObject, p as writeJsonReport, pn as listWorkspacePackages, t as recordCount, tr as provide, u as setJsonReportDirectory, vn as messageFromUnknown, y as METRIC, yt as STAGED_FILES_PROJECT_CONFIG_FILENAMES } from "./record-metric-BGxpNcR0.js";
4
+ import { i as flushSentry } from "./build-section-divider-DKzi1sn2.js";
5
+ import { o as isExpectedUserError } from "./unref-stdin-noiOzC5n.js";
6
+ import { n as handleError, r as handleUserError, t as reportErrorToSentry } from "./report-error-Wat6_MDs.js";
7
+ import { F as runGitRaw, L as cliLogger, c as spinner, i as hasDoctorScript, n as findNearestPackageDirectory, o as isSpinnerSilent, s as setSpinnerSilent } from "./install-doctor-script-J-zvzVqR.js";
8
+ import { a as readReactDoctorWorkflow, c as workflowUsesV1Action, i as isReactDoctorWorkflowInstalled, l as prompts, s as upgradeWorkflowActionToV2 } from "./install-github-workflow-CAo3dNh1.js";
9
+ import { c as openWorkflowPullRequest, i as warnDeprecatedDiff, l as stageWorkflowFile, r as resolveScope, s as resolveBlockingLevel, t as finalizeScope, u as toForwardSlashes } from "./resolve-scope-BbndLz4E.js";
10
+ import { t as shouldSkipPrompts } from "./should-skip-prompts-CZbr6sjQ.js";
11
+ import { A as resolveMergeBaseRef, B as buildEmptyReportMessage, C as copyToClipboard, D as deduplicateProjectScans, E as collectProjectSourceFileCounts, F as printDiagnosticsDump, O as createInvocationInspect, R as filterDiagnosticsByCategories, S as CLI_AGENT_BINARIES, T as buildHandoffPayload, _ as resolveCliInspectOptions, a as projectManifestChanged, b as setUpGitHubActions, c as selectProjects, d as resolveProjectChangedLineRanges, f as resolveProjectDiffIncludePaths, g as resolveWorkspaceDeadCodeOwner, h as retryMissingProjectScores, i as validateModeFlags, j as printHeadlessReport, k as inspect, l as buildCodeFrame, m as resolveProjectRelativeDirectory, n as formatSkippedProjectsMessage, o as partitionProjectScanOutcomes, p as resolveProjectSourceFilePaths, r as validateIncludeUntrackedScope, t as shouldFailScanGate, u as resolveProjectScan, v as filterScansForSurface, w as launchCliAgent, x as detectLaunchableAgents, y as countUniqueScannedFiles } from "./should-fail-scan-gate-CgCFA8pU.js";
12
+ import { a as recordGate, i as readPreference, o as runMigrations, p as SETUP_HINT_EVENT, r as isGatePending, s as writePreference, u as HANDOFF_TARGET_PREFERENCE_ID } from "./report-workflow-result-CULZXneJ.js";
13
+ import { a as askUpgradeActionVersion, c as recordCiPromptDecision, i as installReactDoctorAgentHooks, l as hasHandledActionUpgrade, o as askAddToGitHubActions, r as findAgentsWithLegacyShellHooks, s as hasHandledCiPrompt, t as getSkillSourceDirectory, u as recordActionUpgradeDecision } from "./install-react-doctor-C_5DWtTC.js";
14
+ import { t as readObjectFile } from "./read-object-file-BDLV3R4X.js";
15
+ import * as path$1 from "node:path";
16
+ import * as fs$1 from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { performance } from "node:perf_hooks";
19
+ import { SKILL_MANIFEST_FILE, getSkillAgentConfig, installSkillsFromSource } from "agent-install";
20
+ //#region src/cli/utils/handoff-target-preference.ts
21
+ const HANDOFF_TARGET_PREFERENCE = {
22
+ id: HANDOFF_TARGET_PREFERENCE_ID,
23
+ scope: "global"
24
+ };
25
+ const readHandoffTarget = (options = {}) => readPreference(HANDOFF_TARGET_PREFERENCE, {}, options);
26
+ const rememberHandoffTarget = (target, options = {}) => writePreference(HANDOFF_TARGET_PREFERENCE, target, {}, options);
27
+ //#endregion
28
+ //#region src/cli/utils/install-skill-for-agent.ts
29
+ const installReactDoctorSkillForAgent = async (agent, projectRoot) => {
30
+ const source = getSkillSourceDirectory();
31
+ if (!fs$1.existsSync(path$1.join(source, SKILL_MANIFEST_FILE))) return false;
32
+ const result = await installSkillsFromSource({
33
+ source,
34
+ agents: [agent],
35
+ cwd: projectRoot,
36
+ mode: "copy"
37
+ });
38
+ return result.skills.length > 0 && result.failed.length === 0;
39
+ };
40
+ //#endregion
41
+ //#region src/cli/utils/handoff-to-agent.ts
42
+ const CLIPBOARD_CHOICE = "clipboard";
43
+ const SKIP_CHOICE = "skip";
44
+ const printPayload = (payload) => {
45
+ cliLogger.break();
46
+ cliLogger.log(highlighter.dim("──── Agent prompt ────"));
47
+ cliLogger.log(payload);
48
+ cliLogger.log(highlighter.dim("──────────────────────"));
49
+ };
50
+ const UPGRADE_COMMIT_MESSAGE = "ci: upgrade React Doctor GitHub Action to v2";
51
+ const UPGRADE_PR_TITLE = "Upgrade React Doctor Action to v2";
52
+ const UPGRADE_PR_BODY = `Bumps the React Doctor GitHub Actions workflow to the action's latest major, \`millionco/react-doctor@v2\`.
53
+
54
+ Docs: https://www.react.doctor/ci`;
55
+ const upgradeGitHubActionsWorkflow = async (workflow) => {
56
+ const { content, changed } = upgradeWorkflowActionToV2(workflow.content);
57
+ if (!changed) return false;
58
+ const upgradeSpinner = spinner("Opening a pull request to upgrade React Doctor to v2...").start();
59
+ try {
60
+ fs$1.writeFileSync(workflow.workflowPath, content);
61
+ } catch {
62
+ upgradeSpinner.fail("Couldn't update the workflow file.");
63
+ return false;
64
+ }
65
+ const pullRequestResult = await openWorkflowPullRequest({
66
+ workflowPath: workflow.workflowPath,
67
+ commitMessage: UPGRADE_COMMIT_MESSAGE,
68
+ prTitle: UPGRADE_PR_TITLE,
69
+ prBody: UPGRADE_PR_BODY
70
+ });
71
+ if (pullRequestResult.status === "pr-opened") upgradeSpinner.succeed(`Opened pull request for review: ${highlighter.info(pullRequestResult.url)}`);
72
+ else if (pullRequestResult.status === "pr-exists") {
73
+ try {
74
+ fs$1.writeFileSync(workflow.workflowPath, workflow.content);
75
+ } catch {}
76
+ upgradeSpinner.succeed(`A React Doctor pull request is already open: ${highlighter.info(pullRequestResult.url)}`);
77
+ } else if (pullRequestResult.status === "branch-pushed") upgradeSpinner.warn(`Pushed branch ${highlighter.bold(pullRequestResult.branch)} but couldn't open a PR. Open one with: gh pr create --head ${pullRequestResult.branch}`);
78
+ else {
79
+ upgradeSpinner.stop();
80
+ try {
81
+ fs$1.writeFileSync(workflow.workflowPath, content);
82
+ } catch {
83
+ cliLogger.log(" Couldn't finish the upgrade. Re-run React Doctor to try again.");
84
+ return false;
85
+ }
86
+ const didStage = await stageWorkflowFile({ workflowPath: workflow.workflowPath });
87
+ cliLogger.log(didStage ? " Updated the workflow to @v2 and staged it. Commit it to finish the upgrade." : " Updated the workflow to @v2. Commit the change to finish the upgrade.");
88
+ }
89
+ return true;
90
+ };
91
+ const maybeOfferActionUpgrade = async (projectRoot) => {
92
+ const workflow = readReactDoctorWorkflow(projectRoot);
93
+ if (!workflow || !workflowUsesV1Action(workflow.content)) return;
94
+ if (hasHandledActionUpgrade(projectRoot)) return;
95
+ const outcome = await askUpgradeActionVersion();
96
+ if (outcome === "cancel") return;
97
+ recordCount(METRIC.agentHandoff, 1, { outcome: outcome === "yes" ? "upgrade-accepted" : "upgrade-declined" });
98
+ if (outcome === "no") {
99
+ recordActionUpgradeDecision(projectRoot, "declined");
100
+ return;
101
+ }
102
+ if (await upgradeGitHubActionsWorkflow(workflow)) recordActionUpgradeDecision(projectRoot, "accepted");
103
+ };
104
+ const handoffToAgent = async (input) => {
105
+ if (!input.interactive || input.diagnostics.length === 0) return;
106
+ cliLogger.break();
107
+ const projectRootForCi = findNearestPackageDirectory(input.rootDirectory) ?? input.rootDirectory;
108
+ const isGitHubActionsConfigured = isReactDoctorWorkflowInstalled(projectRootForCi);
109
+ if (!isGitHubActionsConfigured && !hasHandledCiPrompt(projectRootForCi)) {
110
+ const ciOutcome = await askAddToGitHubActions();
111
+ recordCount(METRIC.agentHandoff, 1, {
112
+ outcome: `ci-${ciOutcome}`,
113
+ diagnosticsCount: input.diagnostics.length
114
+ });
115
+ if (ciOutcome === "cancel") return;
116
+ recordCiPromptDecision(projectRootForCi, ciOutcome === "yes" ? "accepted" : "declined");
117
+ if (ciOutcome === "yes") {
118
+ await setUpGitHubActions({ rootDirectory: input.rootDirectory });
119
+ cliLogger.break();
120
+ }
121
+ } else if (isGitHubActionsConfigured) await maybeOfferActionUpgrade(projectRootForCi);
122
+ else recordCount(METRIC.agentHandoff, 1, {
123
+ outcome: "ci-suppressed",
124
+ diagnosticsCount: input.diagnostics.length
125
+ });
126
+ const choices = [
127
+ ...(await detectLaunchableAgents()).map((agentId) => ({
128
+ title: getSkillAgentConfig(agentId).displayName,
129
+ description: `Open ${CLI_AGENT_BINARIES[agentId]} here with the top issues as a prompt`,
130
+ value: agentId
131
+ })),
132
+ {
133
+ title: "Copy prompt to clipboard",
134
+ description: "Paste into any agent or chat",
135
+ value: CLIPBOARD_CHOICE
136
+ },
137
+ {
138
+ title: "Skip",
139
+ description: "Don't hand off",
140
+ value: SKIP_CHOICE
141
+ }
142
+ ];
143
+ const rememberedTarget = readHandoffTarget();
144
+ const rememberedChoiceIndex = choices.findIndex((choice) => choice.value === rememberedTarget);
145
+ const initial = rememberedChoiceIndex >= 0 ? rememberedChoiceIndex : 0;
146
+ const { handoffTarget } = await prompts({
147
+ type: "select",
148
+ name: "handoffTarget",
149
+ message: "What would you like to do next?",
150
+ choices,
151
+ initial
152
+ }, { onCancel: () => true });
153
+ if (handoffTarget !== void 0) rememberHandoffTarget(handoffTarget);
154
+ let handoffOutcome = "launch";
155
+ if (handoffTarget === void 0) handoffOutcome = "cancel";
156
+ else if (handoffTarget === SKIP_CHOICE) handoffOutcome = "skip";
157
+ else if (handoffTarget === CLIPBOARD_CHOICE) handoffOutcome = "clipboard";
158
+ recordCount(METRIC.agentHandoff, 1, {
159
+ outcome: handoffOutcome,
160
+ agent: handoffOutcome === "launch" ? handoffTarget : void 0,
161
+ diagnosticsCount: input.diagnostics.length,
162
+ defaultRemembered: rememberedChoiceIndex >= 0,
163
+ keptDefault: handoffTarget === choices[initial].value
164
+ });
165
+ if (handoffTarget === void 0 || handoffTarget === SKIP_CHOICE) return;
166
+ const payload = buildHandoffPayload({
167
+ diagnostics: input.diagnostics,
168
+ projectName: input.projectName,
169
+ outputDirectory: input.outputDirectory
170
+ });
171
+ if (handoffTarget === CLIPBOARD_CHOICE) {
172
+ if (await copyToClipboard(payload)) cliLogger.log("Copied the prompt to your clipboard.");
173
+ else printPayload(payload);
174
+ return;
175
+ }
176
+ const agentId = handoffTarget;
177
+ const displayName = getSkillAgentConfig(agentId).displayName;
178
+ const skillSpinner = spinner(`Installing the /react-doctor skill for ${displayName}...`).start();
179
+ try {
180
+ if (await installReactDoctorSkillForAgent(agentId, input.rootDirectory)) skillSpinner.succeed(`Installed the /react-doctor skill for ${displayName}.`);
181
+ else skillSpinner.stop();
182
+ } catch {
183
+ skillSpinner.stop();
184
+ }
185
+ cliLogger.log(highlighter.dim(`Handing off to ${displayName}...`));
186
+ try {
187
+ await launchCliAgent(agentId, payload, input.rootDirectory);
188
+ } catch {
189
+ cliLogger.warn(`Couldn't launch ${CLI_AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
190
+ printPayload(payload);
191
+ }
192
+ };
193
+ //#endregion
194
+ //#region src/cli/utils/migrate-action-pin.ts
195
+ const WORKFLOWS_DIRECTORY = path$1.join(".github", "workflows");
196
+ const RECOMMENDED_ACTION_REF = "v2";
197
+ const MUTABLE_ACTION_REF = /(uses:\s*millionco\/react-doctor@)(?:main|master)\b/gi;
198
+ const isWorkflowFile = (fileName) => /\.ya?ml$/.test(fileName);
199
+ /**
200
+ * Rewrites mutable `@main` / `@master` React Doctor GitHub Action references in
201
+ * the repo's `.github/workflows/*.yml` to the recommended floating major
202
+ * (`@v2`) — a supply-chain hardening (#299) that also moves the workflow onto
203
+ * the current (install- and scan-cached) action release. Pinned tags / SHAs are
204
+ * deliberate and left untouched. Returns the absolute paths of the workflow
205
+ * files it rewrote — empty when there's nothing to migrate.
206
+ */
207
+ const migrateActionPin = (projectRoot) => {
208
+ const workflowsDirectory = path$1.join(projectRoot, WORKFLOWS_DIRECTORY);
209
+ let entries;
210
+ try {
211
+ entries = fs$1.readdirSync(workflowsDirectory, { withFileTypes: true });
212
+ } catch {
213
+ return [];
214
+ }
215
+ const rewrittenFiles = [];
216
+ for (const entry of entries) {
217
+ if (!entry.isFile() || !isWorkflowFile(entry.name)) continue;
218
+ const workflowPath = path$1.join(workflowsDirectory, entry.name);
219
+ let contents;
220
+ try {
221
+ contents = fs$1.readFileSync(workflowPath, "utf-8");
222
+ } catch {
223
+ continue;
224
+ }
225
+ const updated = contents.replace(MUTABLE_ACTION_REF, `$1${RECOMMENDED_ACTION_REF}`);
226
+ if (updated === contents) continue;
227
+ try {
228
+ fs$1.writeFileSync(workflowPath, updated);
229
+ rewrittenFiles.push(workflowPath);
230
+ } catch {}
231
+ }
232
+ return rewrittenFiles;
233
+ };
234
+ //#endregion
235
+ //#region src/cli/utils/serialize-ts-object-literal.ts
236
+ const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
237
+ const INDENT_UNIT = " ";
238
+ const serializeKey = (key) => SAFE_IDENTIFIER_PATTERN.test(key) ? key : JSON.stringify(key);
239
+ /**
240
+ * Serializes a JSON-compatible value as an idiomatic TypeScript literal:
241
+ * identifier-shaped object keys stay unquoted, two-space indented, no blank
242
+ * lines. Intended for JSON-sourced config values (string / number / boolean /
243
+ * null / array / plain object); any other type falls back to its JSON form.
244
+ */
245
+ const serializeTsObjectLiteral = (value, depth = 0) => {
246
+ const indent = INDENT_UNIT.repeat(depth);
247
+ const childIndent = INDENT_UNIT.repeat(depth + 1);
248
+ if (Array.isArray(value)) {
249
+ if (value.length === 0) return "[]";
250
+ return `[\n${value.map((item) => `${childIndent}${serializeTsObjectLiteral(item, depth + 1)}`).join(",\n")}\n${indent}]`;
251
+ }
252
+ if (isPlainObject(value)) {
253
+ const keys = Object.keys(value);
254
+ if (keys.length === 0) return "{}";
255
+ return `{\n${keys.map((key) => `${childIndent}${serializeKey(key)}: ${serializeTsObjectLiteral(value[key], depth + 1)}`).join(",\n")}\n${indent}}`;
256
+ }
257
+ return JSON.stringify(value);
258
+ };
259
+ //#endregion
260
+ //#region src/cli/utils/migrate-legacy-config.ts
261
+ const MIGRATED_CONFIG_FILENAME = "doctor.config.ts";
262
+ /**
263
+ * Renames a pre-migration `react-doctor.config.json` to a typed
264
+ * `doctor.config.ts`, preserving the user's settings as the default export.
265
+ * `$schema` is dropped — the `ReactDoctorConfig` type supersedes it for
266
+ * editor autocomplete. Returns the new file's absolute path, or `null` when
267
+ * the legacy file can't be parsed as an object (left untouched so the user
268
+ * can resolve it by hand).
269
+ */
270
+ const migrateLegacyConfig = (legacy) => {
271
+ const parsed = readObjectFile(legacy.legacyFilePath);
272
+ if (!parsed) return null;
273
+ const config = { ...parsed };
274
+ delete config.$schema;
275
+ const targetPath = path$1.join(legacy.directory, MIGRATED_CONFIG_FILENAME);
276
+ const contents = `import type { ReactDoctorConfig } from "react-doctor/api";
277
+
278
+ export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
279
+ `;
280
+ fs$1.writeFileSync(targetPath, contents);
281
+ fs$1.rmSync(legacy.legacyFilePath, { force: true });
282
+ return targetPath;
283
+ };
284
+ //#endregion
285
+ //#region src/cli/utils/cli-migrations.ts
286
+ const PROJECT_MIGRATIONS = [
287
+ {
288
+ id: "config-json-to-ts",
289
+ scope: "project",
290
+ run: ({ projectRoot }) => {
291
+ if (projectRoot === void 0) return false;
292
+ const legacyConfig = findLegacyConfig(projectRoot);
293
+ if (!legacyConfig) return false;
294
+ const migratedPath = migrateLegacyConfig(legacyConfig);
295
+ if (!migratedPath) return false;
296
+ cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
297
+ cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, projectRoot)} and commit it.`);
298
+ cliLogger.break();
299
+ return true;
300
+ }
301
+ },
302
+ {
303
+ id: "action-pin-main-to-v2",
304
+ scope: "project",
305
+ run: ({ projectRoot }) => {
306
+ if (projectRoot === void 0) return false;
307
+ const rewrittenFiles = migrateActionPin(projectRoot);
308
+ if (rewrittenFiles.length === 0) return false;
309
+ const relativeFiles = rewrittenFiles.map((file) => toRelativePath(file, projectRoot)).join(", ");
310
+ cliLogger.success(`Pinned the React Doctor action to @v2 in ${relativeFiles}`);
311
+ cliLogger.dim(" An unpinned @main reference runs whatever the action's HEAD points to (a supply-chain risk). Review and commit the change — or revert it if you intentionally track main.");
312
+ cliLogger.break();
313
+ return true;
314
+ }
315
+ },
316
+ {
317
+ id: "agent-hooks-sh-to-mjs",
318
+ scope: "project",
319
+ run: ({ projectRoot }) => {
320
+ if (projectRoot === void 0) return false;
321
+ const agents = findAgentsWithLegacyShellHooks(projectRoot);
322
+ if (agents.length === 0) return false;
323
+ installReactDoctorAgentHooks({
324
+ projectRoot,
325
+ agents
326
+ });
327
+ cliLogger.success(`Upgraded the legacy react-doctor.sh agent hook to the Node hook (${agents.join(", ")})`);
328
+ cliLogger.dim(" The shell hook can't run on Windows and would double-scan next to the current hook. Review and commit the change.");
329
+ cliLogger.break();
330
+ return true;
331
+ }
332
+ }
333
+ ];
334
+ const runProjectMigrations = (projectRoot, options = {}) => runMigrations(PROJECT_MIGRATIONS, { projectRoot }, options);
335
+ //#endregion
336
+ //#region src/cli/utils/read-changed-files-from.ts
337
+ const isSafeRelativePath = (filePath) => {
338
+ if (filePath.length === 0) return false;
339
+ if (filePath.includes("\0")) return false;
340
+ if (path$1.isAbsolute(filePath)) return false;
341
+ const normalized = path$1.posix.normalize(filePath);
342
+ if (normalized === "." || normalized.startsWith("../") || normalized === "..") return false;
343
+ return normalized === filePath;
344
+ };
345
+ const readChangedFilesFrom = (filePath) => {
346
+ let raw;
347
+ try {
348
+ raw = fs$1.readFileSync(filePath, "utf8");
349
+ } catch (error) {
350
+ const errorCode = isErrnoException(error) ? error.code : void 0;
351
+ throw new CliInputError(`Could not read the --changed-files-from file "${filePath}"${errorCode ? ` (${errorCode})` : ""}. Pass a path to a readable text file that lists changed files, one per line.`);
352
+ }
353
+ const uniqueFiles = /* @__PURE__ */ new Set();
354
+ for (const line of raw.split(/\r?\n/)) {
355
+ const candidate = toForwardSlashes(line.trim());
356
+ if (!isSafeRelativePath(candidate)) continue;
357
+ uniqueFiles.add(candidate);
358
+ }
359
+ return [...uniqueFiles];
360
+ };
361
+ //#endregion
362
+ //#region src/cli/utils/print-completed-scans-headless.ts
363
+ const printCompletedScansHeadless = (input) => {
364
+ const surfaceDiagnostics = filterScansForSurface(input.completedScans, input.outputSurface);
365
+ const diagnostics = filterDiagnosticsByCategories(surfaceDiagnostics, input.categoryFilters);
366
+ const allDiagnosticCount = input.completedScans.reduce((total, scan) => total + scan.result.diagnostics.length, 0);
367
+ const scoreResult = input.completedScans.reduce((lowestScore, scan) => {
368
+ const scanScore = scan.result.score;
369
+ if (scanScore === null) return lowestScore;
370
+ if (lowestScore === null || scanScore.score < lowestScore.score) return scanScore;
371
+ return lowestScore;
372
+ }, null);
373
+ const skippedChecks = [...new Set(input.completedScans.flatMap((scan) => scan.result.skippedChecks))];
374
+ return gen(function* () {
375
+ yield* printHeadlessReport({
376
+ diagnostics,
377
+ elapsedMilliseconds: input.elapsedMilliseconds,
378
+ emptyStateMessage: buildEmptyReportMessage({
379
+ categoryFilters: input.categoryFilters,
380
+ demotedDiagnosticCount: allDiagnosticCount - surfaceDiagnostics.length,
381
+ outputSurface: input.outputSurface
382
+ }),
383
+ noScoreMessage: input.noScoreMessage,
384
+ projectName: input.projectName,
385
+ scannedFileCount: countUniqueScannedFiles(input.completedScans.map((scan) => scan.result)),
386
+ scoreResult: skippedChecks.length > 0 ? null : scoreResult,
387
+ skippedChecks
388
+ });
389
+ if (input.outputDirectory !== void 0 || input.verbose) yield* printDiagnosticsDump(diagnostics, input.outputDirectory, input.verbose);
390
+ });
391
+ };
392
+ //#endregion
393
+ //#region src/cli/utils/prompt-install-setup.ts
394
+ const SETUP_HINT_GATE = {
395
+ id: SETUP_HINT_EVENT,
396
+ scope: "project",
397
+ fireWhenUnknown: true
398
+ };
399
+ const hasDisabledSetupPrompt = (projectRoot, options = {}) => !isGatePending(SETUP_HINT_GATE, { projectRoot }, options);
400
+ const disableSetupPrompt = (projectRoot, options = {}) => recordGate(SETUP_HINT_GATE, {
401
+ projectRoot,
402
+ outcome: "declined"
403
+ }, options);
404
+ const resolveInstallSetupProjectRoot = (options) => {
405
+ if (options.scanDirectories.length === 0) return findNearestPackageDirectory(options.scanRoot) ?? options.scanRoot;
406
+ const packageDirectories = /* @__PURE__ */ new Set();
407
+ for (const scanDirectory of options.scanDirectories) {
408
+ const packageDirectory = findNearestPackageDirectory(scanDirectory, options.scanRoot) ?? findNearestPackageDirectory(scanDirectory) ?? scanDirectory;
409
+ packageDirectories.add(packageDirectory);
410
+ }
411
+ if (packageDirectories.size !== 1) return findNearestPackageDirectory(options.scanRoot, options.scanRoot);
412
+ return [...packageDirectories][0] ?? null;
413
+ };
414
+ const defaultWriteLine = (line = "") => {
415
+ console.log(line);
416
+ };
417
+ const shouldShowAgentInstallHint = (options) => {
418
+ if (!(options.hasCompletedScan ?? options.hasScoredScan ?? false)) return false;
419
+ if (options.isJsonMode) return false;
420
+ if (options.isScoreOnly) return false;
421
+ if (options.isStaged) return false;
422
+ if (!(options.isCodingAgent ?? isCodingAgentEnvironment())) return false;
423
+ if (hasDisabledSetupPrompt(options.projectRoot, options.store)) return false;
424
+ return !hasDoctorScript(options.projectRoot);
425
+ };
426
+ const AGENT_INSTALL_HINT_LINES = [
427
+ "React Doctor is not installed in this project.",
428
+ "Ask the user if they would like to set it up, then run:",
429
+ "",
430
+ " npx react-doctor install --yes",
431
+ "",
432
+ "This adds agent skill files, a `doctor` package script, and react-doctor as a dev dependency."
433
+ ];
434
+ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
435
+ writeLine("");
436
+ for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
437
+ };
438
+ //#endregion
439
+ //#region src/cli/utils/build-diagnostic-issue-url.ts
440
+ const formatRuleIdentifier = (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`;
441
+ const buildDiagnosticIssueBody = (input) => {
442
+ const { diagnostic, relativeFilePath } = input;
443
+ const lines = [
444
+ "## Diagnostic",
445
+ "",
446
+ `- Rule: ${formatRuleIdentifier(diagnostic)}`,
447
+ `- Severity: ${diagnostic.severity}`,
448
+ `- Category: ${diagnostic.category}`,
449
+ `- Location: ${relativeFilePath}:${diagnostic.line}`,
450
+ "",
451
+ "## Message",
452
+ "",
453
+ "```text",
454
+ diagnostic.message,
455
+ "```"
456
+ ];
457
+ if (diagnostic.help) lines.push("", "## Suggested Fix", "", "```text", diagnostic.help, "```");
458
+ lines.push("", "## Why this looks wrong or needs follow-up", "", "Please explain why this should be changed, suppressed, or treated as a false positive.");
459
+ return lines.join("\n");
460
+ };
461
+ const buildDiagnosticIssueUrl = (input) => {
462
+ const { diagnostic, relativeFilePath } = input;
463
+ const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
464
+ issueUrl.searchParams.set("title", `Diagnostic follow-up: ${formatRuleIdentifier(diagnostic)}`);
465
+ issueUrl.searchParams.set("labels", "bug");
466
+ issueUrl.searchParams.set("body", buildDiagnosticIssueBody({
467
+ diagnostic,
468
+ relativeFilePath
469
+ }));
470
+ return issueUrl.toString();
471
+ };
472
+ //#endregion
473
+ //#region src/cli/utils/find-owning-project.ts
474
+ const findOwningProjectDirectory = (rootDirectory, filePath) => {
475
+ const absoluteFile = path$1.isAbsolute(filePath) ? filePath : path$1.resolve(rootDirectory, filePath);
476
+ const workspacePackages = listWorkspacePackages(rootDirectory);
477
+ const candidates = workspacePackages.length > 0 ? workspacePackages : discoverReactSubprojects(rootDirectory);
478
+ if (candidates.length === 0) return rootDirectory;
479
+ let bestMatch = null;
480
+ for (const candidate of candidates) {
481
+ const candidateDirectory = path$1.resolve(candidate.directory);
482
+ const relativeFromCandidate = path$1.relative(candidateDirectory, absoluteFile);
483
+ if (relativeFromCandidate.startsWith("..") || path$1.isAbsolute(relativeFromCandidate)) continue;
484
+ const depth = candidateDirectory.length;
485
+ if (!bestMatch || depth > bestMatch.depth) bestMatch = {
486
+ directory: candidate.directory,
487
+ depth
488
+ };
489
+ }
490
+ return bestMatch ? bestMatch.directory : rootDirectory;
491
+ };
492
+ //#endregion
493
+ //#region src/cli/utils/indent-multiline-text.ts
494
+ const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
495
+ //#endregion
496
+ //#region src/cli/utils/parse-file-line-argument.ts
497
+ const parseFileLineArgument = (rawArgument) => {
498
+ const lastColonIndex = rawArgument.lastIndexOf(":");
499
+ if (lastColonIndex < 0) throw new CliInputError(`Expected "<file>:<line>" (e.g. "src/foo.tsx:42"), got "${rawArgument}".`);
500
+ const filePath = rawArgument.slice(0, lastColonIndex);
501
+ const lineText = rawArgument.slice(lastColonIndex + 1);
502
+ if (filePath.length === 0) throw new CliInputError(`Missing file path in "${rawArgument}".`);
503
+ const line = Number.parseInt(lineText, 10);
504
+ if (!Number.isFinite(line) || line <= 0 || String(line) !== lineText.trim()) throw new CliInputError(`Expected a positive line number in "${rawArgument}".`);
505
+ return {
506
+ filePath,
507
+ line
508
+ };
509
+ };
510
+ //#endregion
511
+ //#region src/cli/utils/run-explain.ts
512
+ const colorizeRuleByDiagnostic = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
513
+ const resolveExplainTargetDirectory = async (filePath, context) => {
514
+ if (context.projectFlag) {
515
+ const matchedDirectories = await selectProjects(context.resolvedDirectory, context.projectFlag, true);
516
+ if (matchedDirectories.length === 0) return context.resolvedDirectory;
517
+ if (matchedDirectories.length > 1) throw new Error(`why takes a single project; --project resolved to ${matchedDirectories.length} projects.`);
518
+ return matchedDirectories[0];
519
+ }
520
+ return findOwningProjectDirectory(context.resolvedDirectory, filePath);
521
+ };
522
+ const runExplain = async (fileLineArgument, context) => {
523
+ const { filePath, line } = parseFileLineArgument(fileLineArgument);
524
+ const targetDirectory = await resolveExplainTargetDirectory(filePath, context);
525
+ const scanResult = await inspect(targetDirectory, {
526
+ ...context.scanOptions,
527
+ silent: true,
528
+ noScore: true,
529
+ configOverride: context.userConfig
530
+ });
531
+ const requestedRelativePath = toRelativePath(filePath, targetDirectory);
532
+ const matchingDiagnostics = scanResult.diagnostics.filter((diagnostic) => diagnostic.line === line && toRelativePath(diagnostic.filePath, targetDirectory) === requestedRelativePath);
533
+ if (matchingDiagnostics.length === 0) {
534
+ cliLogger.log(`No react-doctor diagnostics at ${filePath}:${line}.`);
535
+ return;
536
+ }
537
+ for (const diagnostic of matchingDiagnostics) {
538
+ const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
539
+ const severitySymbol = diagnostic.severity === "error" ? "✖" : "⚠";
540
+ const colorizedRule = colorizeRuleByDiagnostic(ruleIdentifier, diagnostic.severity);
541
+ const severityLabel = colorizeRuleByDiagnostic(diagnostic.severity, diagnostic.severity);
542
+ cliLogger.log(`${severitySymbol} ${colorizedRule} ${highlighter.dim(`(${severityLabel})`)} — ${diagnostic.message}`);
543
+ const codeFrame = buildCodeFrame({
544
+ filePath: diagnostic.filePath,
545
+ line: diagnostic.line,
546
+ column: diagnostic.column,
547
+ endLine: diagnostic.endLine,
548
+ rootDirectory: targetDirectory
549
+ });
550
+ if (codeFrame) cliLogger.log(indentMultilineText(codeFrame, " "));
551
+ if (diagnostic.category) cliLogger.dim(` Category: ${diagnostic.category}`);
552
+ if (diagnostic.help) cliLogger.dim(` ${diagnostic.help}`);
553
+ cliLogger.dim(` If this needs follow-up or looks like a false positive, open: ${buildDiagnosticIssueUrl({
554
+ diagnostic,
555
+ relativeFilePath: requestedRelativePath
556
+ })}`);
557
+ if (diagnostic.suppressionHint) {
558
+ cliLogger.break();
559
+ cliLogger.log(` Suppression diagnosis: ${diagnostic.suppressionHint}`);
560
+ } else cliLogger.dim(" No nearby react-doctor-disable-next-line comment was detected — add one immediately above this line to suppress.");
561
+ cliLogger.break();
562
+ }
563
+ };
564
+ //#endregion
565
+ //#region src/cli/utils/run-project-scan-batch.ts
566
+ /**
567
+ * Run one scan per project through the same bounded pool as
568
+ * `diagnose({ projects })`, with the batch spinner and its progress counter.
569
+ *
570
+ * Pool members must not toggle the module-level spinner-silent flag themselves —
571
+ * overlapping save/restore pairs would race — so the batch owns that toggle once
572
+ * around the whole run.
573
+ *
574
+ */
575
+ const runProjectScanBatch = async (input) => {
576
+ const startTime = performance.now();
577
+ const projectCount = input.projects.length;
578
+ const isMultiProject = projectCount > 1;
579
+ const batchSpinner = isMultiProject && !input.isQuiet ? spinner(`Scanning ${projectCount} projects…`).start() : null;
580
+ const ownsBatchSpinnerSilence = isMultiProject && input.isSilent;
581
+ const wasSpinnerSilent = isSpinnerSilent();
582
+ if (ownsBatchSpinnerSilence) setSpinnerSilent(true);
583
+ let finishedProjectCount = 0;
584
+ let scanOutcomes;
585
+ try {
586
+ scanOutcomes = await mapWithConcurrency(input.projects, isMultiProject ? 4 : 1, async (project) => {
587
+ const scanOutcome = await input.scanProject(project);
588
+ finishedProjectCount += 1;
589
+ batchSpinner?.update(`Scanning ${projectCount} projects… (${finishedProjectCount}/${projectCount})`);
590
+ return scanOutcome;
591
+ });
592
+ } finally {
593
+ if (ownsBatchSpinnerSilence) setSpinnerSilent(wasSpinnerSilent);
594
+ batchSpinner?.stop();
595
+ }
596
+ return {
597
+ ...partitionProjectScanOutcomes(scanOutcomes),
598
+ elapsedMilliseconds: performance.now() - startTime
599
+ };
600
+ };
601
+ //#endregion
602
+ //#region src/cli/utils/build-project-scan-plan.ts
603
+ const buildProjectScanPlan = (input) => {
604
+ const projectBaselineBaseFiles = input.baselineDiffPlan === null ? null : resolveProjectSourceFilePaths(input.rootDirectory, input.projectDirectory, input.baselineDiffPlan.baseFiles);
605
+ const projectBaselineHeadFiles = input.baselineDiffPlan === null ? null : resolveProjectSourceFilePaths(input.rootDirectory, input.projectDirectory, input.baselineDiffPlan.headFiles);
606
+ if (!input.isDiffMode) return {
607
+ includePaths: void 0,
608
+ projectBaselineBaseFiles,
609
+ projectBaselineHeadFiles,
610
+ shouldSkipProject: false,
611
+ supplyChainManifestChanged: false
612
+ };
613
+ const changedSourceFiles = input.diffInfo === null ? [] : resolveProjectDiffIncludePaths(input.rootDirectory, input.projectDirectory, input.diffInfo);
614
+ const supplyChainManifestChanged = input.supplyChainEnabled && input.diffInfo !== null && projectManifestChanged(input.rootDirectory, input.projectDirectory, input.diffInfo);
615
+ const hasProjectBaselineBaseFiles = (projectBaselineBaseFiles?.length ?? 0) > 0;
616
+ const shouldSkipProject = changedSourceFiles.length === 0 && !supplyChainManifestChanged && !hasProjectBaselineBaseFiles;
617
+ const includePaths = [...changedSourceFiles];
618
+ if (includePaths.length === 0 && hasProjectBaselineBaseFiles) includePaths.push(...projectBaselineBaseFiles ?? []);
619
+ if (supplyChainManifestChanged) includePaths.push("package.json");
620
+ return {
621
+ includePaths,
622
+ projectBaselineBaseFiles,
623
+ projectBaselineHeadFiles,
624
+ shouldSkipProject,
625
+ supplyChainManifestChanged
626
+ };
627
+ };
628
+ //#endregion
629
+ //#region src/cli/utils/warn-deprecated-fail-on.ts
630
+ const warnDeprecatedFailOn = (flags, userConfig) => {
631
+ const usedFlag = flags.failOn !== void 0;
632
+ const usedConfig = userConfig?.failOn !== void 0;
633
+ if (!usedFlag && !usedConfig) return;
634
+ const source = usedFlag ? "The `--fail-on` flag" : "The `failOn` config option";
635
+ const replacement = usedFlag ? "`--blocking <level>`" : "`blocking`";
636
+ cliLogger.warn(`${source} is deprecated; rename it to ${replacement}.`);
637
+ };
638
+ //#endregion
639
+ //#region src/cli/utils/warn-ai-training-environment.ts
640
+ let didWarnAiTraining = false;
641
+ const warnIfAiTrainingEnvironment = () => {
642
+ if (didWarnAiTraining) return;
643
+ const detected = detectAiTrainingEnvironment();
644
+ if (detected === null) return;
645
+ didWarnAiTraining = true;
646
+ process.stderr.write(`${highlighter.warn("react-doctor detected use in an AI or ML pipeline. This use requires written permission under the react-doctor license — contact founders@million.dev to request access.")}\n`);
647
+ recordCount(METRIC.aiTrainingWarningShown, 1, { environment: detected });
648
+ };
649
+ //#endregion
650
+ //#region src/cli/utils/parse-staged-snapshot-divergences.ts
651
+ const SNAPSHOT_CONFIG_FILENAMES = new Set([...STAGED_FILES_PROJECT_CONFIG_FILENAMES, ...STAGED_SNAPSHOT_ADDITIONAL_CONFIG_FILENAMES]);
652
+ const parseStagedSnapshotDivergences = (statusOutput) => {
653
+ const divergentConfigFiles = /* @__PURE__ */ new Set();
654
+ const statusEntries = statusOutput.split("\0").filter((entry) => entry.length > 0);
655
+ for (let entryIndex = 0; entryIndex < statusEntries.length; entryIndex += 1) {
656
+ const entry = statusEntries[entryIndex];
657
+ const [indexStatus, worktreeStatus] = entry;
658
+ const filePath = entry.slice(3);
659
+ const hasRenameOrCopySource = indexStatus === "R" || indexStatus === "C" || worktreeStatus === "R" || worktreeStatus === "C";
660
+ const sourcePath = hasRenameOrCopySource ? statusEntries[entryIndex + 1] : void 0;
661
+ const worktreePaths = worktreeStatus === "R" || worktreeStatus === "C" ? [filePath, sourcePath] : [filePath];
662
+ if (worktreeStatus !== " ") {
663
+ for (const worktreePath of worktreePaths) if (worktreePath && SNAPSHOT_CONFIG_FILENAMES.has(path$1.basename(worktreePath))) divergentConfigFiles.add(worktreePath);
664
+ }
665
+ if (hasRenameOrCopySource) entryIndex += 1;
666
+ }
667
+ return [...divergentConfigFiles].sort();
668
+ };
669
+ //#endregion
670
+ //#region src/cli/utils/find-staged-snapshot-divergences.ts
671
+ const findStagedSnapshotDivergences = (directory) => {
672
+ const statusOutput = runGitRaw(directory, [
673
+ "status",
674
+ "--porcelain=v1",
675
+ "-z",
676
+ "--untracked-files=all",
677
+ "--ignored=matching"
678
+ ]);
679
+ if (statusOutput === null) return null;
680
+ return parseStagedSnapshotDivergences(statusOutput);
681
+ };
682
+ //#endregion
683
+ //#region src/cli/utils/build-final-cli-scan-outcome.ts
684
+ const filterCompletedScansByCategories = (completedScans, categoryFilters) => categoryFilters.size === 0 ? [...completedScans] : completedScans.map((scan) => ({
685
+ ...scan,
686
+ result: {
687
+ ...scan.result,
688
+ diagnostics: filterDiagnosticsByCategories(scan.result.diagnostics, categoryFilters)
689
+ }
690
+ }));
691
+ const buildFinalCliScanOutcome = (input) => {
692
+ const baselineDeltas = input.completedScans.flatMap((scan) => scan.result.baselineDelta === void 0 ? [] : [scan.result.baselineDelta]);
693
+ const baselineComputed = input.skippedProjects.length === 0 && input.completedScans.length > 0 && input.completedScans.every((scan) => scan.result.baselineDelta !== void 0);
694
+ const baselineDegraded = input.baselineIntended && !baselineComputed;
695
+ return {
696
+ baseline: baselineComputed && baselineDeltas.length > 0 ? {
697
+ baseRef: baselineDeltas[0].baseRef,
698
+ fixedCount: baselineDeltas.reduce((total, delta) => total + delta.fixedCount, 0),
699
+ baseTotalCount: baselineDeltas.reduce((total, delta) => total + delta.baseTotalCount, 0)
700
+ } : void 0,
701
+ baselineDegraded,
702
+ mode: baselineDegraded ? "diff" : input.mode,
703
+ scansForJsonReport: filterCompletedScansByCategories(input.completedScans, input.categoryFilters),
704
+ shouldWarnNoReactDetected: input.completedScans.length > 0 && !input.completedScans.some((scan) => hasReactRuntime(scan.result.project))
705
+ };
706
+ };
707
+ //#endregion
708
+ //#region src/cli/utils/finalize-cli-scans.ts
709
+ const reportSkippedProjects = (input) => {
710
+ input.skippedProjects.sort((left, right) => left.directory.localeCompare(right.directory));
711
+ if (input.skippedProjects.length === 0) return;
712
+ recordCount(METRIC.scanProjectSkipped, input.skippedProjects.length, { reason: "max-duration" });
713
+ if (!input.isQuiet) {
714
+ cliLogger.warn(formatSkippedProjectsMessage(input.skippedProjects.length));
715
+ cliLogger.break();
716
+ }
717
+ };
718
+ const finalizeCliScans = (input) => {
719
+ const outcome = buildFinalCliScanOutcome({
720
+ completedScans: input.completedScans,
721
+ skippedProjects: input.skippedProjects,
722
+ mode: input.mode,
723
+ baselineIntended: input.baselineIntended,
724
+ categoryFilters: input.categoryFilters
725
+ });
726
+ if (outcome.shouldWarnNoReactDetected) {
727
+ recordCount(METRIC.scanNoReactDetected, 1);
728
+ cliLogger.warn(`No React project detected at ${input.resolvedDirectory} — React rules were gated off; this is not the same as a clean scan.`);
729
+ }
730
+ if (input.isJsonMode) writeJsonReport(buildJsonReport({
731
+ version: VERSION,
732
+ directory: input.resolvedDirectory,
733
+ mode: outcome.mode,
734
+ diff: input.diff,
735
+ scans: outcome.scansForJsonReport,
736
+ skippedProjects: input.skippedProjects,
737
+ totalElapsedMilliseconds: performance.now() - input.startTime,
738
+ baseline: outcome.baseline,
739
+ baselineDegraded: outcome.baselineDegraded
740
+ }));
741
+ if (shouldFailScanGate({
742
+ scans: input.completedScans,
743
+ blockingLevel: resolveBlockingLevel(input.flags, input.userConfig),
744
+ diagnosticsAreGateExempt: input.isScoreOnly || outcome.baselineDegraded
745
+ })) process.exitCode = 1;
746
+ };
747
+ //#endregion
748
+ //#region src/cli/utils/get-staged-files.ts
749
+ const stagedFilesLayer = StagedFiles.layerNode.pipe(provide(Git.layerNode));
750
+ const getStagedSourceFiles = async (directory) => {
751
+ let stagedSourceFiles;
752
+ try {
753
+ stagedSourceFiles = await runPromise(gen(function* () {
754
+ return yield* (yield* StagedFiles).discoverSourceFiles(directory);
755
+ }).pipe(provide$1(stagedFilesLayer)));
756
+ } catch (error) {
757
+ cliLogger.warn(`Failed to discover staged files: ${messageFromUnknown(error)}`);
758
+ return [];
759
+ }
760
+ if (stagedSourceFiles === null) throw new CliInputError("Could not read the git index to list staged files. Check that git can read the index in this directory.");
761
+ return [...stagedSourceFiles];
762
+ };
763
+ const materializeStagedFiles = async (input) => {
764
+ const snapshot = await runPromise(gen(function* () {
765
+ return yield* (yield* StagedFiles).materialize(input);
766
+ }).pipe(provide$1(stagedFilesLayer)));
767
+ return {
768
+ tempDirectory: snapshot.tempDirectory,
769
+ stagedFiles: [...snapshot.stagedFiles],
770
+ unmaterializedFiles: [...snapshot.unmaterializedFiles],
771
+ cleanup: snapshot.cleanup
772
+ };
773
+ };
774
+ //#endregion
775
+ //#region src/cli/utils/select-staged-projects.ts
776
+ /**
777
+ * Appended to the reason a `--staged` run could not scan per package. Shared so
778
+ * both fallback sites word it identically.
779
+ */
780
+ const STAGED_PROJECT_FALLBACK_HINT = "Scanning the staged files at the scan root instead — fix the `projects` entries in your config to scan per package.";
781
+ /**
782
+ * Resolve the project directories a `--staged` scan owns. The interactive picker
783
+ * is always skipped: a commit hook has nobody to answer it.
784
+ */
785
+ const selectStagedProjects = async (input) => {
786
+ if (input.projectFlag) return selectProjects(input.rootDirectory, input.projectFlag, true, void 0);
787
+ if (input.configProjects === void 0) return [input.rootDirectory];
788
+ try {
789
+ return await selectProjects(input.rootDirectory, void 0, true, input.configProjects);
790
+ } catch (error) {
791
+ if (!(error instanceof CliInputError)) throw error;
792
+ cliLogger.warn(`${error.message} ${STAGED_PROJECT_FALLBACK_HINT}`);
793
+ cliLogger.break();
794
+ return [input.rootDirectory];
795
+ }
796
+ };
797
+ //#endregion
798
+ //#region src/cli/utils/run-staged-inspect.ts
799
+ const reportEmptyStagedScan = (input, reason, severity = "dim") => {
800
+ if (input.isJsonMode) {
801
+ writeJsonReport(buildJsonReport({
802
+ version: VERSION,
803
+ directory: input.directory,
804
+ mode: "staged",
805
+ diff: null,
806
+ scans: [],
807
+ totalElapsedMilliseconds: performance.now() - input.startTime
808
+ }));
809
+ return;
810
+ }
811
+ if (input.isScoreOnly) return;
812
+ if (severity === "warn") {
813
+ cliLogger.warn(reason);
814
+ cliLogger.break();
815
+ return;
816
+ }
817
+ cliLogger.dim(reason);
818
+ };
819
+ const resolveStagedProjectScanContext = async ({ scanTarget, rootDirectory, projectDirectory, isProjectExplicitlySelected }) => {
820
+ const projectScan = await resolveProjectScan(scanTarget, projectDirectory);
821
+ const treeRelativeDirectory = resolveProjectRelativeDirectory(rootDirectory, projectScan.directory);
822
+ if (treeRelativeDirectory === null) {
823
+ if (isProjectExplicitlySelected) throw new CliInputError(`Project "${toRelativePath(projectDirectory, rootDirectory)}" is outside ${rootDirectory}, so it holds none of the staged files. Run --staged from a directory that contains the project.`);
824
+ return null;
825
+ }
826
+ return {
827
+ projectDirectory,
828
+ scanDirectory: projectScan.directory,
829
+ treeRelativeDirectory,
830
+ projectConfig: projectScan.config,
831
+ projectConfigSourceDirectory: projectScan.configSourceDirectory
832
+ };
833
+ };
834
+ const collectStagedProjectScanContexts = async ({ scanTarget, rootDirectory, projectDirectories, isProjectExplicitlySelected }) => {
835
+ const projectScanContexts = [];
836
+ const seenScanDirectories = /* @__PURE__ */ new Set();
837
+ for (const projectDirectory of projectDirectories) {
838
+ const projectScanContext = await resolveStagedProjectScanContext({
839
+ scanTarget,
840
+ rootDirectory,
841
+ projectDirectory,
842
+ isProjectExplicitlySelected
843
+ });
844
+ if (projectScanContext === null) continue;
845
+ if (seenScanDirectories.has(projectScanContext.scanDirectory)) continue;
846
+ seenScanDirectories.add(projectScanContext.scanDirectory);
847
+ projectScanContexts.push(projectScanContext);
848
+ }
849
+ return projectScanContexts;
850
+ };
851
+ const resolveStagedProjectScanContexts = async (input) => {
852
+ const { flags, scanTarget } = input;
853
+ const rootDirectory = scanTarget.resolvedDirectory;
854
+ const userConfig = scanTarget.userConfig;
855
+ const configProjectsApply = (userConfig?.projects ?? []).some((projectName) => projectName.trim().length > 0) && scanTarget.requestedDirectory === scanTarget.configSourceDirectory;
856
+ const projectScanContexts = await collectStagedProjectScanContexts({
857
+ scanTarget,
858
+ rootDirectory,
859
+ projectDirectories: await selectStagedProjects({
860
+ rootDirectory,
861
+ projectFlag: flags.project,
862
+ configProjects: configProjectsApply ? userConfig?.projects : void 0
863
+ }),
864
+ isProjectExplicitlySelected: Boolean(flags.project)
865
+ });
866
+ if (projectScanContexts.length > 0) return projectScanContexts;
867
+ cliLogger.warn(`No configured project is inside ${rootDirectory}. ${STAGED_PROJECT_FALLBACK_HINT}`);
868
+ cliLogger.break();
869
+ const rootProjectScanContext = await resolveStagedProjectScanContext({
870
+ scanTarget,
871
+ rootDirectory,
872
+ projectDirectory: rootDirectory,
873
+ isProjectExplicitlySelected: false
874
+ });
875
+ return rootProjectScanContext === null ? [] : [rootProjectScanContext];
876
+ };
877
+ const assignStagedFilesToProjects = (projectScanContexts, stagedFiles) => {
878
+ const contextsByLongestPathFirst = [...projectScanContexts].sort((left, right) => right.treeRelativeDirectory.length - left.treeRelativeDirectory.length);
879
+ const ownedStagedFiles = /* @__PURE__ */ new Map();
880
+ for (const stagedFile of stagedFiles) {
881
+ const owner = contextsByLongestPathFirst.find((context) => context.treeRelativeDirectory.length === 0 || stagedFile.startsWith(`${context.treeRelativeDirectory}/`));
882
+ if (owner === void 0) continue;
883
+ const ownedFiles = ownedStagedFiles.get(owner.scanDirectory);
884
+ if (ownedFiles === void 0) ownedStagedFiles.set(owner.scanDirectory, [stagedFile]);
885
+ else ownedFiles.push(stagedFile);
886
+ }
887
+ return projectScanContexts.flatMap((projectScanContext) => {
888
+ const projectStagedFiles = ownedStagedFiles.get(projectScanContext.scanDirectory);
889
+ return projectStagedFiles === void 0 ? [] : [{
890
+ ...projectScanContext,
891
+ stagedFiles: projectStagedFiles
892
+ }];
893
+ });
894
+ };
895
+ const collectConfigSubdirectories = (rootDirectory, projectScans) => {
896
+ const configSubdirectories = /* @__PURE__ */ new Set();
897
+ for (const projectScan of projectScans) {
898
+ configSubdirectories.add(projectScan.treeRelativeDirectory);
899
+ const projectRelativeDirectory = resolveProjectRelativeDirectory(rootDirectory, projectScan.projectDirectory);
900
+ if (projectRelativeDirectory !== null) configSubdirectories.add(projectRelativeDirectory);
901
+ }
902
+ return [...configSubdirectories];
903
+ };
904
+ const buildStagedProjectRuns = (rootDirectory, projectScans, materializedStagedFiles) => projectScans.map((projectScan) => ({
905
+ projectScan,
906
+ includePaths: resolveProjectSourceFilePaths(rootDirectory, projectScan.scanDirectory, projectScan.stagedFiles.filter((stagedFile) => materializedStagedFiles.has(stagedFile)))
907
+ })).filter((projectRun) => projectRun.includePaths.length > 0);
908
+ const scanStagedProject = async ({ input, projectRun, snapshotDirectory, stagedLineRanges, isMultiProject }) => {
909
+ const { projectScan, includePaths } = projectRun;
910
+ if (input.scanDeadlineEpochMs !== void 0 && remainingDeadlineBudgetMs(input.scanDeadlineEpochMs) === 0) return {
911
+ status: "skipped",
912
+ value: {
913
+ directory: projectScan.scanDirectory,
914
+ reason: "max-duration"
915
+ }
916
+ };
917
+ const rootDirectory = input.scanTarget.resolvedDirectory;
918
+ const projectTempDirectory = path$1.join(snapshotDirectory, projectScan.treeRelativeDirectory);
919
+ const scanResult = await input.inspectProject(projectTempDirectory, {
920
+ ...input.scanOptions,
921
+ deadlineEpochMs: input.scanDeadlineEpochMs,
922
+ includePaths: [...includePaths],
923
+ configOverride: projectScan.projectConfig,
924
+ configSourceDirectory: projectScan.projectConfigSourceDirectory ?? void 0,
925
+ changedLineRanges: stagedLineRanges === null ? void 0 : resolveProjectChangedLineRanges(rootDirectory, projectScan.scanDirectory, stagedLineRanges),
926
+ suppressRendering: isMultiProject,
927
+ concurrentScan: isMultiProject
928
+ });
929
+ const diagnostics = scanResult.diagnostics.map((diagnostic) => ({
930
+ ...diagnostic,
931
+ filePath: path$1.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replaceAll(projectTempDirectory, () => projectScan.scanDirectory) : diagnostic.filePath
932
+ }));
933
+ return {
934
+ status: "completed",
935
+ value: {
936
+ directory: projectScan.scanDirectory,
937
+ result: {
938
+ ...scanResult,
939
+ diagnostics,
940
+ project: {
941
+ ...scanResult.project,
942
+ rootDirectory: projectScan.scanDirectory
943
+ }
944
+ },
945
+ config: projectScan.projectConfig
946
+ }
947
+ };
948
+ };
949
+ const reportUnmaterializedStagedFiles = (unmaterializedFileCount, stagedFileCount, isQuiet) => {
950
+ if (unmaterializedFileCount === 0 || isQuiet) return;
951
+ const stagedFileLabel = `staged file${stagedFileCount === 1 ? "" : "s"}`;
952
+ cliLogger.warn(`Skipped ${unmaterializedFileCount} of ${stagedFileCount} ${stagedFileLabel}; they could not be snapshotted from the index.`);
953
+ cliLogger.break();
954
+ };
955
+ const printStagedBatchResults = async ({ input, completedScans, elapsedMilliseconds, isMultiProject }) => {
956
+ const { flags, scanOptions } = input;
957
+ const outputSurface = scanOptions.outputSurface ?? "cli";
958
+ if (!input.isQuiet && isMultiProject && completedScans.length > 0) await runPromise(printCompletedScansHeadless({
959
+ categoryFilters: input.categoryFilters,
960
+ completedScans,
961
+ elapsedMilliseconds,
962
+ noScoreMessage: "Score unavailable.",
963
+ outputDirectory: flags.outputDir,
964
+ outputSurface,
965
+ projectName: path$1.basename(input.scanTarget.resolvedDirectory),
966
+ verbose: Boolean(flags.verbose)
967
+ }));
968
+ if (!flags.outputDir || !isMultiProject || !input.isQuiet) return;
969
+ await runPromise(printDiagnosticsDump(filterDiagnosticsByCategories(filterScansForSurface(completedScans, outputSurface), input.categoryFilters), flags.outputDir, false, "stderr"));
970
+ };
971
+ const finalizeStagedBatch = ({ input, completedScans, skippedProjects }) => {
972
+ const { flags, scanTarget } = input;
973
+ finalizeCliScans({
974
+ completedScans,
975
+ skippedProjects,
976
+ mode: "staged",
977
+ diff: null,
978
+ baselineIntended: false,
979
+ isJsonMode: input.isJsonMode,
980
+ isScoreOnly: input.isScoreOnly,
981
+ flags,
982
+ categoryFilters: input.categoryFilters,
983
+ userConfig: scanTarget.userConfig,
984
+ resolvedDirectory: scanTarget.resolvedDirectory,
985
+ startTime: input.startTime
986
+ });
987
+ };
988
+ const runMaterializedStagedInspect = async ({ input, projectScans, snapshot, stagedFileCount, stagedLineRanges, emptyScanInput }) => {
989
+ const { scanTarget, scanOptions } = input;
990
+ const resolvedDirectory = scanTarget.resolvedDirectory;
991
+ const projectRuns = buildStagedProjectRuns(resolvedDirectory, projectScans, new Set(snapshot.stagedFiles));
992
+ if (projectRuns.length === 0) {
993
+ reportEmptyStagedScan(emptyScanInput, `Could not read any of the ${stagedFileCount} staged file${stagedFileCount === 1 ? "" : "s"} out of the index, so nothing was scanned. An unusually large staged file is the usual cause.`, "warn");
994
+ return;
995
+ }
996
+ reportUnmaterializedStagedFiles(snapshot.unmaterializedFiles.length, stagedFileCount, input.isQuiet);
997
+ const isMultiProject = projectRuns.length > 1;
998
+ const stagedBatch = await runProjectScanBatch({
999
+ projects: projectRuns,
1000
+ isQuiet: input.isQuiet,
1001
+ isSilent: scanOptions.silent === true,
1002
+ scanProject: (projectRun) => scanStagedProject({
1003
+ input,
1004
+ projectRun,
1005
+ snapshotDirectory: snapshot.tempDirectory,
1006
+ stagedLineRanges,
1007
+ isMultiProject
1008
+ })
1009
+ });
1010
+ const completedScans = stagedBatch.completedScans;
1011
+ const skippedProjects = stagedBatch.skippedScans;
1012
+ reportSkippedProjects({
1013
+ skippedProjects,
1014
+ isQuiet: input.isQuiet
1015
+ });
1016
+ await printStagedBatchResults({
1017
+ input,
1018
+ completedScans,
1019
+ elapsedMilliseconds: stagedBatch.elapsedMilliseconds,
1020
+ isMultiProject
1021
+ });
1022
+ finalizeStagedBatch({
1023
+ input,
1024
+ completedScans,
1025
+ skippedProjects
1026
+ });
1027
+ };
1028
+ const runStagedInspect = async (input) => {
1029
+ const { flags, scanTarget } = input;
1030
+ const resolvedDirectory = scanTarget.resolvedDirectory;
1031
+ const emptyScanInput = {
1032
+ directory: resolvedDirectory,
1033
+ isJsonMode: input.isJsonMode,
1034
+ isScoreOnly: input.isScoreOnly,
1035
+ startTime: input.startTime
1036
+ };
1037
+ const projectScanContexts = await resolveStagedProjectScanContexts(input);
1038
+ const rootStagedFiles = await getStagedSourceFiles(resolvedDirectory);
1039
+ if (rootStagedFiles.length === 0) {
1040
+ reportEmptyStagedScan(emptyScanInput, "No staged source files found.");
1041
+ return;
1042
+ }
1043
+ const stagedProjectScans = assignStagedFilesToProjects(projectScanContexts, rootStagedFiles);
1044
+ if (stagedProjectScans.length === 0) {
1045
+ reportEmptyStagedScan(emptyScanInput, "No staged source files in the selected projects.");
1046
+ return;
1047
+ }
1048
+ const selectedStagedFiles = stagedProjectScans.flatMap((projectScan) => projectScan.stagedFiles);
1049
+ const stagedFileCount = selectedStagedFiles.length;
1050
+ const unselectedStagedFileCount = rootStagedFiles.length - stagedFileCount;
1051
+ if (!input.isQuiet) {
1052
+ cliLogger.log(`Scanning ${highlighter.info(`${stagedFileCount}`)} staged files...`);
1053
+ if (unselectedStagedFileCount > 0) cliLogger.dim(`${unselectedStagedFileCount} more staged file${unselectedStagedFileCount === 1 ? "" : "s"} outside the selected projects.`);
1054
+ cliLogger.break();
1055
+ }
1056
+ const stagedWantsLines = resolveScope(flags, scanTarget.userConfig).scope === "lines";
1057
+ const stagedLineRanges = stagedWantsLines ? await getChangedLineRanges({
1058
+ directory: resolvedDirectory,
1059
+ cached: true,
1060
+ files: selectedStagedFiles
1061
+ }) : null;
1062
+ if (stagedWantsLines && stagedLineRanges === null && !input.isQuiet) {
1063
+ cliLogger.warn("Could not determine staged changed lines; reporting all issues in staged files.");
1064
+ cliLogger.break();
1065
+ }
1066
+ if (stagedProjectScans.some((projectScan) => projectScan.treeRelativeDirectory.length > 0)) recordCount(METRIC.stagedPerProject, 1, { projectCount: stagedProjectScans.length });
1067
+ const tempDirectory = fs$1.mkdtempSync(path$1.join(tmpdir(), STAGED_FILES_TEMP_DIR_PREFIX));
1068
+ const snapshot = await materializeStagedFiles({
1069
+ directory: resolvedDirectory,
1070
+ stagedFiles: selectedStagedFiles,
1071
+ tempDirectory,
1072
+ configSubdirectories: collectConfigSubdirectories(resolvedDirectory, stagedProjectScans)
1073
+ }).catch((error) => {
1074
+ fs$1.rmSync(tempDirectory, {
1075
+ recursive: true,
1076
+ force: true
1077
+ });
1078
+ throw error;
1079
+ });
1080
+ try {
1081
+ await runMaterializedStagedInspect({
1082
+ input,
1083
+ projectScans: stagedProjectScans,
1084
+ snapshot,
1085
+ stagedFileCount,
1086
+ stagedLineRanges,
1087
+ emptyScanInput
1088
+ });
1089
+ } finally {
1090
+ snapshot.cleanup();
1091
+ }
1092
+ };
1093
+ //#endregion
1094
+ //#region src/cli/commands/inspect.ts
1095
+ const buildChangedFilesDiffInfo = (changedFiles) => ({
1096
+ currentBranch: process.env.GITHUB_HEAD_REF?.trim() || null,
1097
+ baseBranch: process.env.GITHUB_BASE_REF?.trim() || "pull request target",
1098
+ baseSha: process.env.REACT_DOCTOR_BASE_SHA?.trim() || void 0,
1099
+ changedFiles,
1100
+ isCurrentChanges: false
1101
+ });
1102
+ const hasScanDeadlineExpired = (scanDeadlineEpochMs) => scanDeadlineEpochMs !== void 0 && remainingDeadlineBudgetMs(scanDeadlineEpochMs) === 0;
1103
+ const isProjectSupplyChainEnabled = ({ flags, projectConfig }) => flags.supplyChain ?? projectConfig?.supplyChain?.enabled !== false;
1104
+ const buildProjectInspectOptions = ({ context, projectScan, projectScanPlan, ownsWorkspaceDeadCode }) => {
1105
+ const scanDirectory = projectScan.directory;
1106
+ return {
1107
+ ...context.scanOptions,
1108
+ deadCode: context.workspaceDeadCodeOwner === null ? context.scanOptions.deadCode : ownsWorkspaceDeadCode,
1109
+ precomputedSourceFileCount: context.precomputedSourceFileCounts?.get(scanDirectory),
1110
+ deadlineEpochMs: context.scanDeadlineEpochMs,
1111
+ includePaths: projectScanPlan.includePaths,
1112
+ configOverride: projectScan.config,
1113
+ configSourceDirectory: projectScan.configSourceDirectory ?? void 0,
1114
+ suppressRendering: context.isMultiProject,
1115
+ concurrentScan: context.isMultiProject,
1116
+ excludedProjectDirectories: context.projectScans.filter((candidateProjectScan) => isPathInsideDirectory(candidateProjectScan.directory, scanDirectory)).map((candidateProjectScan) => candidateProjectScan.directory),
1117
+ retainExcludedProjectDeadCodeDiagnostics: ownsWorkspaceDeadCode,
1118
+ baseline: context.baselineRef !== null && projectScanPlan.projectBaselineBaseFiles !== null && projectScanPlan.projectBaselineHeadFiles !== null ? {
1119
+ ref: context.baselineRef,
1120
+ baseFiles: projectScanPlan.projectBaselineBaseFiles,
1121
+ headFiles: projectScanPlan.projectBaselineHeadFiles
1122
+ } : void 0,
1123
+ changedLineRanges: context.scope === "lines" && context.changedLineRanges !== null ? resolveProjectChangedLineRanges(context.resolvedDirectory, scanDirectory, context.changedLineRanges) : void 0,
1124
+ supplyChainManifestChanged: projectScanPlan.supplyChainManifestChanged
1125
+ };
1126
+ };
1127
+ const runConfiguredProjectScan = async ({ context, projectScan }) => {
1128
+ if (hasScanDeadlineExpired(context.scanDeadlineEpochMs)) return {
1129
+ status: "skipped",
1130
+ value: {
1131
+ directory: projectScan.directory,
1132
+ reason: "max-duration"
1133
+ }
1134
+ };
1135
+ const scanDirectory = projectScan.directory;
1136
+ const projectConfig = projectScan.config;
1137
+ const ownsWorkspaceDeadCode = scanDirectory === context.workspaceDeadCodeOwner;
1138
+ const supplyChainEnabled = isProjectSupplyChainEnabled({
1139
+ flags: context.flags,
1140
+ projectConfig
1141
+ });
1142
+ const projectScanPlan = buildProjectScanPlan({
1143
+ rootDirectory: context.resolvedDirectory,
1144
+ projectDirectory: scanDirectory,
1145
+ baselineDiffPlan: context.baselineDiffPlan,
1146
+ diffInfo: context.diffInfo,
1147
+ isDiffMode: context.isDiffMode,
1148
+ supplyChainEnabled
1149
+ });
1150
+ if (projectScanPlan.shouldSkipProject) {
1151
+ if (!context.isQuiet) {
1152
+ cliLogger.dim(`No changed source files in ${scanDirectory}, skipping.`);
1153
+ cliLogger.break();
1154
+ }
1155
+ return { status: "omitted" };
1156
+ }
1157
+ if (!context.isQuiet && !context.isMultiProject) cliLogger.dim(" ");
1158
+ const scanResult = await context.inspectProject(scanDirectory, buildProjectInspectOptions({
1159
+ context,
1160
+ projectScan,
1161
+ projectScanPlan,
1162
+ ownsWorkspaceDeadCode
1163
+ }));
1164
+ if (!context.isQuiet && !context.isMultiProject) cliLogger.break();
1165
+ return {
1166
+ status: "completed",
1167
+ value: {
1168
+ directory: scanDirectory,
1169
+ result: scanResult,
1170
+ config: projectConfig
1171
+ }
1172
+ };
1173
+ };
1174
+ /**
1175
+ * On an interactive human run, rename a pre-migration
1176
+ * `react-doctor.config.json` to `doctor.config.ts` before config is loaded,
1177
+ * so the scan reads the renamed file and the user is told once. CI, coding
1178
+ * agents, JSON/score output, pre-commit (`--staged`) hooks, and non-TTY runs
1179
+ * are left untouched — the loader still reads the legacy file as a deprecated
1180
+ * fallback and warns — so a scan never mutates the repo unattended.
1181
+ */
1182
+ const maybeMigrateLegacyConfig = async (requestedDirectory, { isQuiet, isStaged }) => {
1183
+ if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
1184
+ await runProjectMigrations(requestedDirectory);
1185
+ };
1186
+ const inspectAction = async (directory, flags, invocationCommand = "inspect") => {
1187
+ const isScoreOnly = Boolean(flags.score);
1188
+ const isJsonMode = Boolean(flags.json);
1189
+ const isQuiet = isScoreOnly || isJsonMode;
1190
+ const requestedDirectory = path$1.resolve(directory);
1191
+ const startTime = performance.now();
1192
+ let scanStartupSpinner = null;
1193
+ try {
1194
+ if (isJsonMode) {
1195
+ enableJsonMode({
1196
+ compact: Boolean(flags.jsonCompact),
1197
+ directory: requestedDirectory,
1198
+ outputFile: flags.jsonOut
1199
+ });
1200
+ if (flags.jsonOut) recordCount(METRIC.jsonOutUsed, 1);
1201
+ }
1202
+ recordCount(METRIC.cliInvoked, 1, { command: invocationCommand });
1203
+ validateModeFlags(flags);
1204
+ if (flags.staged) setJsonReportMode("staged");
1205
+ await maybeMigrateLegacyConfig(requestedDirectory, {
1206
+ isQuiet,
1207
+ isStaged: Boolean(flags.staged)
1208
+ });
1209
+ const scanTarget = await resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
1210
+ const userConfig = scanTarget.userConfig;
1211
+ const resolvedDirectory = scanTarget.resolvedDirectory;
1212
+ setJsonReportDirectory(resolvedDirectory);
1213
+ warnDeprecatedFailOn(flags, userConfig);
1214
+ warnDeprecatedDiff(flags, userConfig);
1215
+ warnIfAiTrainingEnvironment();
1216
+ if (scanTarget.didRedirectViaRootDir && !isQuiet) {
1217
+ cliLogger.dim(`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`);
1218
+ cliLogger.break();
1219
+ }
1220
+ if (flags.staged) {
1221
+ const divergentConfigFiles = findStagedSnapshotDivergences(resolvedDirectory);
1222
+ if (divergentConfigFiles === null) throw new CliInputError("Could not verify that staged configuration matches the worktree. Run the command from a Git worktree with Git available.");
1223
+ if (divergentConfigFiles.length > 0) {
1224
+ recordCount(METRIC.stagedSnapshotDivergence, 1, { divergentInputCount: divergentConfigFiles.length });
1225
+ throw new CliInputError(`Cannot scan staged files while configuration differs between the index and worktree: ${divergentConfigFiles.join(", ")}. Stage or restore those files, then rerun react-doctor --staged.`);
1226
+ }
1227
+ }
1228
+ const explainArgument = flags.explain;
1229
+ if (explainArgument !== void 0) {
1230
+ await runExplain(explainArgument, {
1231
+ resolvedDirectory,
1232
+ userConfig,
1233
+ scanOptions: resolveCliInspectOptions(flags, userConfig),
1234
+ projectFlag: flags.project
1235
+ });
1236
+ return;
1237
+ }
1238
+ const scanOptions = resolveCliInspectOptions(flags, userConfig);
1239
+ const inspectProject = createInvocationInspect(scanOptions.concurrency);
1240
+ const scanDeadlineEpochMs = scanOptions.maxDurationMs !== void 0 ? Date.now() + scanOptions.maxDurationMs : void 0;
1241
+ const categoryFilters = new Set(scanOptions.categoryFilters ?? []);
1242
+ const skipPrompts = shouldSkipPrompts({
1243
+ yes: flags.yes,
1244
+ json: flags.json
1245
+ });
1246
+ if (flags.staged) {
1247
+ await runStagedInspect({
1248
+ flags,
1249
+ scanTarget,
1250
+ scanOptions,
1251
+ inspectProject,
1252
+ scanDeadlineEpochMs,
1253
+ categoryFilters,
1254
+ isQuiet,
1255
+ isJsonMode,
1256
+ isScoreOnly,
1257
+ startTime
1258
+ });
1259
+ return;
1260
+ }
1261
+ const projectDirectories = await selectProjects(resolvedDirectory, flags.project, skipPrompts, userConfig?.projects);
1262
+ const projectSelectionCompletedTime = performance.now();
1263
+ let changedFilesDiffInfo = flags.changedFilesFrom ? buildChangedFilesDiffInfo(readChangedFilesFrom(path$1.resolve(flags.changedFilesFrom))) : null;
1264
+ if (changedFilesDiffInfo !== null && scanTarget.didRedirectViaRootDir) {
1265
+ const relativeProjectDirectory = resolveProjectRelativeDirectory(requestedDirectory, resolvedDirectory);
1266
+ if (relativeProjectDirectory) {
1267
+ const projectPrefix = `${relativeProjectDirectory}/`;
1268
+ changedFilesDiffInfo = {
1269
+ ...changedFilesDiffInfo,
1270
+ changedFiles: changedFilesDiffInfo.changedFiles.flatMap((filePath) => {
1271
+ return filePath.startsWith(projectPrefix) ? [filePath.slice(projectPrefix.length)] : [];
1272
+ })
1273
+ };
1274
+ }
1275
+ }
1276
+ const requestedScope = resolveScope(flags, userConfig);
1277
+ const includeUntracked = flags.includeUntracked ?? false;
1278
+ const scopeRequest = requestedScope.scope === void 0 && changedFilesDiffInfo !== null ? {
1279
+ ...requestedScope,
1280
+ scope: "changed"
1281
+ } : requestedScope;
1282
+ validateIncludeUntrackedScope(includeUntracked, scopeRequest.scope);
1283
+ scanStartupSpinner = !isQuiet ? spinner("Scanning...").start() : null;
1284
+ if (scanStartupSpinner !== null) recordDistribution(METRIC.scanFeedbackDelay, performance.now() - projectSelectionCompletedTime, { unit: "millisecond" });
1285
+ const wantsDiffMode = scopeRequest.scope !== void 0 && scopeRequest.scope !== "full";
1286
+ const shouldDetectDiff = changedFilesDiffInfo === null && (wantsDiffMode || scopeRequest.scope === void 0 && !skipPrompts && !isQuiet);
1287
+ const diffInfo = changedFilesDiffInfo ?? (shouldDetectDiff ? await getDiffInfo(resolvedDirectory, scopeRequest.base, includeUntracked) : null);
1288
+ scanStartupSpinner?.stop();
1289
+ scanStartupSpinner = null;
1290
+ const scope = await finalizeScope({
1291
+ requested: scopeRequest,
1292
+ diffInfo,
1293
+ skipPrompts,
1294
+ isQuiet
1295
+ });
1296
+ scanStartupSpinner = !isQuiet ? spinner("Scanning...").start() : null;
1297
+ const isDiffMode = scope !== "full";
1298
+ const comparisonBaseRef = isDiffMode && diffInfo && !diffInfo.isCurrentChanges ? diffInfo.baseSha ? await resolveMergeBaseRef(resolvedDirectory, diffInfo.baseSha) : diffInfo.diffBaseRef ?? await resolveMergeBaseRef(resolvedDirectory, diffInfo.baseBranch) : null;
1299
+ const baselineRef = scope === "changed" ? comparisonBaseRef : null;
1300
+ const baselineDiffPlan = baselineRef === null ? null : await getBaselineDiffPlan(resolvedDirectory, baselineRef);
1301
+ const linesBaseRef = diffInfo?.isCurrentChanges ? "HEAD" : comparisonBaseRef;
1302
+ const changedLineRanges = scope === "lines" && diffInfo !== null && (diffInfo.isCurrentChanges || linesBaseRef !== null) && diffInfo !== null ? await getChangedLineRanges({
1303
+ directory: resolvedDirectory,
1304
+ baseRef: linesBaseRef ?? void 0,
1305
+ files: [...diffInfo.changedFiles],
1306
+ includeUntracked
1307
+ }) : null;
1308
+ scanStartupSpinner?.stop();
1309
+ scanStartupSpinner = null;
1310
+ if (scope === "lines" && changedLineRanges === null && !isQuiet) {
1311
+ cliLogger.warn("Could not determine changed lines (no base ref or git diff failed); reporting all issues in changed files.");
1312
+ cliLogger.break();
1313
+ }
1314
+ setJsonReportMode(baselineRef ? "baseline" : isDiffMode ? "diff" : "full");
1315
+ if (isDiffMode && diffInfo && !isQuiet) {
1316
+ if (diffInfo.isCurrentChanges) cliLogger.log("Scanning uncommitted changes");
1317
+ else {
1318
+ const currentBranchLabel = diffInfo.currentBranch ?? "(detached HEAD)";
1319
+ cliLogger.log(`Scanning changes: ${highlighter.info(currentBranchLabel)} → ${highlighter.info(diffInfo.baseBranch)}`);
1320
+ }
1321
+ cliLogger.break();
1322
+ }
1323
+ const projectScans = deduplicateProjectScans(await Promise.all(projectDirectories.map((projectDirectory) => resolveProjectScan(scanTarget, projectDirectory))));
1324
+ const isMultiProject = projectScans.length > 1;
1325
+ const rootProjectScan = projectScans.find((projectScan) => path$1.resolve(projectScan.directory) === path$1.resolve(resolvedDirectory));
1326
+ const workspaceDeadCodeOwner = resolveWorkspaceDeadCodeOwner({
1327
+ rootDirectory: resolvedDirectory,
1328
+ projectDirectories: projectScans.map((projectScan) => projectScan.directory),
1329
+ isRootDeadCodeEnabled: scanOptions.deadCode ?? rootProjectScan?.config?.deadCode ?? true
1330
+ });
1331
+ if (workspaceDeadCodeOwner !== null) recordCount(METRIC.scanWorkspaceDeadCodeShared, 1, { projectCount: projectScans.length });
1332
+ const projectScanExecutionContext = {
1333
+ flags,
1334
+ resolvedDirectory,
1335
+ scanOptions,
1336
+ inspectProject,
1337
+ scanDeadlineEpochMs,
1338
+ baselineDiffPlan,
1339
+ diffInfo,
1340
+ isDiffMode,
1341
+ isQuiet,
1342
+ isMultiProject,
1343
+ workspaceDeadCodeOwner,
1344
+ precomputedSourceFileCounts: isMultiProject && !isDiffMode ? await collectProjectSourceFileCounts(resolvedDirectory, projectScans.map((projectScan) => projectScan.directory)) : null,
1345
+ projectScans,
1346
+ baselineRef,
1347
+ scope,
1348
+ changedLineRanges
1349
+ };
1350
+ const projectBatch = await runProjectScanBatch({
1351
+ projects: projectScans,
1352
+ isQuiet,
1353
+ isSilent: scanOptions.silent === true,
1354
+ scanProject: (projectScan) => runConfiguredProjectScan({
1355
+ context: projectScanExecutionContext,
1356
+ projectScan
1357
+ })
1358
+ });
1359
+ const completedScans = await retryMissingProjectScores(projectBatch.completedScans.map((completedScan) => ({
1360
+ ...completedScan,
1361
+ isScoreDisabled: scanOptions.noScore ?? completedScan.config?.noScore ?? false
1362
+ })));
1363
+ const skippedProjects = projectBatch.skippedScans;
1364
+ reportSkippedProjects({
1365
+ skippedProjects,
1366
+ isQuiet
1367
+ });
1368
+ if (!isQuiet && isMultiProject && completedScans.length > 0) await runPromise(printCompletedScansHeadless({
1369
+ categoryFilters,
1370
+ completedScans,
1371
+ elapsedMilliseconds: projectBatch.elapsedMilliseconds,
1372
+ noScoreMessage: "Score unavailable.",
1373
+ outputDirectory: flags.outputDir,
1374
+ outputSurface: scanOptions.outputSurface ?? "cli",
1375
+ projectName: path$1.basename(resolvedDirectory),
1376
+ verbose: Boolean(flags.verbose)
1377
+ }));
1378
+ const selectedSurfaceDiagnostics = filterDiagnosticsByCategories(filterScansForSurface(completedScans, scanOptions.outputSurface ?? "cli"), categoryFilters);
1379
+ const didScansWriteDump = isMultiProject ? !isQuiet && completedScans.length > 0 : completedScans.length > 0;
1380
+ if (flags.outputDir && !didScansWriteDump) await runPromise(printDiagnosticsDump(selectedSurfaceDiagnostics, flags.outputDir, false, isQuiet ? "stderr" : "stdout"));
1381
+ finalizeCliScans({
1382
+ completedScans,
1383
+ skippedProjects,
1384
+ mode: baselineRef ? "baseline" : isDiffMode ? "diff" : "full",
1385
+ diff: isDiffMode ? diffInfo : null,
1386
+ baselineIntended: scope === "changed" && diffInfo !== null && !diffInfo.isCurrentChanges,
1387
+ isJsonMode,
1388
+ isScoreOnly,
1389
+ flags,
1390
+ categoryFilters,
1391
+ userConfig,
1392
+ resolvedDirectory,
1393
+ startTime
1394
+ });
1395
+ if (!isQuiet && !skipPrompts && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment() && selectedSurfaceDiagnostics.length > 0) {
1396
+ await handoffToAgent({
1397
+ diagnostics: selectedSurfaceDiagnostics,
1398
+ projectName: path$1.basename(resolvedDirectory),
1399
+ rootDirectory: resolvedDirectory,
1400
+ interactive: true,
1401
+ outputDirectory: flags.outputDir
1402
+ });
1403
+ return;
1404
+ }
1405
+ const setupProjectRoot = resolveInstallSetupProjectRoot({
1406
+ scanRoot: resolvedDirectory,
1407
+ scanDirectories: projectDirectories
1408
+ });
1409
+ if (setupProjectRoot !== null) {
1410
+ if (shouldShowAgentInstallHint({
1411
+ projectRoot: setupProjectRoot,
1412
+ hasCompletedScan: completedScans.length > 0,
1413
+ isJsonMode,
1414
+ isScoreOnly,
1415
+ isStaged: Boolean(flags.staged)
1416
+ })) {
1417
+ printAgentInstallHint();
1418
+ recordCount(METRIC.agentInstallHintShown, 1);
1419
+ disableSetupPrompt(setupProjectRoot);
1420
+ }
1421
+ }
1422
+ } catch (error) {
1423
+ scanStartupSpinner?.stop();
1424
+ const isUserError = isExpectedUserError(error);
1425
+ const sentryEventId = isUserError ? void 0 : await reportErrorToSentry(error);
1426
+ if (isDebugFlagEnabled()) await flushSentry();
1427
+ if (isJsonMode) {
1428
+ writeJsonErrorReport(error, sentryEventId);
1429
+ process.exitCode = 1;
1430
+ return;
1431
+ }
1432
+ if (isUserError) {
1433
+ handleUserError(error);
1434
+ return;
1435
+ }
1436
+ handleError(error, { sentryEventId });
1437
+ }
1438
+ };
1439
+ //#endregion
1440
+ export { warnDeprecatedFailOn as n, runProjectMigrations as r, inspectAction as t };
1441
+
1442
+ //# sourceMappingURL=inspect-Z_e5RkQs.js.map
1443
+ //# debugId=0567ce41-4a3d-5220-9609-7ec2ee490a60