react-doctor 0.2.14-dev.4bc8a73 → 0.2.14-dev.5976266

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -15,7 +15,10 @@ import * as Redacted from "effect/Redacted";
15
15
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
16
16
  import * as Otlp from "effect/unstable/observability/Otlp";
17
17
  import * as Context from "effect/Context";
18
+ import os, { tmpdir } from "node:os";
18
19
  import * as Console from "effect/Console";
20
+ import { parseJSON5 } from "confbox";
21
+ import { createJiti } from "jiti";
19
22
  import * as Fiber from "effect/Fiber";
20
23
  import * as Filter from "effect/Filter";
21
24
  import * as Option from "effect/Option";
@@ -27,9 +30,9 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
27
30
  import * as NodePath from "@effect/platform-node-shared/NodePath";
28
31
  import * as ChildProcess from "effect/unstable/process/ChildProcess";
29
32
  import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
30
- import os, { tmpdir } from "node:os";
31
33
  import * as ts from "typescript";
32
34
  import { gzipSync } from "node:zlib";
35
+ import * as Sentry from "@sentry/node";
33
36
  import { performance } from "node:perf_hooks";
34
37
  import { stripVTControlCharacters } from "node:util";
35
38
  import tty from "node:tty";
@@ -39,6 +42,8 @@ import basePrompts from "prompts";
39
42
  import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
40
43
  import { fileURLToPath } from "node:url";
41
44
  import Conf from "conf";
45
+ import { generateCode, loadFile, writeFile } from "magicast";
46
+ import { getConfigFromVariableDeclaration, getDefaultExportOptions } from "magicast/helpers";
42
47
  //#region \0rolldown/runtime.js
43
48
  var __create$1 = Object.create;
44
49
  var __defProp$1 = Object.defineProperty;
@@ -6290,7 +6295,8 @@ const MILLISECONDS_PER_SECOND = 1e3;
6290
6295
  const SCORE_API_URL = "https://www.react.doctor/api/score";
6291
6296
  const ENTERPRISE_CONTACT_URL = "https://react.doctor/enterprise";
6292
6297
  const SHARE_BASE_URL = "https://react.doctor/share";
6293
- const PROMPTS_RULES_BASE_URL = "https://www.react.doctor/prompts/rules";
6298
+ const DOCS_URL = "https://www.react.doctor/docs";
6299
+ const DOCS_RULES_BASE_URL = `${DOCS_URL}/rules`;
6294
6300
  const FETCH_TIMEOUT_MS = 1e4;
6295
6301
  const GITHUB_VIEWER_PERMISSION_TIMEOUT_MS = 2e3;
6296
6302
  const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
@@ -6308,11 +6314,19 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
6308
6314
  "tsconfig.json",
6309
6315
  "tsconfig.base.json",
6310
6316
  "package.json",
6311
- "react-doctor.config.json",
6317
+ "doctor.config.ts",
6318
+ "doctor.config.mts",
6319
+ "doctor.config.cts",
6320
+ "doctor.config.js",
6321
+ "doctor.config.mjs",
6322
+ "doctor.config.cjs",
6323
+ "doctor.config.json",
6324
+ "doctor.config.jsonc",
6312
6325
  "oxlint.json",
6313
6326
  ".oxlintrc.json"
6314
6327
  ];
6315
6328
  const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
6329
+ const CANONICAL_DISCORD_URL = "https://react.doctor/discord";
6316
6330
  const SKILL_NAME = "react-doctor";
6317
6331
  const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
6318
6332
  const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
@@ -7288,6 +7302,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
7288
7302
  }).pipe(Layer.provide(FetchHttpClient.layer));
7289
7303
  }).pipe(Effect.orDie));
7290
7304
  /**
7305
+ * Resolves a requested lint worker count to a clamped integer within
7306
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
7307
+ * machine's CPU cores; out-of-range or non-finite requests degrade to
7308
+ * `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
7309
+ */
7310
+ const resolveScanConcurrency = (requested) => {
7311
+ const desired = requested === "auto" ? os.availableParallelism() : requested;
7312
+ if (!Number.isFinite(desired) || desired < 1) return 1;
7313
+ return Math.max(1, Math.min(Math.floor(desired), 16));
7314
+ };
7315
+ /**
7291
7316
  * Per-batch oxlint wall-clock budget. Reads from the env var on
7292
7317
  * startup so the eval harness can raise the budget under sandbox
7293
7318
  * microVMs without recompiling react-doctor. Tests override via
@@ -7307,6 +7332,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
7307
7332
  * tests that exercise the cap behavior.
7308
7333
  */
7309
7334
  var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
