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/README.md +35 -2
- package/dist/cli.js +1419 -163
- package/dist/index.d.ts +31 -9
- package/dist/index.js +233 -85
- package/dist/skills/doctor-explain/SKILL.md +75 -0
- package/dist/skills/react-doctor/SKILL.md +4 -0
- package/package.json +8 -4
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
|
|
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
|
-
"
|
|
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
|
|
7467
|
-
const
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
|
|
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
|
-
|
|
7489
|
-
|
|
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
|
-
|
|
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
|
|
7499
|
-
|
|
7500
|
-
|
|
7501
|
-
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
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
|
|
7513
|
-
if (
|
|
7514
|
-
|
|
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 `
|
|
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.
|
|
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
|
-
|
|
10200
|
-
|
|
10201
|
-
const
|
|
10202
|
-
|
|
10203
|
-
|
|
10204
|
-
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
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
|
|
10212
|
-
|
|
10213
|
-
|
|
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 ??
|
|
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
|
-
*
|
|
11304
|
-
* `
|
|
11305
|
-
*
|
|
11306
|
-
*
|
|
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
|
|
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
|
-
* `${
|
|
11322
|
-
* (see `
|
|
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
|
|
14288
|
-
"
|
|
14289
|
-
"
|
|
14290
|
-
"
|
|
14291
|
-
"
|
|
14292
|
-
"
|
|
14293
|
-
"
|
|
14294
|
-
"
|
|
14295
|
-
"
|
|
14296
|
-
"
|
|
14297
|
-
"
|
|
14298
|
-
"
|
|
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
|
-
|
|
14303
|
-
const
|
|
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
|
|
14406
|
-
*
|
|
14407
|
-
* differences specific to the CLI path:
|
|
14690
|
+
* Same service shape as `@react-doctor/api → diagnose()`'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
|
-
|
|
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
|
|
14521
|
-
const
|
|
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
|
-
|
|
14656
|
-
|
|
14657
|
-
|
|
14658
|
-
|
|
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
|
|
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
|
|
14740
|
-
|
|
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
|
|
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
|
|
14786
|
-
|
|
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
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
15353
|
-
|
|
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 =
|
|
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
|
-
|
|
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([
|
|
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,
|
|
17756
|
-
const userArguments = argv.slice(
|
|
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
|
|
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
|
-
|
|
17776
|
-
|
|
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.
|
|
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);
|