7335
+ /**
7336
+ * Number of oxlint subprocesses the lint pass runs in parallel. Defaults
7337
+ * to `1` (serial — the historical behavior) so resource usage is opt-in.
7338
+ * The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
7339
+ * `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
7340
+ * CI callers that never touch the flag:
7341
+ *
7342
+ * - unset / `0` / `false` / `off` → `1` (serial)
7343
+ * - `auto` / `true` / `on` → available CPU cores (clamped)
7344
+ * - a positive integer → that many workers (clamped)
7345
+ *
7346
+ * The resolved value is always within
7347
+ * `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
7348
+ */
7349
+ var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
7350
+ const raw = process.env["REACT_DOCTOR_PARALLEL"];
7351
+ if (raw === void 0) return 1;
7352
+ const normalized = raw.trim().toLowerCase();
7353
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
7354
+ if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
7355
+ const parsed = Number.parseInt(normalized, 10);
7356
+ if (!Number.isInteger(parsed) || parsed <= 0) return 1;
7357
+ return resolveScanConcurrency(parsed);
7358
+ } }) {};
7310
7359
  const DIAGNOSTIC_SURFACES = [
7311
7360
  "cli",
7312
7361
  "prComment",
@@ -7463,66 +7512,135 @@ const validateConfigTypes = (config) => {
7463
7512
  const warn = (message) => {
7464
7513
  Effect.runSync(Console.warn(message));
7465
7514
  };
7466
- const CONFIG_FILENAME = "react-doctor.config.json";
7467
- const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
7468
- const loadConfigFromDirectory = (directory) => {
7469
- const configFilePath = path.join(directory, CONFIG_FILENAME);
7470
- if (isFile(configFilePath)) try {
7471
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
7472
- const parsed = JSON.parse(fileContent);
7473
- if (isPlainObject(parsed)) return {
7474
- config: validateConfigTypes(parsed),
7475
- sourceDirectory: directory
7476
- };
7477
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
7478
- } catch (error) {
7479
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
7480
- }
7481
- const packageJsonPath = path.join(directory, "package.json");
7482
- if (isFile(packageJsonPath)) try {
7483
- const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
7484
- const packageJson = JSON.parse(fileContent);
7515
+ const CONFIG_BASENAME = "doctor.config";
7516
+ const CONFIG_EXTENSIONS = [
7517
+ "ts",
7518
+ "mts",
7519
+ "cts",
7520
+ "js",
7521
+ "mjs",
7522
+ "cjs",
7523
+ "json",
7524
+ "jsonc"
7525
+ ];
7526
+ const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
7527
+ const PACKAGE_JSON_FILENAME = "package.json";
7528
+ const PACKAGE_JSON_CONFIG_KEY$1 = "reactDoctor";
7529
+ const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
7530
+ const jiti = createJiti(import.meta.url);
7531
+ const formatError = (error) => error instanceof Error ? error.message : String(error);
7532
+ const loadModuleConfig = async (filePath) => {
7533
+ const imported = await jiti.import(filePath);
7534
+ return imported?.default ?? imported;
7535
+ };
7536
+ const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
7537
+ const readEmbeddedPackageJsonConfig = (directory) => {
7538
+ const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
7539
+ if (!isFile(packageJsonPath)) return null;
7540
+ try {
7541
+ const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
7485
7542
  if (isPlainObject(packageJson)) {
7486
- const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
7487
- if (isPlainObject(embeddedConfig)) return {
7488
- config: validateConfigTypes(embeddedConfig),
7489
- sourceDirectory: directory
7543
+ const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY$1];
7544
+ if (isPlainObject(embeddedConfig)) return embeddedConfig;
7545
+ }
7546
+ } catch {}
7547
+ return null;
7548
+ };
7549
+ const loadPackageJsonConfig = (directory) => {
7550
+ const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
7551
+ if (!embeddedConfig) return null;
7552
+ return {
7553
+ config: validateConfigTypes(embeddedConfig),
7554
+ sourceDirectory: directory,
7555
+ configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
7556
+ format: "package-json"
7557
+ };
7558
+ };
7559
+ const loadConfigFromDirectory = async (directory) => {
7560
+ let sawBrokenConfigFile = false;
7561
+ for (const extension of CONFIG_EXTENSIONS) {
7562
+ const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
7563
+ if (!isFile(filePath)) continue;
7564
+ const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
7565
+ try {
7566
+ const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
7567
+ if (isPlainObject(parsed)) return {
7568
+ status: "found",
7569
+ loaded: {
7570
+ config: validateConfigTypes(parsed),
7571
+ sourceDirectory: directory,
7572
+ configFilePath: filePath,
7573
+ format: isDataFile ? "json" : "module"
7574
+ }
7490
7575
  };
7576
+ warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
7577
+ sawBrokenConfigFile = true;
7578
+ } catch (error) {
7579
+ warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
7580
+ sawBrokenConfigFile = true;
7491
7581
  }
7492
- } catch {
7493
- return null;
7494
7582
  }
7495
- return null;
7583
+ const packageJsonConfig = loadPackageJsonConfig(directory);
7584
+ if (packageJsonConfig) return {
7585
+ status: "found",
7586
+ loaded: packageJsonConfig
7587
+ };
7588
+ if (isFile(path.join(directory, LEGACY_CONFIG_FILENAME))) warn(`${LEGACY_CONFIG_FILENAME} is no longer read — rename it to ${CONFIG_BASENAME}.json (or author a ${CONFIG_BASENAME}.ts).`);
7589
+ return {
7590
+ status: sawBrokenConfigFile ? "invalid" : "absent",
7591
+ loaded: null
7592
+ };
7496
7593
  };
7497
7594
  const cachedConfigs = /* @__PURE__ */ new Map();
7498
- const loadConfigWithSource = (rootDirectory) => {
7499
- const cached = cachedConfigs.get(rootDirectory);
7500
- if (cached !== void 0) return cached;
7501
- const localConfig = loadConfigFromDirectory(rootDirectory);
7502
- if (localConfig) {
7503
- cachedConfigs.set(rootDirectory, localConfig);
7504
- return localConfig;
7505
- }
7506
- if (isProjectBoundary(rootDirectory)) {
7507
- cachedConfigs.set(rootDirectory, null);
7508
- return null;
7509
- }
7595
+ const clearConfigCache = () => {
7596
+ cachedConfigs.clear();
7597
+ };
7598
+ const loadConfigWalkingUp = async (rootDirectory) => {
7599
+ const localResult = await loadConfigFromDirectory(rootDirectory);
7600
+ if (localResult.status === "found") return localResult.loaded;
7601
+ if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
7510
7602
  let ancestorDirectory = path.dirname(rootDirectory);
7511
7603
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
7512
- const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
7513
- if (ancestorConfig) {
7514
- cachedConfigs.set(rootDirectory, ancestorConfig);
7515
- return ancestorConfig;
7516
- }
7517
- if (isProjectBoundary(ancestorDirectory)) {
7518
- cachedConfigs.set(rootDirectory, null);
7519
- return null;
7520
- }
7604
+ const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
7605
+ if (ancestorResult.status === "found") return ancestorResult.loaded;
7606
+ if (isProjectBoundary(ancestorDirectory)) return null;
7521
7607
  ancestorDirectory = path.dirname(ancestorDirectory);
7522
7608
  }
7523
- cachedConfigs.set(rootDirectory, null);
7524
7609
  return null;
7525
7610
  };
7611
+ const loadConfigWithSource = (rootDirectory) => {
7612
+ const cached = cachedConfigs.get(rootDirectory);
7613
+ if (cached !== void 0) return cached;
7614
+ const loadPromise = loadConfigWalkingUp(rootDirectory);
7615
+ cachedConfigs.set(rootDirectory, loadPromise);
7616
+ return loadPromise;
7617
+ };
7618
+ const directoryHasCurrentConfig = (directory) => {
7619
+ for (const extension of CONFIG_EXTENSIONS) if (isFile(path.join(directory, `${CONFIG_BASENAME}.${extension}`))) return true;
7620
+ return readEmbeddedPackageJsonConfig(directory) !== null;
7621
+ };
7622
+ /**
7623
+ * Walks up from `rootDirectory` (same boundary semantics as
7624
+ * `loadConfigWithSource`) looking for a pre-migration
7625
+ * `react-doctor.config.json` that is no longer read. Returns the first one
7626
+ * found, or `null` when a current-format config supersedes it or none exists
7627
+ * before a project boundary. Detection only — the CLI performs the rename.
7628
+ */
7629
+ const findLegacyConfig = (rootDirectory) => {
7630
+ let directory = rootDirectory;
7631
+ while (true) {
7632
+ if (directoryHasCurrentConfig(directory)) return null;
7633
+ const legacyFilePath = path.join(directory, LEGACY_CONFIG_FILENAME);
7634
+ if (isFile(legacyFilePath)) return {
7635
+ legacyFilePath,
7636
+ directory
7637
+ };
7638
+ if (isProjectBoundary(directory)) return null;
7639
+ const parentDirectory = path.dirname(directory);
7640
+ if (parentDirectory === directory) return null;
7641
+ directory = parentDirectory;
7642
+ }
7643
+ };
7526
7644
  const resolveConfigRootDir = (config, configSourceDirectory) => {
7527
7645
  if (!config || !configSourceDirectory) return null;
7528
7646
  const rawRootDir = config.rootDir;
@@ -7550,8 +7668,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
7550
7668
  * (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
7551
7669
  *
7552
7670
  * 1. Resolve the requested directory to absolute.
7553
- * 2. Load `react-doctor.config.(json|js)` / `package.json#reactDoctor`
7554
- * if present.
7671
+ * 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
7555
7672
  * 3. Honor `config.rootDir` to redirect the scan to a nested
7556
7673
  * project root, if configured.
7557
7674
  * 4. Walk into a nested React subproject when the requested
@@ -7569,9 +7686,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
7569
7686
  * via its own cache). Routing through `resolveScanTarget` keeps every
7570
7687
  * shell in agreement on what "the scan directory" means.
7571
7688
  */
7572
- const resolveScanTarget = (requestedDirectory, options = {}) => {
7689
+ const resolveScanTarget = async (requestedDirectory, options = {}) => {
7573
7690
  const absoluteRequested = path.resolve(requestedDirectory);
7574
- const loadedConfig = loadConfigWithSource(absoluteRequested);
7691
+ const loadedConfig = await loadConfigWithSource(absoluteRequested);
7575
7692
  const userConfig = loadedConfig?.config ?? null;
7576
7693
  const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
7577
7694
  const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
@@ -8618,8 +8735,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
8618
8735
  const cache = yield* Cache.make({
8619
8736
  capacity: 16,
8620
8737
  timeToLive: CONFIG_CACHE_TTL_MS,
8621
- lookup: (directory) => Effect.sync(() => {
8622
- const loaded = loadConfigWithSource(directory);
8738
+ lookup: (directory) => Effect.promise(async () => {
8739
+ const loaded = await loadConfigWithSource(directory);
8623
8740
  const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
8624
8741
  return {
8625
8742
  config: loaded?.config ?? null,
@@ -9494,6 +9611,44 @@ const dedupeDiagnostics = (diagnostics) => {
9494
9611
  }
9495
9612
  return uniqueDiagnostics;
9496
9613
  };
9614
+ /**
9615
+ * Runs `task` over `items` with at most `concurrency` tasks in flight at
9616
+ * once, returning results in input order. A pool of workers each pulls the
9617
+ * next not-yet-started index until the list drains — so a worker that
9618
+ * finishes a fast task immediately picks up the next one (greedy load
9619
+ * balancing), which matters when tasks have uneven durations (oxlint
9620
+ * batches do).
9621
+ *
9622
+ * Failure semantics mirror a bounded `Promise.all`: on the first rejection
9623
+ * no further tasks are started, the already-in-flight tasks are awaited to
9624
+ * settle (so no subprocess is orphaned mid-write), and the returned promise
9625
+ * rejects with that first error. This keeps the caller's fail-fast retry
9626
+ * path (e.g. oxlint's retry-without-extends) from spawning a second wave on
9627
+ * top of a still-running first one.
9628
+ */
9629
+ const mapWithConcurrency = async (items, concurrency, task) => {
9630
+ const results = new Array(items.length);
9631
+ if (items.length === 0) return results;
9632
+ const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
9633
+ let nextIndex = 0;
9634
+ const errors = [];
9635
+ const runWorker = async () => {
9636
+ while (errors.length === 0) {
9637
+ const index = nextIndex;
9638
+ nextIndex += 1;
9639
+ if (index >= items.length) return;
9640
+ try {
9641
+ results[index] = await task(items[index], index);
9642
+ } catch (error) {
9643
+ errors.push(error);
9644
+ return;
9645
+ }
9646
+ }
9647
+ };
9648
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
9649
+ if (errors.length > 0) throw errors[0];
9650
+ return results;
9651
+ };
9497
9652
  const getPublicEnvPrefix = (framework) => {
9498
9653
  switch (framework) {
9499
9654
  case "nextjs": return "NEXT_PUBLIC_*";
@@ -10176,6 +10331,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
10176
10331
  */
10177
10332
  const spawnLintBatches = async (input) => {
10178
10333
  const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
10334
+ const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
10179
10335
  const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
10180
10336
  const allDiagnostics = [];
10181
10337
  const droppedFiles = [];
@@ -10195,23 +10351,31 @@ const spawnLintBatches = async (input) => {
10195
10351
  return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
10196
10352
  }
10197
10353
  };
10354
+ let startedFileCount = 0;
10198
10355
  let scannedFileCount = 0;
10199
- for (const batch of fileBatches) {
10200
- let batchFileIndex = 0;
10201
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
10202
- if (batchFileIndex < batch.length) {
10203
- batchFileIndex += 1;
10204
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
10205
- }
10206
- }, 50) : null;
10207
- try {
10356
+ let displayedFileCount = 0;
10357
+ const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
10358
+ const ceiling = Math.min(startedFileCount, totalFileCount - 1);
10359
+ if (displayedFileCount < ceiling) {
10360
+ displayedFileCount += 1;
10361
+ onFileProgress(displayedFileCount, totalFileCount);
10362
+ }
10363
+ }, 50) : null;
10364
+ progressTimer?.unref?.();
10365
+ try {
10366
+ const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
10367
+ startedFileCount += batch.length;
10208
10368
  const batchDiagnostics = await spawnLintBatch(batch);
10209
- allDiagnostics.push(...batchDiagnostics);
10210
10369
  scannedFileCount += batch.length;
10211
- onFileProgress?.(scannedFileCount, totalFileCount);
10212
- } finally {
10213
- if (progressInterval !== null) clearInterval(progressInterval);
10214
- }
10370
+ if (onFileProgress) {
10371
+ displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
10372
+ onFileProgress(displayedFileCount, totalFileCount);
10373
+ }
10374
+ return batchDiagnostics;
10375
+ });
10376
+ for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
10377
+ } finally {
10378
+ if (progressTimer !== null) clearInterval(progressTimer);
10215
10379
  }
10216
10380
  if (droppedFiles.length > 0 && onPartialFailure) {
10217
10381
  const previewFiles = droppedFiles.slice(0, 3).join(", ");
@@ -10338,7 +10502,8 @@ const runOxlint = async (options) => {
10338
10502
  onPartialFailure,
10339
10503
  onFileProgress: options.onFileProgress,
10340
10504
  spawnTimeoutMs,
10341
- outputMaxBytes
10505
+ outputMaxBytes,
10506
+ concurrency: options.concurrency
10342
10507
  });
10343
10508
  writeOxlintConfig(configPath, buildConfig(extendsPaths));
10344
10509
  try {
@@ -10406,6 +10571,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
10406
10571
  const partialFailures = yield* LintPartialFailures;
10407
10572
  const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
10408
10573
  const outputMaxBytes = yield* OxlintOutputMaxBytes;
10574
+ const concurrency = yield* OxlintConcurrency;
10409
10575
  const collectedFailures = [];
10410
10576
  const diagnostics = yield* Effect.tryPromise({
10411
10577
  try: () => runOxlint({
@@ -10424,7 +10590,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
10424
10590
  },
10425
10591
  onFileProgress: input.onFileProgress,
10426
10592
  spawnTimeoutMs,
10427
- outputMaxBytes
10593
+ outputMaxBytes,
10594
+ concurrency
10428
10595
  }),
10429
10596
  catch: ensureReactDoctorError
10430
10597
  });
@@ -10748,7 +10915,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10748
10915
  const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
10749
10916
  yield* beforeLint(project, lintIncludePaths ?? void 0);
10750
10917
  const isDiffMode = input.includePaths.length > 0;
10751
- const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? false;
10918
+ const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
10752
10919
  const transform = buildDiagnosticPipeline({
10753
10920
  rootDirectory: scanDirectory,
10754
10921
  userConfig: resolvedConfig.config,
@@ -10773,6 +10940,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10773
10940
  didFail: false,
10774
10941
  reason: null
10775
10942
  });
10943
+ const scanConcurrency = yield* OxlintConcurrency;
10944
+ const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
10776
10945
  const scanProgress = yield* progressService.start("Scanning...");
10777
10946
  const scanStartTime = Date.now();
10778
10947
  let lastReportedTotalFileCount = 0;
@@ -10789,7 +10958,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10789
10958
  configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
10790
10959
  onFileProgress: (scannedFileCount, totalFileCount) => {
10791
10960
  lastReportedTotalFileCount = totalFileCount;
10792
- Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
10961
+ Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
10793
10962
  }
10794
10963
  }).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
10795
10964
  yield* Ref.set(lintFailure, {
@@ -10821,7 +10990,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10821
10990
  const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
10822
10991
  if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
10823
10992
  else if (input.suppressScanSummary) yield* scanProgress.stop();
10824
- else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
10993
+ else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
10825
10994
  yield* reporterService.finalize;
10826
10995
  const finalDiagnostics = [
10827
10996
  ...envCollected,
@@ -10873,7 +11042,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
10873
11042
  "inspect.isCi": input.isCi,
10874
11043
  "inspect.scoreSurface": input.scoreSurface ?? "score"
10875
11044
  } }));
10876
- Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
10877
11045
  const parseNodeVersion = (versionString) => {
10878
11046
  const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
10879
11047
  return {
@@ -11196,6 +11364,26 @@ const buildJsonReport = (input) => {
11196
11364
  };
11197
11365
  };
11198
11366
  /**
11367
+ * Single source of truth for the skipped-check accounting shared by the
11368
+ * CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
11369
+ * programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
11370
+ * failed lint / dead-code pass instead of a false "all clear", so the
11371
+ * branch logic lives here once.
11372
+ */
11373
+ const buildSkippedChecks = (input) => {
11374
+ const skippedChecks = [];
11375
+ if (input.didLintFail) skippedChecks.push("lint");
11376
+ if (input.didDeadCodeFail) skippedChecks.push("dead-code");
11377
+ const skippedCheckReasons = {};
11378
+ if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
11379
+ else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
11380
+ if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
11381
+ return {
11382
+ skippedChecks,
11383
+ skippedCheckReasons
11384
+ };
11385
+ };
11386
+ /**
11199
11387
  * Programmatic façade over `Git.diffSelection`. Async because the
11200
11388
  * Git service runs through Effect's `ChildProcess` (true subprocess
11201
11389
  * spawn, not `spawnSync`).
@@ -11300,12 +11488,32 @@ const highlighter = {
11300
11488
  bold: import_picocolors.default.bold
11301
11489
  };
11302
11490
  /**
11303
- * Canonical URL for a rule's reviewer-tested fix recipe, served at
11304
- * `https://www.react.doctor/prompts/rules/<plugin>/<rule>.md`. The
11305
- * `/doctor` playbook fetches it on demand so each fix follows the
11306
- * canonical recipe instead of being improvised per diagnostic.
11491
+ * Override picocolors' automatic color detection. picocolors decides
11492
+ * once, at import time, from `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY.
11493
+ * This lets the CLI honor an explicit `--color` / `--no-color` flag
11494
+ * (clig.dev, Output: "Disable color if the user requested it") by
11495
+ * swapping in a fresh set of formatters. Call it before any colored
11496
+ * output is produced. Every call site reads `highlighter.<method>` at
11497
+ * call time, so reassigning the properties propagates everywhere.
11498
+ */
11499
+ const setColorEnabled = (enabled) => {
11500
+ const colors = import_picocolors.default.createColors(enabled);
11501
+ highlighter.error = colors.red;
11502
+ highlighter.warn = colors.yellow;
11503
+ highlighter.info = colors.cyan;
11504
+ highlighter.success = colors.green;
11505
+ highlighter.dim = colors.dim;
11506
+ highlighter.gray = colors.gray;
11507
+ highlighter.bold = colors.bold;
11508
+ };
11509
+ /**
11510
+ * Canonical URL for a rule's documentation page — its reviewer-tested fix
11511
+ * recipe rendered for humans — served at
11512
+ * `https://www.react.doctor/docs/rules/<plugin>/<rule>`. The CLI links here
11513
+ * from its fix-recipe directive so each fix follows the canonical recipe
11514
+ * instead of being improvised per diagnostic.
11307
11515
  */
11308
- const buildRulePromptUrl = (plugin, rule) => `${PROMPTS_RULES_BASE_URL}/${plugin}/${rule}.md`;
11516
+ const buildRuleDocsUrl = (plugin, rule) => `${DOCS_RULES_BASE_URL}/${plugin}/${rule}`;
11309
11517
  const groupBy = (items, keyFn) => {
11310
11518
  const groups = /* @__PURE__ */ new Map();
11311
11519
  for (const item of items) {
@@ -11318,8 +11526,8 @@ const groupBy = (items, keyFn) => {
11318
11526
  };
11319
11527
  /**
11320
11528
  * Whether a diagnostic's rule has a published per-rule fix recipe at
11321
- * `${PROMPTS_RULES_BASE_URL}/react-doctor/<rule>.md`
11322
- * (see `buildRulePromptUrl`).
11529
+ * `${DOCS_RULES_BASE_URL}/react-doctor/<rule>`
11530
+ * (see `buildRuleDocsUrl`).
11323
11531
  *
11324
11532
  * Recipes are generated from react-doctor's own engine rules, so only
11325
11533
  * those resolve. Dead-code (`deslop`), the synthetic environment and
@@ -11331,6 +11539,46 @@ const groupBy = (items, keyFn) => {
11331
11539
  */
11332
11540
  const hasPublishedFixRecipe = (diagnostic) => diagnostic.plugin === "react-doctor" && Object.hasOwn(reactDoctorPlugin.rules, diagnostic.rule);
11333
11541
  //#endregion
11542
+ //#region src/cli/utils/constants.ts
11543
+ const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
11544
+ const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
11545
+ const SENTRY_DSN = "https://f253d570240a59b8dbd77b7a548ef133@o4510226365743104.ingest.us.sentry.io/4511487817809920";
11546
+ //#endregion
11547
+ //#region src/cli/utils/version.ts
11548
+ const VERSION = "0.2.14-dev.5976266";
11549
+ //#endregion
11550
+ //#region src/instrument.ts
11551
+ let isInitialized = false;
11552
+ const shouldEnableSentry = () => {
11553
+ if (process.argv.includes("--no-score") || process.argv.includes("--no-telemetry")) return false;
11554
+ if (process.env.VITEST || process.env.NODE_ENV === "test") return false;
11555
+ return true;
11556
+ };
11557
+ /**
11558
+ * Initializes the Sentry Node SDK for CLI crash reporting. Invoked as
11559
+ * the first statement of the CLI entry (`cli/index.ts`) so the SDK's
11560
+ * global `uncaughtException` / `unhandledRejection` handlers are armed
11561
+ * before any command runs.
11562
+ *
11563
+ * Exported as a function rather than a bare side-effecting import
11564
+ * because the package declares `"sideEffects": false`, which lets the
11565
+ * bundler tree-shake side-effect-only modules. An explicit call keeps
11566
+ * the initialization in the published `dist/cli.js`.
11567
+ *
11568
+ * Scoped to the CLI application only — the programmatic
11569
+ * `@react-doctor/api` library never initializes Sentry, so importing
11570
+ * `diagnose()` into a consumer app can't hijack their telemetry.
11571
+ */
11572
+ const initializeSentry = () => {
11573
+ if (isInitialized || !shouldEnableSentry()) return;
11574
+ isInitialized = true;
11575
+ Sentry.init({
11576
+ dsn: SENTRY_DSN,
11577
+ sendDefaultPii: true,
11578
+ release: VERSION
11579
+ });
11580
+ };
11581
+ //#endregion
11334
11582
  //#region ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
11335
11583
  const ANSI_BACKGROUND_OFFSET = 10;
11336
11584
  const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
@@ -14284,23 +14532,60 @@ const CI_ENVIRONMENT_VARIABLES = [
14284
14532
  "GITLAB_CI",
14285
14533
  "CIRCLECI"
14286
14534
  ];
14287
- const CODING_AGENT_ENVIRONMENT_VARIABLES = [
14288
- "CLAUDECODE",
14289
- "CLAUDE_CODE",
14290
- "CURSOR_AGENT",
14291
- "CODEX_CI",
14292
- "CODEX_SANDBOX",
14293
- "CODEX_SANDBOX_NETWORK_DISABLED",
14294
- "OPENCODE",
14295
- "GOOSE_TERMINAL",
14296
- "AGENT_SESSION_ID",
14297
- "AMP_THREAD_ID",
14298
- "AGENT_THREAD_ID"
14535
+ const CI_PROVIDER_BY_ENVIRONMENT_VARIABLE = [
14536
+ ["GITHUB_ACTIONS", "github-actions"],
14537
+ ["GITLAB_CI", "gitlab-ci"],
14538
+ ["CIRCLECI", "circleci"],
14539
+ ["BUILDKITE", "buildkite"],
14540
+ ["JENKINS_URL", "jenkins"],
14541
+ ["TF_BUILD", "azure-pipelines"],
14542
+ ["CODEBUILD_BUILD_ID", "aws-codebuild"],
14543
+ ["TEAMCITY_VERSION", "teamcity"],
14544
+ ["BITBUCKET_BUILD_NUMBER", "bitbucket"],
14545
+ ["TRAVIS", "travis"],
14546
+ ["DRONE", "drone"]
14547
+ ];
14548
+ const CODING_AGENT_BY_ENVIRONMENT_VARIABLE = [
14549
+ ["CLAUDECODE", "claude-code"],
14550
+ ["CLAUDE_CODE", "claude-code"],
14551
+ ["CURSOR_AGENT", "cursor"],
14552
+ ["CODEX_CI", "codex"],
14553
+ ["CODEX_SANDBOX", "codex"],
14554
+ ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
14555
+ ["OPENCODE", "opencode"],
14556
+ ["GOOSE_TERMINAL", "goose"],
14557
+ ["AMP_THREAD_ID", "amp"]
14299
14558
  ];
14559
+ const GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES = ["AGENT_SESSION_ID", "AGENT_THREAD_ID"];
14300
14560
  const CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES = ["AGENT"];
14301
14561
  const CODING_AGENT_ENVIRONMENT_VALUES = { AGENT: ["amp", "goose"] };
14302
- const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
14303
- 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));
14562
+ [...CODING_AGENT_BY_ENVIRONMENT_VARIABLE.map(([environmentVariable]) => environmentVariable), ...GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES];
14563
+ const FALSY_CI_FLAG_VALUES = new Set([
14564
+ "",
14565
+ "0",
14566
+ "false"
14567
+ ]);
14568
+ const isCiFlagSet = (value) => value !== void 0 && !FALSY_CI_FLAG_VALUES.has(value.toLowerCase());
14569
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((environmentVariable) => Boolean(process.env[environmentVariable])) || isCiFlagSet(process.env.CI);
14570
+ const detectCiProvider = () => {
14571
+ for (const [environmentVariable, provider] of CI_PROVIDER_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return provider;
14572
+ return isCiFlagSet(process.env.CI) ? "unknown" : null;
14573
+ };
14574
+ const detectCodingAgentFromValue = () => {
14575
+ for (const environmentVariable of CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES) {
14576
+ const value = process.env[environmentVariable]?.toLowerCase();
14577
+ if (value && CODING_AGENT_ENVIRONMENT_VALUES[environmentVariable].includes(value)) return value;
14578
+ }
14579
+ return null;
14580
+ };
14581
+ const detectCodingAgent = () => {
14582
+ for (const [environmentVariable, agent] of CODING_AGENT_BY_ENVIRONMENT_VARIABLE) if (process.env[environmentVariable]) return agent;
14583
+ const agentFromValue = detectCodingAgentFromValue();
14584
+ if (agentFromValue) return agentFromValue;
14585
+ if (GENERIC_CODING_AGENT_ENVIRONMENT_VARIABLES.some((environmentVariable) => process.env[environmentVariable])) return "unknown";
14586
+ return null;
14587
+ };
14588
+ const isCodingAgentEnvironment = () => detectCodingAgent() !== null;
14304
14589
  const isCiOrCodingAgentEnvironment = () => isCiEnvironment() || isCodingAgentEnvironment();
14305
14590
  //#endregion
14306
14591
  //#region src/cli/utils/is-non-interactive-environment.ts
@@ -14402,9 +14687,8 @@ const buildSpinnerProgressHandle = (text) => {
14402
14687
  * construction and post-scan rendering — layer wiring is its own
14403
14688
  * concern with its own contract.
14404
14689
  *
14405
- * Same shape as `core/src/run-inspect.tslayerInspectLive`
14406
- * (the default for `@react-doctor/api diagnose()`) with the
14407
- * differences specific to the CLI path:
14690
+ * Same service shape as `@react-doctor/apidiagnose()`'s
14691
+ * `buildDiagnoseLayer`, with the differences specific to the CLI path:
14408
14692
  *
14409
14693
  * - **Config**: when the caller passes `configOverride`, the
14410
14694
  * already-loaded config is provided via `Config.layerOf` instead
@@ -14430,7 +14714,8 @@ const buildRuntimeLayers = (input) => {
14430
14714
  resolvedDirectory: input.directory,
14431
14715
  configSourceDirectory: input.configSourceDirectory
14432
14716
  }) : Config.layerNode;
14433
- return Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
14717
+ const baseLayers = Layer.mergeAll(Project.layerNode, configLayer, Files.layerNode, Git.layerNode, linterLayer, LintPartialFailures.layerLive, deadCodeLayer, progressLayer, Reporter.layerNoop, scoreLayer);
14718
+ return input.oxlintConcurrency === void 0 ? baseLayers : Layer.mergeAll(baseLayers, Layer.succeed(OxlintConcurrency, input.oxlintConcurrency));
14434
14719
  };
14435
14720
  //#endregion
14436
14721
  //#region src/cli/utils/noop-console.ts
@@ -14517,8 +14802,10 @@ const compareByRulePriority = (ruleKeyA, ruleKeyB, rulePriority) => {
14517
14802
  return priorityB - priorityA;
14518
14803
  };
14519
14804
  const sortRuleGroupsByImportance = (diagnosticGroups, rulePriority) => diagnosticGroups.toSorted(([ruleKeyA], [ruleKeyB]) => compareByRulePriority(ruleKeyA, ruleKeyB, rulePriority));
14520
- const FETCH_FIX_RECIPE_LABEL = "Fetch & follow the canonical fix recipe before fixing";
14521
- const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FETCH_FIX_RECIPE_LABEL}: ${buildRulePromptUrl(diagnostic.plugin, diagnostic.rule)}` : null;
14805
+ const buildSortedRuleGroups = (diagnostics, rulePriority) => sortRuleGroupsByImportance([...groupBy([...diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)], rulePriority);
14806
+ const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing";
14807
+ const formatFixRecipeLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `${FIX_RECIPE_DIRECTIVE_LABEL}: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
14808
+ const formatLearnMoreLine = (diagnostic) => hasPublishedFixRecipe(diagnostic) ? `Learn more: ${buildRuleDocsUrl(diagnostic.plugin, diagnostic.rule)}` : null;
14522
14809
  //#endregion
14523
14810
  //#region src/cli/utils/box-text.ts
14524
14811
  const ESCAPE = String.fromCharCode(27);
@@ -14649,15 +14936,17 @@ const buildVerboseSiteMap = (diagnostics) => {
14649
14936
  return fileSites;
14650
14937
  };
14651
14938
  const formatSiteCountBadge = (count) => count > 1 ? `×${count}` : "";
14939
+ const formatTrailingSiteBadge = (count) => {
14940
+ const badge = formatSiteCountBadge(count);
14941
+ return badge.length > 0 ? ` ${highlighter.gray(badge)}` : "";
14942
+ };
14652
14943
  const categoryTopRuleKey = (categoryGroup) => categoryGroup.ruleGroups[0][0];
14653
14944
  const buildCategoryDiagnosticGroups = (diagnostics, rulePriority) => {
14654
- return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => {
14655
- return {
14656
- category,
14657
- diagnostics: categoryDiagnostics,
14658
- ruleGroups: sortRuleGroupsByImportance([...groupBy(categoryDiagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority)
14659
- };
14660
- }).toSorted((categoryGroupA, categoryGroupB) => {
14945
+ return [...groupBy(diagnostics, (diagnostic) => diagnostic.category).entries()].map(([category, categoryDiagnostics]) => ({
14946
+ category,
14947
+ diagnostics: categoryDiagnostics,
14948
+ ruleGroups: buildSortedRuleGroups(categoryDiagnostics, rulePriority)
14949
+ })).toSorted((categoryGroupA, categoryGroupB) => {
14661
14950
  const priorityDelta = compareByRulePriority(categoryTopRuleKey(categoryGroupA), categoryTopRuleKey(categoryGroupB), rulePriority);
14662
14951
  if (priorityDelta !== 0) return priorityDelta;
14663
14952
  return categoryGroupA.category.localeCompare(categoryGroupB.category);
@@ -14673,6 +14962,7 @@ const buildCompactCategoryLine = (categoryGroup) => {
14673
14962
  };
14674
14963
  const TOP_ERROR_DETAIL_INDENT = " ";
14675
14964
  const pickRepresentativeDiagnostic = (ruleDiagnostics) => ruleDiagnostics.find((diagnostic) => diagnostic.line > 0) ?? ruleDiagnostics[0];
14965
+ const isErrorRuleGroup = (ruleDiagnostics) => pickRepresentativeDiagnostic(ruleDiagnostics).severity === "error";
14676
14966
  const FRAME_CONTEXT_REACH_LINES = 3;
14677
14967
  const clusterNearbyDiagnostics = (diagnostics) => {
14678
14968
  const byFile = groupBy(diagnostics, (diagnostic) => diagnostic.filePath);
@@ -14704,17 +14994,17 @@ const formatClusterLocation = (cluster) => {
14704
14994
  if (cluster.endLine > cluster.startLine) return `${filePath}:${cluster.startLine}-${cluster.endLine}`;
14705
14995
  return `${filePath}:${cluster.startLine}`;
14706
14996
  };
14707
- const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
14997
+ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot, renderCodeFrame) => {
14708
14998
  const lead = cluster.diagnostics[0];
14709
14999
  const isMultiSite = cluster.diagnostics.length > 1;
14710
15000
  const lines = ["", highlighter.gray(`${TOP_ERROR_DETAIL_INDENT}${formatClusterLocation(cluster)}`)];
14711
- const codeFrame = buildCodeFrame({
15001
+ const codeFrame = renderCodeFrame ? buildCodeFrame({
14712
15002
  filePath: lead.filePath,
14713
15003
  line: cluster.startLine,
14714
15004
  column: isMultiSite ? 0 : lead.column,
14715
15005
  endLine: isMultiSite ? cluster.endLine : void 0,
14716
15006
  rootDirectory: resolveSourceRoot(lead)
14717
- });
15007
+ }) : null;
14718
15008
  if (codeFrame) lines.push(indentMultilineText(boxText(codeFrame, 60), TOP_ERROR_DETAIL_INDENT));
14719
15009
  const seenHints = /* @__PURE__ */ new Set();
14720
15010
  for (const diagnostic of cluster.diagnostics) if (diagnostic.suppressionHint && !seenHints.has(diagnostic.suppressionHint)) {
@@ -14726,23 +15016,60 @@ const buildDiagnosticClusterLines = (cluster, resolveSourceRoot) => {
14726
15016
  const buildRuleDetailBlock = (ruleKey, ruleDiagnostics, resolveSourceRoot, renderEverySite) => {
14727
15017
  const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
14728
15018
  const { severity } = representative;
14729
- const siteCountBadge = formatSiteCountBadge(ruleDiagnostics.length);
14730
- const trailingBadge = siteCountBadge.length > 0 ? ` ${highlighter.gray(siteCountBadge)}` : "";
15019
+ const trailingBadge = formatTrailingSiteBadge(ruleDiagnostics.length);
14731
15020
  const headline = colorizeBySeverity(`${representative.category}: ${representative.title ?? ruleKey}`, severity);
14732
15021
  const lines = [` ${colorizeBySeverity(severity === "error" ? "✗" : "⚠", severity)} ${headline}${trailingBadge}`];
14733
15022
  if (!renderEverySite) for (const explanationLine of wrapTextToWidth(representative.message, 60, { breakLongWords: false })) lines.push(`${TOP_ERROR_DETAIL_INDENT}${explanationLine}`);
14734
15023
  if (representative.help) for (const fixLine of wrapTextToWidth(`→ ${representative.help}`, 60, { breakLongWords: false })) lines.push(highlighter.dim(`${TOP_ERROR_DETAIL_INDENT}${fixLine}`));
15024
+ const renderCodeFrame = severity === "error";
14735
15025
  const sites = renderEverySite ? ruleDiagnostics : [representative];
14736
- for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot));
15026
+ for (const cluster of clusterNearbyDiagnostics(sites)) lines.push(...buildDiagnosticClusterLines(cluster, resolveSourceRoot, renderCodeFrame));
15027
+ return lines;
15028
+ };
15029
+ const WARNING_DETAIL_INDENT = " ";
15030
+ const computeRuleNameColumnWidth = (ruleKeys) => ruleKeys.reduce((widest, ruleKey) => Math.max(widest, ruleKey.length), 36);
15031
+ const padRuleNameToColumn = (ruleName, columnWidth) => ruleName.length >= columnWidth ? ruleName : ruleName + " ".repeat(columnWidth - ruleName.length);
15032
+ const buildWarningHeaderLine = (ruleKey, siteCount, ruleNameColumnWidth) => {
15033
+ const ruleName = formatSiteCountBadge(siteCount).length > 0 ? padRuleNameToColumn(ruleKey, ruleNameColumnWidth) : ruleKey;
15034
+ return ` ${highlighter.warn("⚠")} ${ruleName}${formatTrailingSiteBadge(siteCount)}`;
15035
+ };
15036
+ const buildWarningRuleBlock = (ruleKey, ruleDiagnostics, ruleNameColumnWidth, isAgentEnvironment) => {
15037
+ const representative = pickRepresentativeDiagnostic(ruleDiagnostics);
15038
+ const lines = [buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth)];
15039
+ if (!isAgentEnvironment) {
15040
+ const learnMoreLine = formatLearnMoreLine(representative);
15041
+ if (learnMoreLine) lines.push(`${WARNING_DETAIL_INDENT}${highlighter.info(learnMoreLine)}`);
15042
+ }
15043
+ lines.push(highlighter.gray(indentMultilineText(representative.message, WARNING_DETAIL_INDENT)));
15044
+ if (representative.help) lines.push(highlighter.gray(indentMultilineText(`→ ${representative.help}`, WARNING_DETAIL_INDENT)));
15045
+ if (isAgentEnvironment) {
15046
+ const fixRecipeLine = formatFixRecipeLine(representative);
15047
+ if (fixRecipeLine) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${fixRecipeLine}`));
15048
+ }
15049
+ for (const [filePath, sites] of buildVerboseSiteMap(ruleDiagnostics)) {
15050
+ if (sites.length === 0) {
15051
+ lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}`));
15052
+ continue;
15053
+ }
15054
+ for (const site of sites) {
15055
+ lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT}${filePath}:${site.line}`));
15056
+ if (site.suppressionHint) lines.push(highlighter.gray(`${WARNING_DETAIL_INDENT} ↳ ${site.suppressionHint}`));
15057
+ }
15058
+ }
14737
15059
  return lines;
14738
15060
  };
14739
- const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => {
14740
- return sortRuleGroupsByImportance([...groupBy(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority).slice(0, limit);
15061
+ const selectErrorRuleGroups = (diagnostics, rulePriority) => buildSortedRuleGroups(diagnostics.filter((diagnostic) => diagnostic.severity === "error"), rulePriority);
15062
+ const selectTopErrorRuleGroups = (diagnostics, limit, rulePriority) => selectErrorRuleGroups(diagnostics, rulePriority).slice(0, limit);
15063
+ const buildMoreRulesLine = (hiddenRuleCount, severityNoun, accent) => {
15064
+ const ruleNoun = hiddenRuleCount === 1 ? "rule" : "rules";
15065
+ return ` ${highlighter.bold(accent(`+${hiddenRuleCount} more ${ruleNoun}`))} ${highlighter.dim("— run")} ${highlighter.bold(highlighter.info("--verbose"))} ${highlighter.dim(`to view the rest of the ${severityNoun} and details about each`)}`;
14741
15066
  };
14742
15067
  const getTopErrorRuleKeys = (diagnostics, limit, rulePriority) => new Set(selectTopErrorRuleGroups(diagnostics, limit, rulePriority).map(([ruleKey]) => ruleKey));
14743
15068
  const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
14744
- const topRuleGroups = selectTopErrorRuleGroups(diagnostics, 3, rulePriority);
15069
+ const errorRuleGroups = selectErrorRuleGroups(diagnostics, rulePriority);
15070
+ const topRuleGroups = errorRuleGroups.slice(0, 3);
14745
15071
  if (topRuleGroups.length === 0) return [];
15072
+ const hiddenRuleCount = errorRuleGroups.length - topRuleGroups.length;
14746
15073
  const lines = [
14747
15074
  highlighter.dim(` ${"─".repeat(60)}`),
14748
15075
  ` ${highlighter.bold(`Top ${topRuleGroups.length} ${topRuleGroups.length === 1 ? "error" : "errors"} you should fix`)}`,
@@ -14752,6 +15079,23 @@ const buildTopErrorsLines = (diagnostics, resolveSourceRoot, rulePriority) => {
14752
15079
  lines.push(...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, false));
14753
15080
  lines.push("");
14754
15081
  }
15082
+ if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "errors", highlighter.error));
15083
+ return lines;
15084
+ };
15085
+ const buildWarningsListLines = (diagnostics, rulePriority) => {
15086
+ const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
15087
+ if (warningDiagnostics.length === 0) return [];
15088
+ const sortedRuleGroups = buildSortedRuleGroups(warningDiagnostics, rulePriority);
15089
+ const shownRuleGroups = sortedRuleGroups.slice(0, 10);
15090
+ const hiddenRuleCount = sortedRuleGroups.length - shownRuleGroups.length;
15091
+ const ruleNameColumnWidth = computeRuleNameColumnWidth(shownRuleGroups.map(([ruleKey]) => ruleKey));
15092
+ const lines = [
15093
+ highlighter.dim(` ${"─".repeat(60)}`),
15094
+ ` ${highlighter.bold(`${warningDiagnostics.length} ${warningDiagnostics.length === 1 ? "warning" : "warnings"}`)}`,
15095
+ ""
15096
+ ];
15097
+ for (const [ruleKey, ruleDiagnostics] of shownRuleGroups) lines.push(buildWarningHeaderLine(ruleKey, ruleDiagnostics.length, ruleNameColumnWidth));
15098
+ if (hiddenRuleCount > 0) lines.push(buildMoreRulesLine(hiddenRuleCount, "warnings", highlighter.warn));
14755
15099
  return lines;
14756
15100
  };
14757
15101
  const buildCategoryBreakdownLines = (diagnostics, rulePriority) => buildCategoryDiagnosticGroups(diagnostics, rulePriority).map(buildCompactCategoryLine);
@@ -14778,12 +15122,18 @@ const buildCountsSummaryLines = (diagnostics) => {
14778
15122
  * single Effect.forEach over Console.log so failures or fiber
14779
15123
  * interruption produce predictable partial output.
14780
15124
  */
14781
- const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority) => Effect.gen(function* () {
15125
+ const printDiagnostics = (diagnostics, isVerbose, sourceRoot, rulePriority, isAgentEnvironment = false) => Effect.gen(function* () {
14782
15126
  const resolveSourceRoot = typeof sourceRoot === "function" ? sourceRoot : () => sourceRoot;
14783
15127
  let detailLines;
14784
15128
  if (!isVerbose) detailLines = buildTopErrorsLines(diagnostics, resolveSourceRoot, rulePriority);
14785
- else detailLines = sortRuleGroupsByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()], rulePriority).flatMap(([ruleKey, ruleDiagnostics]) => [...buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true), ""]);
14786
- const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines);
15129
+ else {
15130
+ const sortedRuleGroups = buildSortedRuleGroups(diagnostics, rulePriority);
15131
+ const warningRuleNameColumnWidth = computeRuleNameColumnWidth(sortedRuleGroups.filter(([, ruleDiagnostics]) => !isErrorRuleGroup(ruleDiagnostics)).map(([ruleKey]) => ruleKey));
15132
+ detailLines = sortedRuleGroups.flatMap(([ruleKey, ruleDiagnostics]) => {
15133
+ return [...isErrorRuleGroup(ruleDiagnostics) ? buildRuleDetailBlock(ruleKey, ruleDiagnostics, resolveSourceRoot, true) : buildWarningRuleBlock(ruleKey, ruleDiagnostics, warningRuleNameColumnWidth, isAgentEnvironment), ""];
15134
+ });
15135
+ }
15136
+ const lines = joinSections(buildCategoryBreakdownLines(diagnostics, rulePriority), buildCountsSummaryLines(diagnostics), detailLines, isVerbose ? [] : buildWarningsListLines(diagnostics, rulePriority));
14787
15137
  for (const line of lines) yield* Console.log(line);
14788
15138
  });
14789
15139
  const formatElapsedTime = (elapsedMilliseconds) => {
@@ -14833,10 +15183,6 @@ const colorizeByScore = (text, score) => {
14833
15183
  return highlighter.error(text);
14834
15184
  };
14835
15185
  //#endregion
14836
- //#region src/cli/utils/constants.ts
14837
- const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-";
14838
- const INTERNAL_ERROR_JSON_FALLBACK = "{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n";
14839
- //#endregion
14840
15186
  //#region src/cli/utils/render-score-header.ts
14841
15187
  const RAINBOW_HUE_SHIFT_PER_FRAME = 9;
14842
15188
  const RAINBOW_GRADIENT_WIDTH = 80;
@@ -15029,8 +15375,7 @@ const printNoScoreHeader = (noScoreMessage) => Effect.gen(function* () {
15029
15375
  const writeDiagnosticsDirectory = (diagnostics) => {
15030
15376
  const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
15031
15377
  mkdirSync(outputDirectory, { recursive: true });
15032
- const sortedRuleGroups = sortRuleGroupsByImportance([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
15033
- for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
15378
+ for (const [ruleKey, ruleDiagnostics] of buildSortedRuleGroups(diagnostics)) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
15034
15379
  writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
15035
15380
  return outputDirectory;
15036
15381
  };
@@ -15050,7 +15395,14 @@ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
15050
15395
  };
15051
15396
  const printVerboseTip = (diagnostics, isVerbose) => Effect.gen(function* () {
15052
15397
  if (isVerbose || diagnostics.length === 0) return;
15053
- yield* Console.log(highlighter.dim(` Tip: Run ${highlighter.info("npx react-doctor@latest --verbose")} to list every issue`));
15398
+ const command = highlighter.info("npx react-doctor@latest --verbose");
15399
+ const message = diagnostics.some((diagnostic) => diagnostic.severity === "warning") ? `Run ${command} to see each warning explained with its fix` : `Run ${command} to see each issue explained with its fix`;
15400
+ yield* Console.log(highlighter.dim(` Tip: ${message}`));
15401
+ });
15402
+ const printDocsNote = () => Effect.gen(function* () {
15403
+ yield* Console.log("");
15404
+ yield* Console.log(` ${highlighter.bold("Docs:")} ${highlighter.info(DOCS_URL)}`);
15405
+ yield* Console.log(highlighter.dim(" Set up CI/CD, suppress rules with a config file, and scan diffs or PRs."));
15054
15406
  });
15055
15407
  const printSummary = (input) => Effect.gen(function* () {
15056
15408
  if (input.scoreResult) {
@@ -15221,9 +15573,6 @@ const resolveOxlintNodeEffect = (isLintEnabled, isQuiet) => Effect.gen(function*
15221
15573
  });
15222
15574
  const resolveOxlintNode = (isLintEnabled, isQuiet) => Effect.runPromise(resolveOxlintNodeEffect(isLintEnabled, isQuiet).pipe(Effect.provide(NodeResolver.layerNode)));
15223
15575
  //#endregion
15224
- //#region src/cli/utils/version.ts
15225
- const VERSION = "0.2.14-dev.4bc8a73";
15226
- //#endregion
15227
15576
  //#region src/inspect.ts
15228
15577
  const silentConsole = makeNoopConsole();
15229
15578
  const runConsole = (effect) => {
@@ -15248,11 +15597,12 @@ const mergeInspectOptions = (inputOptions, userConfig) => ({
15248
15597
  customRulesOnly: userConfig?.customRulesOnly ?? false,
15249
15598
  share: userConfig?.share ?? true,
15250
15599
  respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true,
15251
- warnings: inputOptions.warnings ?? userConfig?.warnings ?? false,
15600
+ warnings: inputOptions.warnings ?? userConfig?.warnings ?? true,
15252
15601
  adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
15253
15602
  ignoredTags: buildIgnoredTags(userConfig),
15254
15603
  outputSurface: inputOptions.outputSurface ?? "cli",
15255
- suppressRendering: inputOptions.suppressRendering ?? false
15604
+ suppressRendering: inputOptions.suppressRendering ?? false,
15605
+ concurrency: inputOptions.concurrency
15256
15606
  });
15257
15607
  const inspect = async (directory, inputOptions = {}) => {
15258
15608
  const startTime = performance.now();
@@ -15265,7 +15615,7 @@ const inspect = async (directory, inputOptions = {}) => {
15265
15615
  userConfig = inputOptions.configOverride ?? null;
15266
15616
  configSourceDirectory = null;
15267
15617
  } else {
15268
- const scanTarget = resolveScanTarget(directory);
15618
+ const scanTarget = await resolveScanTarget(directory);
15269
15619
  scanDirectory = scanTarget.resolvedDirectory;
15270
15620
  userConfig = scanTarget.userConfig;
15271
15621
  configSourceDirectory = scanTarget.configSourceDirectory;
@@ -15292,7 +15642,8 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
15292
15642
  shouldSkipLint: !options.lint || lintBindingMissing,
15293
15643
  shouldRunDeadCode: options.deadCode,
15294
15644
  shouldComputeScore: !options.noScore,
15295
- shouldShowProgressSpinners
15645
+ shouldShowProgressSpinners,
15646
+ oxlintConcurrency: options.concurrency
15296
15647
  });
15297
15648
  const program = runInspect({
15298
15649
  directory,
@@ -15348,15 +15699,15 @@ const runInspectWithRuntime = async (directory, options, userConfig, hasConfigOv
15348
15699
  };
15349
15700
  const finalizeAndRender = (input) => Effect.gen(function* () {
15350
15701
  const { options, elapsedMilliseconds, diagnostics, score, project, userConfig, didLintFail, lintFailureReason, lintPartialFailures, didDeadCodeFail, deadCodeFailureReason, directory, scannedFileCount, scannedFilePaths, scanElapsedMilliseconds } = input;
15351
- const skippedChecks = [];
15352
- if (didLintFail) skippedChecks.push("lint");
15353
- if (didDeadCodeFail) skippedChecks.push("dead-code");
15702
+ const { skippedChecks, skippedCheckReasons } = buildSkippedChecks({
15703
+ didLintFail,
15704
+ lintFailureReason,
15705
+ lintPartialFailures,
15706
+ didDeadCodeFail,
15707
+ deadCodeFailureReason
15708
+ });
15354
15709
  const hasSkippedChecks = skippedChecks.length > 0;
15355
15710
  const noScoreMessage = buildNoScoreMessage(options.noScore);
15356
- const skippedCheckReasons = {};
15357
- if (didLintFail && lintFailureReason !== null) skippedCheckReasons.lint = lintFailureReason;
15358
- else if (lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = lintPartialFailures.join("; ");
15359
- if (didDeadCodeFail && deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = deadCodeFailureReason;
15360
15711
  const buildResult = () => ({
15361
15712
  diagnostics: [...diagnostics],
15362
15713
  score,
@@ -15392,7 +15743,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
15392
15743
  return buildResult();
15393
15744
  }
15394
15745
  yield* Console.log("");
15395
- yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]));
15746
+ yield* printDiagnostics([...surfaceDiagnostics], options.verbose, directory, buildRulePriorityMap([score]), isCodingAgentEnvironment());
15396
15747
  if (options.isNonInteractiveEnvironment && options.outputSurface !== "prComment") yield* printAgentGuidance();
15397
15748
  if (demotedDiagnosticCount > 0) {
15398
15749
  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.`));
@@ -15417,6 +15768,7 @@ const finalizeAndRender = (input) => Effect.gen(function* () {
15417
15768
  yield* Console.warn(highlighter.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`));
15418
15769
  }
15419
15770
  yield* printVerboseTip([...surfaceDiagnostics], options.verbose);
15771
+ yield* printDocsNote();
15420
15772
  return buildResult();
15421
15773
  });
15422
15774
  //#endregion
@@ -15510,6 +15862,7 @@ const handleErrorEffect = (error) => Effect.gen(function* () {
15510
15862
  yield* Console.error("");
15511
15863
  yield* Console.error(highlighter.error("Something went wrong. Please check the error below for more details."));
15512
15864
  yield* Console.error(highlighter.error(`If the problem persists, please open this prefilled issue: ${buildErrorIssueUrl(error)}`));
15865
+ yield* Console.error(highlighter.error(`You can also ask for help in Discord: ${CANONICAL_DISCORD_URL}`));
15513
15866
  yield* Console.error("");
15514
15867
  yield* Console.error(highlighter.error(formatErrorForReport(error)));
15515
15868
  yield* Console.error("");
@@ -15527,7 +15880,7 @@ const handleError = (error, options = { shouldExit: true }) => {
15527
15880
  //#endregion
15528
15881
  //#region src/cli/utils/build-handoff-payload.ts
15529
15882
  const buildHandoffPayload = (input) => {
15530
- const topGroups = sortRuleGroupsByImportance([...groupBy([...input.diagnostics], (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`)]).slice(0, 3);
15883
+ const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, 3);
15531
15884
  let diagnosticsDirectory = null;
15532
15885
  try {
15533
15886
  diagnosticsDirectory = writeDiagnosticsDirectory([...input.diagnostics]);
@@ -15770,7 +16123,7 @@ const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json";
15770
16123
  const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.sh";
15771
16124
  const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch";
15772
16125
  const CURSOR_HOOKS_SCHEMA_VERSION = 1;
15773
- const JSON_INDENT_SPACES = 2;
16126
+ const JSON_INDENT_SPACES$1 = 2;
15774
16127
  const isSupportedAgent = (agent) => agent === CLAUDE_AGENT || agent === CURSOR_AGENT;
15775
16128
  const readJsonFile = (filePath, fallback) => {
15776
16129
  if (!existsSync(filePath)) return fallback;
@@ -15780,7 +16133,7 @@ const readJsonFile = (filePath, fallback) => {
15780
16133
  };
15781
16134
  const writeJsonFile = (filePath, value) => {
15782
16135
  mkdirSync(path.dirname(filePath), { recursive: true });
15783
- writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES)}\n`);
16136
+ writeFileSync(filePath, `${JSON.stringify(value, null, JSON_INDENT_SPACES$1)}\n`);
15784
16137
  };
15785
16138
  const writeHookScript = (filePath) => {
15786
16139
  mkdirSync(path.dirname(filePath), { recursive: true });
@@ -16546,6 +16899,29 @@ const getSkillSourceDirectory = () => {
16546
16899
  const distDirectory = path.dirname(fileURLToPath(import.meta.url));
16547
16900
  return path.join(distDirectory, "skills", SKILL_NAME);
16548
16901
  };
16902
+ const findBundledSiblingSkills = (primarySkillDir) => {
16903
+ const skillsParent = path.dirname(primarySkillDir);
16904
+ if (!existsSync(skillsParent)) return [];
16905
+ const resolvedPrimary = path.resolve(primarySkillDir);
16906
+ return readdirSync(skillsParent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => ({
16907
+ name: entry.name,
16908
+ source: path.join(skillsParent, entry.name)
16909
+ })).filter((sibling) => path.resolve(sibling.source) !== resolvedPrimary && existsSync(path.join(sibling.source, SKILL_MANIFEST_FILE)));
16910
+ };
16911
+ const installBundledSiblingSkills = async (primarySkillDir, agents, projectRoot) => {
16912
+ const installedSkillNames = [];
16913
+ for (const sibling of findBundledSiblingSkills(primarySkillDir)) {
16914
+ const result = await installSkillsFromSource({
16915
+ source: sibling.source,
16916
+ agents: [...agents],
16917
+ cwd: projectRoot,
16918
+ mode: "copy"
16919
+ });
16920
+ if (result.failed.length > 0) throw new Error(result.failed.map((failure) => `${getSkillAgentConfig(failure.agent).displayName}: ${failure.error}`).join("\n"));
16921
+ if (result.skills.length > 0) installedSkillNames.push(sibling.name);
16922
+ }
16923
+ return installedSkillNames;
16924
+ };
16549
16925
  const canInstallNativeAgentHooks = (agents) => agents.some((agent) => agent === "claude-code" || agent === "cursor");
16550
16926
  const buildWorkflowContent = () => [
16551
16927
  "name: React Doctor",
@@ -16652,6 +17028,7 @@ const runInstallReactDoctor = async (options = {}) => {
16652
17028
  cliLogger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
16653
17029
  for (const agent of selectedAgents) cliLogger.dim(` - ${getSkillAgentConfig(agent).displayName}`);
16654
17030
  cliLogger.dim(` Source: ${sourceDir}`);
17031
+ for (const sibling of findBundledSiblingSkills(sourceDir)) cliLogger.dim(` Also installs skill: ${sibling.name}`);
16655
17032
  cliLogger.dim(" Package script: doctor (or react-doctor if doctor exists)");
16656
17033
  cliLogger.dim(" Dev dependency: react-doctor");
16657
17034
  if (shouldInstallGitHook) cliLogger.dim(` Git hook: ${gitHookPath}`);
@@ -16674,6 +17051,12 @@ const runInstallReactDoctor = async (options = {}) => {
16674
17051
  installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
16675
17052
  throw error;
16676
17053
  }
17054
+ try {
17055
+ const installedSiblingSkills = await installBundledSiblingSkills(sourceDir, selectedAgents, projectRoot);
17056
+ if (installedSiblingSkills.length > 0) cliLogger.dim(` Also installed the ${installedSiblingSkills.join(", ")} skill.`);
17057
+ } catch {
17058
+ cliLogger.dim(" Skipped bundled sibling skills (install error).");
17059
+ }
16677
17060
  await installReactDoctorPackageSetup(projectRoot, options.installDependencyRunner);
16678
17061
  if (shouldInstallGitHook && gitHookTarget !== null && gitHookTarget !== void 0) {
16679
17062
  const hookSpinner = spinner("Installing React Doctor pre-commit hook...").start();
@@ -16859,6 +17242,70 @@ const handoffToAgent = async (input) => {
16859
17242
  }
16860
17243
  };
16861
17244
  //#endregion
17245
+ //#region src/cli/utils/read-object-file.ts
17246
+ /**
17247
+ * Reads a JSON / JSONC file as a plain object, or `null` when it is missing,
17248
+ * unparseable, or not an object. JSON5 parsing tolerates comments and
17249
+ * trailing commas so hand-edited config files round-trip.
17250
+ */
17251
+ const readObjectFile = (filePath) => {
17252
+ try {
17253
+ const parsed = parseJSON5(readFileSync(filePath, "utf-8"));
17254
+ return isPlainObject(parsed) ? parsed : null;
17255
+ } catch {
17256
+ return null;
17257
+ }
17258
+ };
17259
+ //#endregion
17260
+ //#region src/cli/utils/serialize-ts-object-literal.ts
17261
+ const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
17262
+ const INDENT_UNIT = " ";
17263
+ const serializeKey = (key) => SAFE_IDENTIFIER_PATTERN.test(key) ? key : JSON.stringify(key);
17264
+ /**
17265
+ * Serializes a JSON-compatible value as an idiomatic TypeScript literal:
17266
+ * identifier-shaped object keys stay unquoted, two-space indented, no blank
17267
+ * lines. Intended for JSON-sourced config values (string / number / boolean /
17268
+ * null / array / plain object); any other type falls back to its JSON form.
17269
+ */
17270
+ const serializeTsObjectLiteral = (value, depth = 0) => {
17271
+ const indent = INDENT_UNIT.repeat(depth);
17272
+ const childIndent = INDENT_UNIT.repeat(depth + 1);
17273
+ if (Array.isArray(value)) {
17274
+ if (value.length === 0) return "[]";
17275
+ return `[\n${value.map((item) => `${childIndent}${serializeTsObjectLiteral(item, depth + 1)}`).join(",\n")}\n${indent}]`;
17276
+ }
17277
+ if (isPlainObject(value)) {
17278
+ const keys = Object.keys(value);
17279
+ if (keys.length === 0) return "{}";
17280
+ return `{\n${keys.map((key) => `${childIndent}${serializeKey(key)}: ${serializeTsObjectLiteral(value[key], depth + 1)}`).join(",\n")}\n${indent}}`;
17281
+ }
17282
+ return JSON.stringify(value);
17283
+ };
17284
+ //#endregion
17285
+ //#region src/cli/utils/migrate-legacy-config.ts
17286
+ const MIGRATED_CONFIG_FILENAME = "doctor.config.ts";
17287
+ /**
17288
+ * Renames a pre-migration `react-doctor.config.json` to a typed
17289
+ * `doctor.config.ts`, preserving the user's settings as the default export.
17290
+ * `$schema` is dropped — the `ReactDoctorConfig` type supersedes it for
17291
+ * editor autocomplete. Returns the new file's absolute path, or `null` when
17292
+ * the legacy file can't be parsed as an object (left untouched so the user
17293
+ * can resolve it by hand).
17294
+ */
17295
+ const migrateLegacyConfig = (legacy) => {
17296
+ const parsed = readObjectFile(legacy.legacyFilePath);
17297
+ if (!parsed) return null;
17298
+ const config = { ...parsed };
17299
+ delete config.$schema;
17300
+ const targetPath = path.join(legacy.directory, MIGRATED_CONFIG_FILENAME);
17301
+ writeFileSync(targetPath, `import type { ReactDoctorConfig } from "react-doctor/api";
17302
+
17303
+ export default ${serializeTsObjectLiteral(config)} satisfies ReactDoctorConfig;
17304
+ `);
17305
+ rmSync(legacy.legacyFilePath, { force: true });
17306
+ return targetPath;
17307
+ };
17308
+ //#endregion
16862
17309
  //#region src/cli/utils/json-mode.ts
16863
17310
  let context = null;
16864
17311
  /**
@@ -16959,6 +17406,78 @@ const printBrandedHeader = Effect.gen(function* () {
16959
17406
  yield* Console.log("");
16960
17407
  });
16961
17408
  //#endregion
17409
+ //#region src/cli/utils/build-run-context.ts
17410
+ const ROOT_SUBCOMMANDS = new Set(["install", "setup"]);
17411
+ const detectOrigin = () => {
17412
+ if (process.env.GIT_DIR) return "git-hook";
17413
+ if (isCodingAgentEnvironment()) return "agent";
17414
+ if (isCiEnvironment()) return "ci";
17415
+ return "cli";
17416
+ };
17417
+ const detectCommand = (userArguments) => {
17418
+ for (const argument of userArguments) {
17419
+ if (argument === "--") break;
17420
+ if (argument.startsWith("-")) continue;
17421
+ return ROOT_SUBCOMMANDS.has(argument) ? argument : "inspect";
17422
+ }
17423
+ return "inspect";
17424
+ };
17425
+ /**
17426
+ * Snapshot of the current invocation, attached to Sentry events as the
17427
+ * `run` context to make crashes triage-able (which version, platform,
17428
+ * CI/agent, how it was invoked). Every field is cheap, synchronous, and
17429
+ * safe to read at any point — cwd reads fall back, env reads are
17430
+ * booleans — so it's rebuilt lazily at capture time when runtime-only
17431
+ * signals like `jsonMode` are finally known.
17432
+ */
17433
+ const buildRunContext = () => {
17434
+ const userArguments = process.argv.slice(2);
17435
+ return {
17436
+ version: VERSION,
17437
+ origin: detectOrigin(),
17438
+ command: detectCommand(userArguments),
17439
+ argv: userArguments.join(" "),
17440
+ cwd: process.cwd(),
17441
+ node: process.version,
17442
+ platform: process.platform,
17443
+ arch: process.arch,
17444
+ ci: isCiEnvironment(),
17445
+ ciProvider: detectCiProvider(),
17446
+ codingAgent: detectCodingAgent(),
17447
+ interactive: !isNonInteractiveEnvironment(),
17448
+ jsonMode: isJsonModeActive()
17449
+ };
17450
+ };
17451
+ //#endregion
17452
+ //#region src/cli/utils/report-error.ts
17453
+ /**
17454
+ * Sends an error to Sentry, enriched with a snapshot of the current run
17455
+ * (version, platform, CI/agent, invocation), and waits for delivery
17456
+ * before the caller exits. The CLI tears down the process synchronously
17457
+ * after rendering an error, so the awaited `flush` is what actually gets
17458
+ * the event off the machine (see the Sentry CLI/serverless flush
17459
+ * contract).
17460
+ *
17461
+ * Returns early when Sentry was never initialized (`--no-score`, tests,
17462
+ * or a missing DSN), and swallows any transport failure so telemetry can
17463
+ * never mask the user's original error.
17464
+ */
17465
+ const reportErrorToSentry = async (error) => {
17466
+ if (!Sentry.isInitialized()) return;
17467
+ try {
17468
+ const runContext = buildRunContext();
17469
+ Sentry.setContext("run", { ...runContext });
17470
+ Sentry.setTags({
17471
+ origin: runContext.origin,
17472
+ command: runContext.command,
17473
+ ciProvider: runContext.ciProvider,
17474
+ codingAgent: runContext.codingAgent
17475
+ });
17476
+ Sentry.captureException(error);
17477
+ await Sentry.flush(2e3);
17478
+ } catch {}
17479
+ };
17480
+ //#endregion
16962
17481
  //#region src/cli/utils/path-format.ts
16963
17482
  const toForwardSlashes = (filePath) => filePath.replaceAll("\\", "/");
16964
17483
  //#endregion
@@ -17026,7 +17545,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
17026
17545
  yield* Console.log(`${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalScanElapsedMilliseconds)}`);
17027
17546
  if (surfaceDiagnostics.length > 0) {
17028
17547
  yield* Console.log("");
17029
- yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)));
17548
+ yield* printDiagnostics(surfaceDiagnostics, verbose, resolveDiagnosticSourceRoot, buildRulePriorityMap(completedScans.map((scan) => scan.result.score)), isCodingAgentEnvironment());
17030
17549
  }
17031
17550
  const lowestScoredScan = findLowestScoredScan(completedScans);
17032
17551
  const aggregateScore = lowestScoredScan?.result.score ?? null;
@@ -17056,6 +17575,7 @@ const printMultiProjectSummary = (input) => Effect.gen(function* () {
17056
17575
  for (const entry of entries) yield* Console.log(buildSummaryLine(entry, longestProjectNameLength));
17057
17576
  yield* Console.log("");
17058
17577
  yield* printVerboseTip(surfaceDiagnostics, verbose);
17578
+ yield* printDocsNote();
17059
17579
  });
17060
17580
  //#endregion
17061
17581
  //#region src/cli/utils/prompt-install-setup.ts
@@ -17103,6 +17623,34 @@ const printAgentInstallHint = (writeLine = defaultWriteLine) => {
17103
17623
  for (const line of AGENT_INSTALL_HINT_LINES) writeLine(line);
17104
17624
  };
17105
17625
  //#endregion
17626
+ //#region src/cli/utils/resolve-parallel-flag.ts
17627
+ /**
17628
+ * Translates the `--experimental-parallel [workers]` flag into a concrete
17629
+ * worker count for `InspectOptions.concurrency`:
17630
+ *
17631
+ * - flag absent (`undefined`) → `undefined` (defer to the ambient
17632
+ * default: serial unless `REACT_DOCTOR_PARALLEL` is set)
17633
+ * - bare flag / `auto` → auto-detect CPU cores
17634
+ * - `--experimental-parallel <n>` → `n` workers (clamped)
17635
+ * - `false` / `off` / `0` → serial (an explicit opt-out, so
17636
+ * it overrides an env-enabled default rather than deferring to it)
17637
+ * - an unparseable value → auto-detect cores
17638
+ *
17639
+ * Commander yields `true` for a bare flag, the raw string for an explicit
17640
+ * value, and `undefined` when the flag is omitted.
17641
+ */
17642
+ const resolveParallelFlag = (parallel) => {
17643
+ if (parallel === void 0) return void 0;
17644
+ if (parallel === true) return resolveScanConcurrency("auto");
17645
+ if (parallel === false) return 1;
17646
+ const normalized = parallel.trim().toLowerCase();
17647
+ if (normalized === "" || normalized === "auto" || normalized === "true") return resolveScanConcurrency("auto");
17648
+ if (normalized === "false" || normalized === "off" || normalized === "0") return 1;
17649
+ const parsed = Number.parseInt(normalized, 10);
17650
+ if (!Number.isInteger(parsed) || parsed <= 0) return resolveScanConcurrency("auto");
17651
+ return resolveScanConcurrency(parsed);
17652
+ };
17653
+ //#endregion
17106
17654
  //#region src/cli/utils/resolve-cli-inspect-options.ts
17107
17655
  /**
17108
17656
  * Translates CLI flags into the `InspectOptions` contract `inspect()`
@@ -17125,10 +17673,11 @@ const resolveCliInspectOptions = (flags, userConfig) => {
17125
17673
  respectInlineDisables: flags.respectInlineDisables,
17126
17674
  warnings: flags.warnings ?? (wantsWarningGate ? true : void 0),
17127
17675
  scoreOnly: flags.score === true,
17128
- noScore: flags.score === false || (userConfig?.noScore ?? false),
17676
+ noScore: flags.score === false || flags.telemetry === false || (userConfig?.noScore ?? false),
17129
17677
  isCi: isCiEnvironment(),
17130
17678
  silent: Boolean(flags.json),
17131
- outputSurface: flags.prComment ? "prComment" : "cli"
17679
+ outputSurface: flags.prComment ? "prComment" : "cli",
17680
+ concurrency: resolveParallelFlag(flags.experimentalParallel)
17132
17681
  };
17133
17682
  };
17134
17683
  //#endregion
@@ -17393,6 +17942,7 @@ const validateModeFlags = (flags) => {
17393
17942
  if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
17394
17943
  if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
17395
17944
  if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
17945
+ if (flags.score && flags.telemetry === false) throw new Error("Cannot combine --score with --no-telemetry; --score prints the score that --no-telemetry disables.");
17396
17946
  if (flags.prComment && (flags.json || flags.score)) throw new Error("--pr-comment cannot be combined with --json or --score.");
17397
17947
  if (flags.annotations && flags.score) throw new Error("--annotations cannot be combined with --score.");
17398
17948
  if (flags.explain !== void 0 && flags.why !== void 0) throw new Error("Use --explain or --why, not both — they're aliases of the same flag.");
@@ -17426,6 +17976,24 @@ const buildChangedFilesDiffInfo = (changedFiles) => ({
17426
17976
  changedFiles,
17427
17977
  isCurrentChanges: false
17428
17978
  });
17979
+ /**
17980
+ * On an interactive human run, rename a pre-migration
17981
+ * `react-doctor.config.json` to `doctor.config.ts` before config is loaded,
17982
+ * so the scan reads the renamed file and the user is told once. CI, coding
17983
+ * agents, JSON/score output, pre-commit (`--staged`) hooks, and non-TTY runs
17984
+ * are left untouched — the loader's warning still nudges them — so a scan
17985
+ * never mutates the repo unattended.
17986
+ */
17987
+ const maybeMigrateLegacyConfig = (requestedDirectory, { isQuiet, isStaged }) => {
17988
+ if (!(!isQuiet && !isStaged && process.stdout.isTTY === true && !isCiOrCodingAgentEnvironment())) return;
17989
+ const legacyConfig = findLegacyConfig(requestedDirectory);
17990
+ if (!legacyConfig) return;
17991
+ const migratedPath = migrateLegacyConfig(legacyConfig);
17992
+ if (!migratedPath) return;
17993
+ cliLogger.success("Migrated react-doctor.config.json → doctor.config.ts");
17994
+ cliLogger.dim(` Your settings were preserved. Review ${toRelativePath(migratedPath, requestedDirectory)} and commit it.`);
17995
+ cliLogger.break();
17996
+ };
17429
17997
  const inspectAction = async (directory, flags) => {
17430
17998
  const isScoreOnly = Boolean(flags.score);
17431
17999
  const isJsonMode = Boolean(flags.json);
@@ -17438,7 +18006,11 @@ const inspectAction = async (directory, flags) => {
17438
18006
  });
17439
18007
  try {
17440
18008
  validateModeFlags(flags);
17441
- const scanTarget = resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
18009
+ maybeMigrateLegacyConfig(requestedDirectory, {
18010
+ isQuiet,
18011
+ isStaged: Boolean(flags.staged)
18012
+ });
18013
+ const scanTarget = await resolveScanTarget(requestedDirectory, { allowAmbiguous: true });
17442
18014
  const userConfig = scanTarget.userConfig;
17443
18015
  const resolvedDirectory = scanTarget.resolvedDirectory;
17444
18016
  setJsonReportDirectory(resolvedDirectory);
@@ -17612,6 +18184,7 @@ const inspectAction = async (directory, flags) => {
17612
18184
  })) printAgentInstallHint();
17613
18185
  }
17614
18186
  } catch (error) {
18187
+ await reportErrorToSentry(error);
17615
18188
  if (isJsonMode) {
17616
18189
  writeJsonErrorReport(error);
17617
18190
  process.exitCode = 1;
@@ -17633,10 +18206,573 @@ const installAction = async (options, command) => {
17633
18206
  projectRoot: options.cwd ?? process.cwd()
17634
18207
  });
17635
18208
  } catch (error) {
18209
+ await reportErrorToSentry(error);
17636
18210
  handleError(error);
17637
18211
  }
17638
18212
  };
17639
18213
  //#endregion
18214
+ //#region src/cli/utils/rule-catalog.ts
18215
+ const buildRuleCatalog = () => REACT_DOCTOR_RULES.map((entry) => ({
18216
+ key: entry.key,
18217
+ id: entry.id,
18218
+ category: entry.rule.category ?? "Other",
18219
+ defaultSeverity: entry.rule.severity,
18220
+ framework: entry.rule.framework ?? "global",
18221
+ tags: entry.rule.tags ?? [],
18222
+ recommendation: entry.rule.recommendation,
18223
+ defaultEnabled: entry.rule.defaultEnabled !== false
18224
+ }));
18225
+ /**
18226
+ * Resolves a user-supplied rule reference to a catalog entry. Accepts the
18227
+ * fully-qualified key (`react-doctor/no-danger`), the bare id (`no-danger`),
18228
+ * and legacy plugin keys (`react/no-danger`) via the shared alias map.
18229
+ */
18230
+ const findRuleInCatalog = (catalog, ruleQuery) => {
18231
+ const normalizedQuery = ruleQuery.trim();
18232
+ if (normalizedQuery.length === 0) return void 0;
18233
+ const directMatch = catalog.find((entry) => entry.key === normalizedQuery || entry.id === normalizedQuery);
18234
+ if (directMatch) return directMatch;
18235
+ return catalog.find((entry) => isSameRuleKey(entry.key, normalizedQuery));
18236
+ };
18237
+ const listRuleCategories = (catalog) => [...new Set(catalog.map((entry) => entry.category))].sort();
18238
+ const listRuleTags = (catalog) => [...new Set(catalog.flatMap((entry) => [...entry.tags]))].sort();
18239
+ //#endregion
18240
+ //#region src/cli/utils/render-rule-catalog.ts
18241
+ const SEVERITY_COLUMN_WIDTH_CHARS = 6;
18242
+ const colorizeSeverity = (severity, text) => {
18243
+ if (severity === "error") return highlighter.error(text);
18244
+ if (severity === "warn") return highlighter.warn(text);
18245
+ return highlighter.gray(text);
18246
+ };
18247
+ const formatSourceNote = (effective) => effective.source === "default" ? highlighter.dim("(default)") : highlighter.dim(`(${effective.source})`);
18248
+ const renderRuleCatalog = (rows) => {
18249
+ if (rows.length === 0) return highlighter.dim("No rules match the given filters.");
18250
+ const rowsByCategory = /* @__PURE__ */ new Map();
18251
+ for (const row of rows) {
18252
+ const bucket = rowsByCategory.get(row.entry.category) ?? [];
18253
+ bucket.push(row);
18254
+ rowsByCategory.set(row.entry.category, bucket);
18255
+ }
18256
+ const lines = [];
18257
+ for (const category of [...rowsByCategory.keys()].sort()) {
18258
+ const categoryRows = (rowsByCategory.get(category) ?? []).sort((leftRow, rightRow) => leftRow.entry.key.localeCompare(rightRow.entry.key));
18259
+ lines.push(highlighter.bold(`${category} ${highlighter.dim(`(${categoryRows.length})`)}`));
18260
+ for (const row of categoryRows) {
18261
+ const severityBadge = colorizeSeverity(row.effective.value, row.effective.value.padEnd(SEVERITY_COLUMN_WIDTH_CHARS));
18262
+ const tagSuffix = row.entry.tags.length > 0 ? highlighter.dim(` [${row.entry.tags.join(", ")}]`) : "";
18263
+ lines.push(` ${severityBadge} ${row.entry.key} ${formatSourceNote(row.effective)}${tagSuffix}`);
18264
+ }
18265
+ lines.push("");
18266
+ }
18267
+ lines.push(highlighter.dim(`${rows.length} rule${rows.length === 1 ? "" : "s"} shown.`));
18268
+ return lines.join("\n");
18269
+ };
18270
+ const DETAIL_LABEL_COLUMN_WIDTH_CHARS = 18;
18271
+ const formatDetailRow = (label, value) => ` ${highlighter.dim(label.padEnd(DETAIL_LABEL_COLUMN_WIDTH_CHARS))}${value}`;
18272
+ const renderRuleExplanation = (row) => {
18273
+ const { entry, effective } = row;
18274
+ const lines = [highlighter.bold(entry.key), ""];
18275
+ lines.push(formatDetailRow("Category", entry.category));
18276
+ lines.push(formatDetailRow("Default severity", entry.defaultSeverity));
18277
+ lines.push(formatDetailRow("Current severity", `${colorizeSeverity(effective.value, effective.value)} ${formatSourceNote(effective)}`));
18278
+ lines.push(formatDetailRow("Framework", entry.framework));
18279
+ lines.push(formatDetailRow("Tags", entry.tags.length > 0 ? entry.tags.join(", ") : "none"));
18280
+ lines.push(formatDetailRow("Default enabled", entry.defaultEnabled ? "yes" : "no (opt-in)"));
18281
+ lines.push("");
18282
+ lines.push(highlighter.bold("Why it matters"));
18283
+ lines.push(` ${entry.recommendation ?? "No additional guidance recorded for this rule yet."}`);
18284
+ lines.push("");
18285
+ lines.push(highlighter.bold("Configure"));
18286
+ lines.push(highlighter.dim(` react-doctor rules disable ${entry.key}`));
18287
+ lines.push(highlighter.dim(` react-doctor rules enable ${entry.key} --severity error`));
18288
+ lines.push(highlighter.dim(` react-doctor rules set ${entry.key} warn`));
18289
+ lines.push("");
18290
+ lines.push(highlighter.bold("Learn more"));
18291
+ lines.push(highlighter.dim(` ${buildRuleDocsUrl("react-doctor", entry.id)}`));
18292
+ return lines.join("\n");
18293
+ };
18294
+ //#endregion
18295
+ //#region src/cli/utils/rule-config-file.ts
18296
+ const NEW_CONFIG_FILENAME = "doctor.config.json";
18297
+ const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
18298
+ const JSON_INDENT_SPACES = 2;
18299
+ const MANAGED_KEYS = [
18300
+ "rules",
18301
+ "categories",
18302
+ "ignore"
18303
+ ];
18304
+ /**
18305
+ * Decides where a rule-config mutation should be written. Discovery
18306
+ * reuses `loadConfigWithSource` (the loader the scan uses) so edits land
18307
+ * in the file the scan reads — `doctor.config.{ts,js,…}` is preferred,
18308
+ * then `package.json#reactDoctor`. When nothing exists, a fresh
18309
+ * `doctor.config.json` is targeted at `projectRoot`. Data configs are
18310
+ * re-read raw so unrelated fields round-trip untouched.
18311
+ */
18312
+ const resolveRuleConfigTarget = async (projectRoot) => {
18313
+ clearConfigCache();
18314
+ const loaded = await loadConfigWithSource(projectRoot);
18315
+ if (loaded) {
18316
+ if (loaded.format === "package-json") {
18317
+ const embedded = (readObjectFile(loaded.configFilePath) ?? {})[PACKAGE_JSON_CONFIG_KEY];
18318
+ return {
18319
+ format: "package-json",
18320
+ filePath: loaded.configFilePath,
18321
+ directory: loaded.sourceDirectory,
18322
+ exists: true,
18323
+ config: isPlainObject(embedded) ? embedded : {}
18324
+ };
18325
+ }
18326
+ if (loaded.format === "json") return {
18327
+ format: "json",
18328
+ filePath: loaded.configFilePath,
18329
+ directory: loaded.sourceDirectory,
18330
+ exists: true,
18331
+ config: readObjectFile(loaded.configFilePath) ?? {}
18332
+ };
18333
+ return {
18334
+ format: "module",
18335
+ filePath: loaded.configFilePath,
18336
+ directory: loaded.sourceDirectory,
18337
+ exists: true,
18338
+ config: loaded.config
18339
+ };
18340
+ }
18341
+ return {
18342
+ format: "json",
18343
+ filePath: path.join(projectRoot, NEW_CONFIG_FILENAME),
18344
+ directory: projectRoot,
18345
+ exists: false,
18346
+ config: {}
18347
+ };
18348
+ };
18349
+ const writeJsonConfig = (filePath, nextConfig) => {
18350
+ const { $schema, ...rest } = nextConfig;
18351
+ writeFileSync(filePath, `${JSON.stringify({
18352
+ $schema: $schema ?? "https://react.doctor/schema/config.json",
18353
+ ...rest
18354
+ }, null, JSON_INDENT_SPACES)}\n`);
18355
+ };
18356
+ const writePackageJsonConfig = (filePath, nextConfig) => {
18357
+ const packageJson = readObjectFile(filePath) ?? {};
18358
+ writeFileSync(filePath, `${JSON.stringify({
18359
+ ...packageJson,
18360
+ [PACKAGE_JSON_CONFIG_KEY]: nextConfig
18361
+ }, null, JSON_INDENT_SPACES)}\n`);
18362
+ };
18363
+ const syncManagedKeys = (target, nextConfig) => {
18364
+ for (const key of MANAGED_KEYS) {
18365
+ const value = nextConfig[key];
18366
+ if (value === void 0) {
18367
+ if (target[key] !== void 0) delete target[key];
18368
+ } else target[key] = value;
18369
+ }
18370
+ };
18371
+ const assignNodeSource = (owner, key, code) => {
18372
+ owner[key] = code;
18373
+ };
18374
+ const editVariableDeclarationConfig = (declaration, config, nextConfig) => {
18375
+ syncManagedKeys(config, nextConfig);
18376
+ const initializer = declaration.init;
18377
+ if (!initializer) return false;
18378
+ const generatedSource = generateCode(config).code;
18379
+ if (initializer.type === "ObjectExpression") {
18380
+ assignNodeSource(declaration, "init", generatedSource);
18381
+ return true;
18382
+ }
18383
+ if (initializer.type === "TSSatisfiesExpression" && initializer.expression.type === "ObjectExpression") {
18384
+ assignNodeSource(initializer, "expression", generatedSource);
18385
+ return true;
18386
+ }
18387
+ return false;
18388
+ };
18389
+ const writeModuleConfig = async (filePath, nextConfig) => {
18390
+ try {
18391
+ const module = await loadFile(filePath);
18392
+ if (module.exports.default?.$type === "identifier") {
18393
+ const { declaration, config } = getConfigFromVariableDeclaration(module);
18394
+ if (!config || !editVariableDeclarationConfig(declaration, config, nextConfig)) return false;
18395
+ } else syncManagedKeys(getDefaultExportOptions(module), nextConfig);
18396
+ await writeFile(module, filePath);
18397
+ return true;
18398
+ } catch {
18399
+ return false;
18400
+ }
18401
+ };
18402
+ const writeRuleConfig = async (target, nextConfig) => {
18403
+ if (target.format === "module") {
18404
+ const written = await writeModuleConfig(target.filePath, nextConfig);
18405
+ if (written) clearConfigCache();
18406
+ return { written };
18407
+ }
18408
+ if (target.format === "package-json") writePackageJsonConfig(target.filePath, nextConfig);
18409
+ else writeJsonConfig(target.filePath, nextConfig);
18410
+ clearConfigCache();
18411
+ return { written: true };
18412
+ };
18413
+ //#endregion
18414
+ //#region src/cli/utils/resolve-effective-rule-severity.ts
18415
+ /**
18416
+ * Resolves what a rule will actually do under the current config without
18417
+ * running a scan. `ignore.tags` is a pre-lint gate: a rule carrying an
18418
+ * ignored tag is dropped (via `shouldEnableRule`) before any severity is
18419
+ * read, so it wins over every override. Among rules that survive the gate,
18420
+ * the scanner's order is `rules` > `categories` > `buckets` > the registry
18421
+ * default.
18422
+ */
18423
+ const resolveEffectiveRuleSeverity = (config, entry) => {
18424
+ const ignoredTags = config?.ignore?.tags ?? [];
18425
+ if (entry.tags.some((tag) => ignoredTags.includes(tag))) return {
18426
+ value: "off",
18427
+ source: "tag"
18428
+ };
18429
+ const ruleOverrides = config?.rules ?? {};
18430
+ for (const equivalentKey of getEquivalentRuleKeys(entry.key)) {
18431
+ const override = ruleOverrides[equivalentKey];
18432
+ if (override !== void 0) return {
18433
+ value: override,
18434
+ source: "rule"
18435
+ };
18436
+ }
18437
+ const categoryOverride = config?.categories?.[entry.category];
18438
+ if (categoryOverride !== void 0) return {
18439
+ value: categoryOverride,
18440
+ source: "category"
18441
+ };
18442
+ if (COMPILER_CLEANUP_RULE_KEYS.has(entry.key)) {
18443
+ const bucketOverride = config?.buckets?.[COMPILER_CLEANUP_BUCKET];
18444
+ if (bucketOverride !== void 0) return {
18445
+ value: bucketOverride,
18446
+ source: "bucket"
18447
+ };
18448
+ }
18449
+ return {
18450
+ value: entry.defaultEnabled ? entry.defaultSeverity : "off",
18451
+ source: "default"
18452
+ };
18453
+ };
18454
+ //#endregion
18455
+ //#region src/cli/utils/update-rule-config.ts
18456
+ /**
18457
+ * Sets a per-rule severity, replacing any existing entry for the same
18458
+ * rule (including legacy-aliased keys, so a config still targeting
18459
+ * `react/no-danger` is rewritten to the canonical key instead of
18460
+ * leaving a dead duplicate).
18461
+ */
18462
+ const setRuleSeverity = (config, ruleKey, severity) => {
18463
+ const equivalentKeys = new Set(getEquivalentRuleKeys(ruleKey));
18464
+ const nextRules = {};
18465
+ for (const [existingKey, existingSeverity] of Object.entries(config.rules ?? {})) if (!equivalentKeys.has(existingKey)) nextRules[existingKey] = existingSeverity;
18466
+ nextRules[ruleKey] = severity;
18467
+ return {
18468
+ ...config,
18469
+ rules: nextRules
18470
+ };
18471
+ };
18472
+ const setCategorySeverity = (config, category, severity) => ({
18473
+ ...config,
18474
+ categories: {
18475
+ ...config.categories,
18476
+ [category]: severity
18477
+ }
18478
+ });
18479
+ const addIgnoredTag = (config, tag) => {
18480
+ const currentTags = config.ignore?.tags ?? [];
18481
+ if (currentTags.includes(tag)) return config;
18482
+ return {
18483
+ ...config,
18484
+ ignore: {
18485
+ ...config.ignore,
18486
+ tags: [...new Set([...currentTags, tag])].sort()
18487
+ }
18488
+ };
18489
+ };
18490
+ const removeIgnoredTag = (config, tag) => {
18491
+ const currentTags = config.ignore?.tags ?? [];
18492
+ if (!currentTags.includes(tag)) return config;
18493
+ const remainingTags = currentTags.filter((existingTag) => existingTag !== tag);
18494
+ const { tags: _removed, ...remainingIgnore } = config.ignore ?? {};
18495
+ if (remainingTags.length === 0) {
18496
+ if (Object.keys(remainingIgnore).length === 0) {
18497
+ const { ignore: _ignore, ...configWithoutIgnore } = config;
18498
+ return configWithoutIgnore;
18499
+ }
18500
+ return {
18501
+ ...config,
18502
+ ignore: remainingIgnore
18503
+ };
18504
+ }
18505
+ return {
18506
+ ...config,
18507
+ ignore: {
18508
+ ...remainingIgnore,
18509
+ tags: remainingTags
18510
+ }
18511
+ };
18512
+ };
18513
+ //#endregion
18514
+ //#region src/cli/commands/rules.ts
18515
+ const SEVERITY_VALUES = [
18516
+ "off",
18517
+ "warn",
18518
+ "error"
18519
+ ];
18520
+ const resolveProjectRoot = (options) => {
18521
+ const requestedDirectory = path.resolve(options.cwd ?? process.cwd());
18522
+ return findNearestPackageDirectory(requestedDirectory) ?? requestedDirectory;
18523
+ };
18524
+ const parseSeverity = (value) => SEVERITY_VALUES.includes(value) ? value : null;
18525
+ const reportInvalidSeverity = (value) => {
18526
+ cliLogger.error(`Invalid severity "${value}". Expected one of: ${SEVERITY_VALUES.join(", ")}.`);
18527
+ process.exitCode = 1;
18528
+ };
18529
+ const reportRuleNotFound = (ruleQuery) => {
18530
+ cliLogger.error(`Unknown rule "${ruleQuery}".`);
18531
+ cliLogger.dim(" Run `react-doctor rules list` to see every available rule.");
18532
+ process.exitCode = 1;
18533
+ };
18534
+ const describeTargetPath = (target) => {
18535
+ const relativePath = path.relative(process.cwd(), target.filePath);
18536
+ const displayPath = relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : target.filePath;
18537
+ return target.exists ? displayPath : `${displayPath} ${highlighter.dim("(created)")}`;
18538
+ };
18539
+ const applyConfigChange = async (options, change) => {
18540
+ const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
18541
+ const nextConfig = change(target.config);
18542
+ const { written } = await writeRuleConfig(target, nextConfig);
18543
+ return {
18544
+ target,
18545
+ nextConfig,
18546
+ written
18547
+ };
18548
+ };
18549
+ const reportManualEdit = (target, nextConfig) => {
18550
+ const managed = {};
18551
+ for (const key of [
18552
+ "rules",
18553
+ "categories",
18554
+ "ignore"
18555
+ ]) if (nextConfig[key] !== void 0) managed[key] = nextConfig[key];
18556
+ cliLogger.error(`Couldn't automatically edit ${describeTargetPath(target)} (dynamic config).`);
18557
+ cliLogger.dim(" Apply this to your config's default export, then re-run:");
18558
+ for (const line of JSON.stringify(managed, null, 2).split("\n")) cliLogger.dim(` ${line}`);
18559
+ process.exitCode = 1;
18560
+ };
18561
+ const rulesListAction = async (options) => {
18562
+ const catalog = buildRuleCatalog();
18563
+ const config = validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config);
18564
+ const categoryFilter = options.category?.toLowerCase();
18565
+ const frameworkFilter = options.framework?.toLowerCase();
18566
+ const rows = catalog.filter((entry) => {
18567
+ if (categoryFilter && entry.category.toLowerCase() !== categoryFilter) return false;
18568
+ if (frameworkFilter && entry.framework.toLowerCase() !== frameworkFilter) return false;
18569
+ if (options.tag && !entry.tags.includes(options.tag)) return false;
18570
+ return true;
18571
+ }).map((entry) => ({
18572
+ entry,
18573
+ effective: resolveEffectiveRuleSeverity(config, entry)
18574
+ })).filter((row) => options.configured ? row.effective.source !== "default" : true);
18575
+ if (options.json) {
18576
+ const payload = rows.map((row) => ({
18577
+ key: row.entry.key,
18578
+ id: row.entry.id,
18579
+ category: row.entry.category,
18580
+ framework: row.entry.framework,
18581
+ tags: row.entry.tags,
18582
+ defaultSeverity: row.entry.defaultSeverity,
18583
+ defaultEnabled: row.entry.defaultEnabled,
18584
+ severity: row.effective.value,
18585
+ source: row.effective.source
18586
+ }));
18587
+ cliLogger.log(JSON.stringify(payload, null, 2));
18588
+ return;
18589
+ }
18590
+ cliLogger.log(renderRuleCatalog(rows));
18591
+ };
18592
+ const rulesExplainAction = async (ruleQuery, options) => {
18593
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
18594
+ if (!entry) {
18595
+ reportRuleNotFound(ruleQuery);
18596
+ return;
18597
+ }
18598
+ const effective = resolveEffectiveRuleSeverity(validateConfigTypes((await resolveRuleConfigTarget(resolveProjectRoot(options))).config), entry);
18599
+ if (options.json) {
18600
+ cliLogger.log(JSON.stringify({
18601
+ key: entry.key,
18602
+ id: entry.id,
18603
+ category: entry.category,
18604
+ framework: entry.framework,
18605
+ tags: entry.tags,
18606
+ defaultSeverity: entry.defaultSeverity,
18607
+ defaultEnabled: entry.defaultEnabled,
18608
+ severity: effective.value,
18609
+ source: effective.source,
18610
+ recommendation: entry.recommendation ?? null,
18611
+ learnMoreUrl: buildRuleDocsUrl("react-doctor", entry.id)
18612
+ }, null, 2));
18613
+ return;
18614
+ }
18615
+ cliLogger.log(renderRuleExplanation({
18616
+ entry,
18617
+ effective
18618
+ }));
18619
+ };
18620
+ const setRuleSeverityAndReport = async (entry, severity, options) => {
18621
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => setRuleSeverity(config, entry.key, severity));
18622
+ if (!written) {
18623
+ reportManualEdit(target, nextConfig);
18624
+ return;
18625
+ }
18626
+ cliLogger.success(`Set ${entry.key} → ${severity}`);
18627
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
18628
+ };
18629
+ const rulesSetAction = async (ruleQuery, severityValue, options) => {
18630
+ const severity = parseSeverity(severityValue);
18631
+ if (!severity) {
18632
+ reportInvalidSeverity(severityValue);
18633
+ return;
18634
+ }
18635
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
18636
+ if (!entry) {
18637
+ reportRuleNotFound(ruleQuery);
18638
+ return;
18639
+ }
18640
+ await setRuleSeverityAndReport(entry, severity, options);
18641
+ };
18642
+ const rulesEnableAction = async (ruleQuery, options) => {
18643
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
18644
+ if (!entry) {
18645
+ reportRuleNotFound(ruleQuery);
18646
+ return;
18647
+ }
18648
+ if (options.severity === void 0) {
18649
+ await setRuleSeverityAndReport(entry, entry.defaultSeverity, options);
18650
+ return;
18651
+ }
18652
+ const severity = parseSeverity(options.severity);
18653
+ if (!severity) {
18654
+ reportInvalidSeverity(options.severity);
18655
+ return;
18656
+ }
18657
+ if (severity === "off") {
18658
+ cliLogger.error("`enable` cannot set a rule to off. Use `react-doctor rules disable` instead.");
18659
+ process.exitCode = 1;
18660
+ return;
18661
+ }
18662
+ await setRuleSeverityAndReport(entry, severity, options);
18663
+ };
18664
+ const rulesDisableAction = async (ruleQuery, options) => {
18665
+ const entry = findRuleInCatalog(buildRuleCatalog(), ruleQuery);
18666
+ if (!entry) {
18667
+ reportRuleNotFound(ruleQuery);
18668
+ return;
18669
+ }
18670
+ await setRuleSeverityAndReport(entry, "off", options);
18671
+ };
18672
+ const rulesCategoryAction = async (categoryQuery, severityValue, options) => {
18673
+ const severity = parseSeverity(severityValue);
18674
+ if (!severity) {
18675
+ reportInvalidSeverity(severityValue);
18676
+ return;
18677
+ }
18678
+ const knownCategories = listRuleCategories(buildRuleCatalog());
18679
+ const matchedCategory = knownCategories.find((category) => category.toLowerCase() === categoryQuery.toLowerCase());
18680
+ if (!matchedCategory) {
18681
+ cliLogger.error(`Unknown category "${categoryQuery}".`);
18682
+ cliLogger.dim(` Known categories: ${knownCategories.join(", ")}`);
18683
+ process.exitCode = 1;
18684
+ return;
18685
+ }
18686
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => setCategorySeverity(config, matchedCategory, severity));
18687
+ if (!written) {
18688
+ reportManualEdit(target, nextConfig);
18689
+ return;
18690
+ }
18691
+ cliLogger.success(`Set category "${matchedCategory}" → ${severity}`);
18692
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
18693
+ };
18694
+ const rulesIgnoreTagAction = async (tag, options) => {
18695
+ const knownTags = listRuleTags(buildRuleCatalog());
18696
+ if (!knownTags.includes(tag)) {
18697
+ cliLogger.error(`Unknown tag "${tag}".`);
18698
+ cliLogger.dim(` Known tags: ${knownTags.join(", ")}`);
18699
+ process.exitCode = 1;
18700
+ return;
18701
+ }
18702
+ const { target, nextConfig, written } = await applyConfigChange(options, (config) => addIgnoredTag(config, tag));
18703
+ if (!written) {
18704
+ reportManualEdit(target, nextConfig);
18705
+ return;
18706
+ }
18707
+ cliLogger.success(`Ignoring tag "${tag}" (rules with this tag are skipped before linting)`);
18708
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
18709
+ };
18710
+ const rulesUnignoreTagAction = async (tag, options) => {
18711
+ const target = await resolveRuleConfigTarget(resolveProjectRoot(options));
18712
+ if (!(target.config.ignore?.tags ?? []).includes(tag)) {
18713
+ cliLogger.dim(`Tag "${tag}" was not being ignored; nothing to change.`);
18714
+ return;
18715
+ }
18716
+ const nextConfig = removeIgnoredTag(target.config, tag);
18717
+ const { written } = await writeRuleConfig(target, nextConfig);
18718
+ if (!written) {
18719
+ reportManualEdit(target, nextConfig);
18720
+ return;
18721
+ }
18722
+ cliLogger.success(`Tag "${tag}" is no longer ignored`);
18723
+ cliLogger.dim(` Updated ${describeTargetPath(target)}`);
18724
+ };
18725
+ //#endregion
18726
+ //#region src/cli/commands/version.ts
18727
+ /**
18728
+ * oclif-style version line. 12-factor CLI Apps (#3, "What version am I
18729
+ * on?"): the `version` command is the primary place users grab debugging
18730
+ * info, so it carries the Node runtime and platform alongside the CLI
18731
+ * version. The `-v` / `-V` / `--version` flags stay terse (just the
18732
+ * number) so scripts can parse them.
18733
+ */
18734
+ const buildVersionString = () => `react-doctor/${VERSION} ${process.platform}-${process.arch} node-${process.version}`;
18735
+ const versionAction = () => {
18736
+ process.stdout.write(`${buildVersionString()}\n`);
18737
+ };
18738
+ //#endregion
18739
+ //#region src/cli/utils/apply-color-preference.ts
18740
+ /**
18741
+ * Resolve an explicit color preference from `--color` / `--no-color` or the
18742
+ * app-specific `REACT_DOCTOR_NO_COLOR` / `REACT_DOCTOR_FORCE_COLOR` env vars
18743
+ * (clig.dev Output; 12-factor #6), overriding picocolors' own
18744
+ * `NO_COLOR` / `FORCE_COLOR` / `TERM` / TTY detection. Flags win over env
18745
+ * vars; with neither set, picocolors' detection stands.
18746
+ *
18747
+ * A resolved preference is mirrored onto the standard `NO_COLOR` /
18748
+ * `FORCE_COLOR` env vars in addition to our picocolors highlighter, so
18749
+ * libraries with their own color stacks (the `ora` spinner, `prompts`)
18750
+ * honor it too rather than only the scan report. Scanning argv directly
18751
+ * (not Commander's parsed options) applies the preference before Commander
18752
+ * parses, so it reaches every later path. The scan stops at `--`.
18753
+ */
18754
+ const applyColorPreference = (argv, env = process.env) => {
18755
+ let enabled;
18756
+ for (const argument of argv) {
18757
+ if (argument === "--") break;
18758
+ if (argument === "--no-color") enabled = false;
18759
+ else if (argument === "--color") enabled = true;
18760
+ }
18761
+ if (enabled === void 0) {
18762
+ if (env.REACT_DOCTOR_NO_COLOR) enabled = false;
18763
+ else if (env.REACT_DOCTOR_FORCE_COLOR) enabled = true;
18764
+ }
18765
+ if (enabled === void 0) return;
18766
+ if (enabled) {
18767
+ env.FORCE_COLOR = "1";
18768
+ delete env.NO_COLOR;
18769
+ } else {
18770
+ env.NO_COLOR = "1";
18771
+ delete env.FORCE_COLOR;
18772
+ }
18773
+ setColorEnabled(enabled);
18774
+ };
18775
+ //#endregion
17640
18776
  //#region src/cli/utils/exit-gracefully.ts
17641
18777
  const exitGracefully = () => {
17642
18778
  try {
@@ -17646,21 +18782,54 @@ const exitGracefully = () => {
17646
18782
  process.exit(130);
17647
18783
  };
17648
18784
  //#endregion
18785
+ //#region src/cli/utils/normalize-help-command.ts
18786
+ /**
18787
+ * 12-factor CLI Apps (#1, "Great help is essential"): `mycli help` and
18788
+ * `mycli help <command>` must display help. Commander doesn't wire this
18789
+ * up once the root command has its own default action plus a positional
18790
+ * argument — it treats a leading `help` as the `[directory]` to scan,
18791
+ * which then errors with "No React project found in ./help".
18792
+ *
18793
+ * We rewrite the argv up front so the existing `--help` paths handle it:
18794
+ * `react-doctor help` -> `react-doctor --help`
18795
+ * `react-doctor help install` -> `react-doctor install --help`
18796
+ *
18797
+ * Only a *leading* `help` token is rewritten, so a flag value such as
18798
+ * `--project help` is never mistaken for the help command. The target is
18799
+ * the first non-flag token after `help`, so intervening flags like
18800
+ * `help --no-color install` still resolve to `install`. An unknown target
18801
+ * (`help bogus`) falls back to root help rather than erroring.
18802
+ */
18803
+ const normalizeHelpInvocation = (argv, knownCommands) => {
18804
+ const nodeArguments = argv.slice(0, 2);
18805
+ const userArguments = argv.slice(2);
18806
+ if (userArguments[0] !== "help") return [...argv];
18807
+ const target = userArguments.slice(1).find((argument) => !argument.startsWith("-"));
18808
+ if (target !== void 0 && knownCommands.includes(target)) return [
18809
+ ...nodeArguments,
18810
+ target,
18811
+ "--help"
18812
+ ];
18813
+ return [...nodeArguments, "--help"];
18814
+ };
18815
+ //#endregion
17649
18816
  //#region src/cli/utils/strip-unknown-cli-flags.ts
17650
- const NODE_ARGUMENT_COUNT = 2;
17651
18817
  const ROOT_FLAG_SPEC = {
17652
18818
  longOptionsWithoutValues: new Set([
17653
18819
  "--annotations",
18820
+ "--color",
17654
18821
  "--dead-code",
17655
18822
  "--full",
17656
18823
  "--help",
17657
18824
  "--json",
17658
18825
  "--json-compact",
17659
18826
  "--lint",
18827
+ "--no-color",
17660
18828
  "--no-dead-code",
17661
18829
  "--no-lint",
17662
18830
  "--no-respect-inline-disables",
17663
18831
  "--no-score",
18832
+ "--no-telemetry",
17664
18833
  "--no-warnings",
17665
18834
  "--pr-comment",
17666
18835
  "--respect-inline-disables",
@@ -17678,7 +18847,7 @@ const ROOT_FLAG_SPEC = {
17678
18847
  "--project",
17679
18848
  "--why"
17680
18849
  ]),
17681
- longOptionsWithOptionalValues: new Set(["--diff"]),
18850
+ longOptionsWithOptionalValues: new Set(["--diff", "--experimental-parallel"]),
17682
18851
  shortOptionsWithoutValues: new Set([
17683
18852
  "-h",
17684
18853
  "-v",
@@ -17689,8 +18858,10 @@ const ROOT_FLAG_SPEC = {
17689
18858
  const INSTALL_FLAG_SPEC = {
17690
18859
  longOptionsWithoutValues: new Set([
17691
18860
  "--agent-hooks",
18861
+ "--color",
17692
18862
  "--dry-run",
17693
18863
  "--help",
18864
+ "--no-color",
17694
18865
  "--yes"
17695
18866
  ]),
17696
18867
  longOptionsWithRequiredValues: new Set(["--cwd"]),
@@ -17698,7 +18869,40 @@ const INSTALL_FLAG_SPEC = {
17698
18869
  shortOptionsWithoutValues: new Set(["-h", "-y"]),
17699
18870
  shortOptionsWithRequiredValues: new Set(["-c"])
17700
18871
  };
17701
- const COMMAND_FLAG_SPECS = new Map([["install", INSTALL_FLAG_SPEC], ["setup", INSTALL_FLAG_SPEC]]);
18872
+ const COMMAND_FLAG_SPECS = new Map([
18873
+ ["install", INSTALL_FLAG_SPEC],
18874
+ ["setup", INSTALL_FLAG_SPEC],
18875
+ ["version", {
18876
+ longOptionsWithoutValues: new Set([
18877
+ "--color",
18878
+ "--help",
18879
+ "--no-color"
18880
+ ]),
18881
+ longOptionsWithRequiredValues: /* @__PURE__ */ new Set(),
18882
+ longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
18883
+ shortOptionsWithoutValues: new Set(["-h"]),
18884
+ shortOptionsWithRequiredValues: /* @__PURE__ */ new Set()
18885
+ }],
18886
+ ["rules", {
18887
+ longOptionsWithoutValues: new Set([
18888
+ "--color",
18889
+ "--configured",
18890
+ "--help",
18891
+ "--json",
18892
+ "--no-color"
18893
+ ]),
18894
+ longOptionsWithRequiredValues: new Set([
18895
+ "--category",
18896
+ "--cwd",
18897
+ "--framework",
18898
+ "--severity",
18899
+ "--tag"
18900
+ ]),
18901
+ longOptionsWithOptionalValues: /* @__PURE__ */ new Set(),
18902
+ shortOptionsWithoutValues: new Set(["-h"]),
18903
+ shortOptionsWithRequiredValues: new Set(["-c"])
18904
+ }]
18905
+ ]);
17702
18906
  const isFlagLike = (argument) => argument.startsWith("-") && argument !== "-";
17703
18907
  const getLongOptionName = (argument) => {
17704
18908
  const equalsIndex = argument.indexOf("=");
@@ -17752,8 +18956,8 @@ const stripUnknownFlags = (userArguments, flagSpec) => {
17752
18956
  return sanitizedArguments;
17753
18957
  };
17754
18958
  const stripUnknownCliFlags = (argv) => {
17755
- const nodeArguments = argv.slice(0, NODE_ARGUMENT_COUNT);
17756
- const userArguments = argv.slice(NODE_ARGUMENT_COUNT);
18959
+ const nodeArguments = argv.slice(0, 2);
18960
+ const userArguments = argv.slice(2);
17757
18961
  const commandIndex = findCommandIndex(userArguments);
17758
18962
  if (commandIndex === null) return [...nodeArguments, ...stripUnknownFlags(userArguments, ROOT_FLAG_SPEC)];
17759
18963
  const commandName = userArguments[commandIndex];
@@ -17767,23 +18971,75 @@ const stripUnknownCliFlags = (argv) => {
17767
18971
  };
17768
18972
  //#endregion
17769
18973
  //#region src/cli/index.ts
18974
+ initializeSentry();
17770
18975
  process.on("SIGINT", exitGracefully);
17771
18976
  process.on("SIGTERM", exitGracefully);
17772
18977
  unrefStdin();
17773
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead-code analysis (default)").option("--no-dead-code", "skip dead-code analysis (unused files / exports / dependencies, circular imports)").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--changed-files-from <file>", "internal: scan source files listed in a newline-delimited changed-files file").option("--no-score", "skip the score API and the share URL").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none (default: none)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--pr-comment", "tune CLI output for sticky PR comments (drops weak-signal rule families like `design` from the printed list and the fail-on gate; configure via config.surfaces)").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "alias for --explain").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").option("--warnings", "show warning-severity diagnostics (errors always show)").option("--no-warnings", "hide warning-severity diagnostics (default)").addHelpText("after", `
18978
+ const formatExampleLines = (examples) => {
18979
+ const width = Math.max(...examples.map(([command]) => command.length));
18980
+ return examples.map(([command, description]) => ` $ ${command.padEnd(width)} ${highlighter.dim(`# ${description}`)}`).join("\n");
18981
+ };
18982
+ const renderRootHelpEpilog = () => `
18983
+ ${highlighter.dim("Examples:")}
18984
+ ${formatExampleLines([
18985
+ ["react-doctor", "scan the current project"],
18986
+ ["react-doctor ./apps/web", "scan a specific directory"],
18987
+ ["react-doctor --diff main", "scan only files changed vs. main"],
18988
+ ["react-doctor --staged", "scan staged files (pre-commit hook)"],
18989
+ ["react-doctor --fail-on warning", "exit non-zero on warnings (CI gate)"],
18990
+ ["react-doctor --json > report.json", "write a machine-readable report"],
18991
+ ["react-doctor --explain src/App.tsx:42", "explain why a rule fired there"],
18992
+ ["react-doctor install", "set up the agent skill and git hook"]
18993
+ ])}
18994
+
17774
18995
  ${highlighter.dim("Configuration:")}
17775
- Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
17776
- CLI flags always override config values. See the README for the full schema.
18996
+ Add a ${highlighter.info("doctor.config.ts")} (or .js/.mjs/.json — or a ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
18997
+ Use ${highlighter.info("react-doctor rules")} to list, explain, and configure rules. CLI flags always override config values.
18998
+
18999
+ ${highlighter.dim("Feedback & bug reports:")}
19000
+ ${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
17777
19001
 
17778
19002
  ${highlighter.dim("Learn more:")}
17779
19003
  ${highlighter.info(CANONICAL_GITHUB_URL)}
17780
- `);
19004
+ `;
19005
+ const renderInstallHelpEpilog = () => `
19006
+ ${highlighter.dim("Examples:")}
19007
+ ${formatExampleLines([
19008
+ ["react-doctor install", "interactive setup"],
19009
+ ["react-doctor install --yes", "non-interactive; all detected agents"],
19010
+ ["react-doctor install --dry-run", "preview without writing files"],
19011
+ ["react-doctor install --agent-hooks", "also install native agent hooks"]
19012
+ ])}
19013
+
19014
+ ${highlighter.dim("Learn more:")}
19015
+ ${highlighter.info(CANONICAL_GITHUB_URL)}
19016
+ `;
19017
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead-code analysis (default)").option("--no-dead-code", "skip dead-code analysis (unused files / exports / dependencies, circular imports)").option("--verbose", "show every rule and per-file details (default shows top 3 rules)").option("--score", "output only the score").option("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--experimental-parallel [workers]", "experimental: lint with N parallel workers (default: auto-detect CPU cores) — speeds up large repos").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--changed-files-from <file>", "internal: scan source files listed in a newline-delimited changed-files file").option("--no-score", "skip the score API, the share URL, and crash reporting").option("--no-telemetry", "alias for --no-score (skip the score API, share URL, and crash reporting)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none (default: none)").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--pr-comment", "tune CLI output for sticky PR comments (drops weak-signal rule families like `design` from the printed list and the fail-on gate; configure via config.surfaces)").option("--explain <file:line>", "diagnose why a rule fired or why a suppression didn't apply at a specific location").option("--why <file:line>", "alias for --explain").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").option("--warnings", "show warning-severity diagnostics (default)").option("--no-warnings", "hide warning-severity diagnostics (errors only)").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderRootHelpEpilog);
17781
19018
  program.action(inspectAction);
17782
- program.command("install").alias("setup").description("Install the react-doctor skill into your coding agents and optional git hook").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor").option("-c, --cwd <cwd>", "working directory", process.cwd()).action(installAction);
19019
+ program.command("install").alias("setup").description("Install the react-doctor skill into your coding agents and optional git hook").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor").option("-c, --cwd <cwd>", "working directory", process.cwd()).option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").addHelpText("after", renderInstallHelpEpilog).action(installAction);
19020
+ program.command("version").description("show the version with Node and platform info").option("--color", "force colored output").option("--no-color", "disable colored output (also honors NO_COLOR)").action(versionAction);
19021
+ const rules = program.command("rules").description("List, explain, and configure which React Doctor rules run");
19022
+ rules.command("list").description("List rules and the severity they run at under your config").option("--category <name>", "only show rules in a category (e.g. Performance)").option("--tag <name>", "only show rules with a tag (e.g. design, test-noise)").option("--framework <name>", "only show rules for a framework (e.g. global, nextjs)").option("--configured", "only show rules your config has changed from the default").option("--json", "output a structured JSON array").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((_options, command) => rulesListAction(command.optsWithGlobals()));
19023
+ rules.command("explain <rule>").description("Explain why a rule matters, its current severity, and how to configure it").option("--json", "output a structured JSON object").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesExplainAction(rule, command.optsWithGlobals()));
19024
+ rules.command("set <rule> <severity>").description("Set a rule's severity: off, warn, or error").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, severity, _options, command) => rulesSetAction(rule, severity, command.optsWithGlobals()));
19025
+ rules.command("enable <rule>").description("Enable a rule at its recommended severity (or pass --severity)").option("--severity <level>", "severity to enable at: warn or error").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesEnableAction(rule, command.optsWithGlobals()));
19026
+ rules.command("disable <rule>").description("Disable a rule so it never runs").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((rule, _options, command) => rulesDisableAction(rule, command.optsWithGlobals()));
19027
+ rules.command("category <category> <severity>").description("Set the severity for a whole category (off, warn, error)").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((category, severity, _options, command) => rulesCategoryAction(category, severity, command.optsWithGlobals()));
19028
+ rules.command("ignore-tag <tag>").description("Skip a whole rule family by tag before linting (e.g. design)").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((tag, _options, command) => rulesIgnoreTagAction(tag, command.optsWithGlobals()));
19029
+ rules.command("unignore-tag <tag>").description("Stop ignoring a tag previously skipped via ignore-tag").option("-c, --cwd <cwd>", "working directory", process.cwd()).action((tag, _options, command) => rulesUnignoreTagAction(tag, command.optsWithGlobals()));
17783
19030
  process.stdout.on("error", (error) => {
17784
19031
  if (error.code === "EPIPE") process.exit(0);
17785
19032
  });
17786
- program.parseAsync(stripUnknownCliFlags(process.argv)).catch((error) => {
19033
+ const knownCommands = program.commands.flatMap((command) => [command.name(), ...command.aliases()]);
19034
+ const strippedArgv = stripUnknownCliFlags(process.argv);
19035
+ if (process.argv.includes("-V") && !strippedArgv.includes("-V")) {
19036
+ process.stdout.write(`${VERSION}\n`);
19037
+ process.exit(0);
19038
+ }
19039
+ applyColorPreference(strippedArgv);
19040
+ const argv = normalizeHelpInvocation(strippedArgv, knownCommands);
19041
+ program.parseAsync(argv).catch(async (error) => {
19042
+ await reportErrorToSentry(error);
17787
19043
  if (isJsonModeActive()) {
17788
19044
  writeJsonErrorReport(error);
17789
19045
  process.exit(1);