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/index.d.ts
CHANGED
|
@@ -5,13 +5,35 @@ import * as Cause from "effect/Cause";
|
|
|
5
5
|
//#region src/types/config.d.ts
|
|
6
6
|
type FailOnLevel = "error" | "warning" | "none";
|
|
7
7
|
interface ReactDoctorIgnoreOverride {
|
|
8
|
+
/** Glob patterns the override applies to (e.g. `["src/legacy/**"]`). */
|
|
8
9
|
files: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Rule keys to suppress for the matched files. Omit (or leave empty) to
|
|
12
|
+
* suppress every rule for those files.
|
|
13
|
+
*/
|
|
9
14
|
rules?: string[];
|
|
10
15
|
}
|
|
11
16
|
interface ReactDoctorIgnoreConfig {
|
|
17
|
+
/**
|
|
18
|
+
* Fully-qualified rule keys (`"<plugin>/<rule>"`) whose diagnostics are
|
|
19
|
+
* dropped AFTER linting. The rule still runs; its findings are filtered
|
|
20
|
+
* out. To stop a rule from running at all, set it to `"off"` in the
|
|
21
|
+
* top-level `rules` map instead. Prefer `react-doctor rules disable
|
|
22
|
+
* <rule>` to edit this safely.
|
|
23
|
+
*/
|
|
12
24
|
rules?: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Glob patterns whose files are excluded from scanning entirely (matched
|
|
27
|
+
* against paths relative to the scanned directory).
|
|
28
|
+
*/
|
|
13
29
|
files?: string[];
|
|
30
|
+
/** Per-path rule suppressions — narrower than the top-level `rules`/`files`. */
|
|
14
31
|
overrides?: ReactDoctorIgnoreOverride[];
|
|
32
|
+
/**
|
|
33
|
+
* Behavioral tags whose rules are disabled BEFORE linting, skipping a
|
|
34
|
+
* whole family at once (e.g. `["design", "test-noise", "migration-hint"]`).
|
|
35
|
+
* Prefer `react-doctor rules ignore-tag <tag>` to edit this safely.
|
|
36
|
+
*/
|
|
15
37
|
tags?: string[];
|
|
16
38
|
}
|
|
17
39
|
/**
|
|
@@ -100,11 +122,11 @@ interface ReactDoctorConfig {
|
|
|
100
122
|
deadCode?: boolean;
|
|
101
123
|
verbose?: boolean;
|
|
102
124
|
/**
|
|
103
|
-
* Whether to surface `"warning"`-severity diagnostics. Default: `
|
|
104
|
-
* —
|
|
105
|
-
*
|
|
125
|
+
* Whether to surface `"warning"`-severity diagnostics. Default: `true`
|
|
126
|
+
* — every warning reaches every surface (CLI, PR comment, score,
|
|
127
|
+
* `--fail-on`).
|
|
106
128
|
*
|
|
107
|
-
* Set to `
|
|
129
|
+
* Set to `false` to surface only `"error"`-severity findings. This is the
|
|
108
130
|
* master toggle and runs after per-rule / per-category severity
|
|
109
131
|
* overrides: a rule the user explicitly restamps to `"warn"` (via
|
|
110
132
|
* `rules` / `categories`) still shows even when `warnings` is `false`.
|
|
@@ -122,7 +144,7 @@ interface ReactDoctorConfig {
|
|
|
122
144
|
* the redirect is stable no matter where the CLI / `diagnose()` is
|
|
123
145
|
* run from. Absolute paths are used as-is.
|
|
124
146
|
*
|
|
125
|
-
* Typical use: a monorepo root holds the only `
|
|
147
|
+
* Typical use: a monorepo root holds the only `doctor.config.*`
|
|
126
148
|
* (so editor tooling and child commands all find it), but the React
|
|
127
149
|
* app lives in `apps/web`. Setting `"rootDir": "apps/web"` makes
|
|
128
150
|
* every invocation that loads this config scan that subproject
|
|
@@ -420,8 +442,8 @@ interface DiagnoseOptions {
|
|
|
420
442
|
respectInlineDisables?: boolean;
|
|
421
443
|
/**
|
|
422
444
|
* Per-call override for `ReactDoctorConfig.warnings`. See that field's
|
|
423
|
-
* docs — `"warning"`-severity diagnostics
|
|
424
|
-
* the config) opts
|
|
445
|
+
* docs — `"warning"`-severity diagnostics surface by default unless this
|
|
446
|
+
* (or the config) opts out via `false`.
|
|
425
447
|
*/
|
|
426
448
|
warnings?: boolean;
|
|
427
449
|
}
|
|
@@ -444,7 +466,7 @@ interface DiagnoseResult {
|
|
|
444
466
|
* Scan options (`deadCode`, `lint`, etc.) are flat on the entry and
|
|
445
467
|
* layer on top of the global defaults — omitted fields fall through.
|
|
446
468
|
* `config` is a full `ReactDoctorConfig` override that replaces the
|
|
447
|
-
* on-disk `
|
|
469
|
+
* on-disk `doctor.config.*` for this project's scan.
|
|
448
470
|
*/
|
|
449
471
|
//#endregion
|
|
450
472
|
//#region src/types/inspect.d.ts
|
|
@@ -742,7 +764,7 @@ interface BuildJsonReportInput {
|
|
|
742
764
|
totalElapsedMilliseconds: number;
|
|
743
765
|
}
|
|
744
766
|
declare const buildJsonReport: (input: BuildJsonReportInput) => JsonReport; //#endregion
|
|
745
|
-
//#region src/
|
|
767
|
+
//#region src/build-skipped-checks.d.ts
|
|
746
768
|
//#endregion
|
|
747
769
|
//#region src/get-diff-files.d.ts
|
|
748
770
|
/**
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,10 @@ import * as Redacted from "effect/Redacted";
|
|
|
14
14
|
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
15
15
|
import * as Otlp from "effect/unstable/observability/Otlp";
|
|
16
16
|
import * as Context from "effect/Context";
|
|
17
|
+
import os from "node:os";
|
|
17
18
|
import * as Console from "effect/Console";
|
|
19
|
+
import { parseJSON5 } from "confbox";
|
|
20
|
+
import { createJiti } from "jiti";
|
|
18
21
|
import * as Fiber from "effect/Fiber";
|
|
19
22
|
import * as Filter from "effect/Filter";
|
|
20
23
|
import * as Option from "effect/Option";
|
|
@@ -26,7 +29,6 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
|
|
|
26
29
|
import * as NodePath from "@effect/platform-node-shared/NodePath";
|
|
27
30
|
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
28
31
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
29
|
-
import os from "node:os";
|
|
30
32
|
import * as ts from "typescript";
|
|
31
33
|
import { gzipSync } from "node:zlib";
|
|
32
34
|
//#region \0rolldown/runtime.js
|
|
@@ -3289,7 +3291,14 @@ const STAGED_FILES_PROJECT_CONFIG_FILENAMES = [
|
|
|
3289
3291
|
"tsconfig.json",
|
|
3290
3292
|
"tsconfig.base.json",
|
|
3291
3293
|
"package.json",
|
|
3292
|
-
"
|
|
3294
|
+
"doctor.config.ts",
|
|
3295
|
+
"doctor.config.mts",
|
|
3296
|
+
"doctor.config.cts",
|
|
3297
|
+
"doctor.config.js",
|
|
3298
|
+
"doctor.config.mjs",
|
|
3299
|
+
"doctor.config.cjs",
|
|
3300
|
+
"doctor.config.json",
|
|
3301
|
+
"doctor.config.jsonc",
|
|
3293
3302
|
"oxlint.json",
|
|
3294
3303
|
".oxlintrc.json"
|
|
3295
3304
|
];
|
|
@@ -4271,6 +4280,17 @@ const layerOtlp = Layer.unwrap(Effect.gen(function* () {
|
|
|
4271
4280
|
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
4272
4281
|
}).pipe(Effect.orDie));
|
|
4273
4282
|
/**
|
|
4283
|
+
* Resolves a requested lint worker count to a clamped integer within
|
|
4284
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`. `"auto"` uses the
|
|
4285
|
+
* machine's CPU cores; out-of-range or non-finite requests degrade to
|
|
4286
|
+
* `MIN_SCAN_CONCURRENCY` rather than oversubscribing or running zero workers.
|
|
4287
|
+
*/
|
|
4288
|
+
const resolveScanConcurrency = (requested) => {
|
|
4289
|
+
const desired = requested === "auto" ? os.availableParallelism() : requested;
|
|
4290
|
+
if (!Number.isFinite(desired) || desired < 1) return 1;
|
|
4291
|
+
return Math.max(1, Math.min(Math.floor(desired), 16));
|
|
4292
|
+
};
|
|
4293
|
+
/**
|
|
4274
4294
|
* Per-batch oxlint wall-clock budget. Reads from the env var on
|
|
4275
4295
|
* startup so the eval harness can raise the budget under sandbox
|
|
4276
4296
|
* microVMs without recompiling react-doctor. Tests override via
|
|
@@ -4290,6 +4310,30 @@ var OxlintSpawnTimeoutMs = class extends Context.Reference("react-doctor/OxlintS
|
|
|
4290
4310
|
* tests that exercise the cap behavior.
|
|
4291
4311
|
*/
|
|
4292
4312
|
var OxlintOutputMaxBytes = class extends Context.Reference("react-doctor/OxlintOutputMaxBytes", { defaultValue: () => OXLINT_OUTPUT_MAX_BYTES }) {};
|
|
4313
|
+
/**
|
|
4314
|
+
* Number of oxlint subprocesses the lint pass runs in parallel. Defaults
|
|
4315
|
+
* to `1` (serial — the historical behavior) so resource usage is opt-in.
|
|
4316
|
+
* The CLI's `--experimental-parallel` flag overrides this via `Layer.succeed`; the
|
|
4317
|
+
* `REACT_DOCTOR_PARALLEL` env var seeds the default for programmatic /
|
|
4318
|
+
* CI callers that never touch the flag:
|
|
4319
|
+
*
|
|
4320
|
+
* - unset / `0` / `false` / `off` → `1` (serial)
|
|
4321
|
+
* - `auto` / `true` / `on` → available CPU cores (clamped)
|
|
4322
|
+
* - a positive integer → that many workers (clamped)
|
|
4323
|
+
*
|
|
4324
|
+
* The resolved value is always within
|
|
4325
|
+
* `[MIN_SCAN_CONCURRENCY, MAX_SCAN_CONCURRENCY]`.
|
|
4326
|
+
*/
|
|
4327
|
+
var OxlintConcurrency = class extends Context.Reference("react-doctor/OxlintConcurrency", { defaultValue: () => {
|
|
4328
|
+
const raw = process.env["REACT_DOCTOR_PARALLEL"];
|
|
4329
|
+
if (raw === void 0) return 1;
|
|
4330
|
+
const normalized = raw.trim().toLowerCase();
|
|
4331
|
+
if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "off") return 1;
|
|
4332
|
+
if (normalized === "auto" || normalized === "true" || normalized === "on") return resolveScanConcurrency("auto");
|
|
4333
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
4334
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return 1;
|
|
4335
|
+
return resolveScanConcurrency(parsed);
|
|
4336
|
+
} }) {};
|
|
4293
4337
|
const DIAGNOSTIC_SURFACES = [
|
|
4294
4338
|
"cli",
|
|
4295
4339
|
"prComment",
|
|
@@ -4446,69 +4490,109 @@ const validateConfigTypes = (config) => {
|
|
|
4446
4490
|
const warn = (message) => {
|
|
4447
4491
|
Effect.runSync(Console.warn(message));
|
|
4448
4492
|
};
|
|
4449
|
-
const
|
|
4493
|
+
const CONFIG_BASENAME = "doctor.config";
|
|
4494
|
+
const CONFIG_EXTENSIONS = [
|
|
4495
|
+
"ts",
|
|
4496
|
+
"mts",
|
|
4497
|
+
"cts",
|
|
4498
|
+
"js",
|
|
4499
|
+
"mjs",
|
|
4500
|
+
"cjs",
|
|
4501
|
+
"json",
|
|
4502
|
+
"jsonc"
|
|
4503
|
+
];
|
|
4504
|
+
const DATA_CONFIG_EXTENSIONS = new Set(["json", "jsonc"]);
|
|
4505
|
+
const PACKAGE_JSON_FILENAME = "package.json";
|
|
4450
4506
|
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
4451
|
-
const
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
const packageJsonPath = path.join(directory, "package.json");
|
|
4465
|
-
if (isFile(packageJsonPath)) try {
|
|
4466
|
-
const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
|
|
4467
|
-
const packageJson = JSON.parse(fileContent);
|
|
4507
|
+
const LEGACY_CONFIG_FILENAME = "react-doctor.config.json";
|
|
4508
|
+
const jiti = createJiti(import.meta.url);
|
|
4509
|
+
const formatError = (error) => error instanceof Error ? error.message : String(error);
|
|
4510
|
+
const loadModuleConfig = async (filePath) => {
|
|
4511
|
+
const imported = await jiti.import(filePath);
|
|
4512
|
+
return imported?.default ?? imported;
|
|
4513
|
+
};
|
|
4514
|
+
const readDataConfig = (filePath) => parseJSON5(fs.readFileSync(filePath, "utf-8"));
|
|
4515
|
+
const readEmbeddedPackageJsonConfig = (directory) => {
|
|
4516
|
+
const packageJsonPath = path.join(directory, PACKAGE_JSON_FILENAME);
|
|
4517
|
+
if (!isFile(packageJsonPath)) return null;
|
|
4518
|
+
try {
|
|
4519
|
+
const packageJson = parseJSON5(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
4468
4520
|
if (isPlainObject(packageJson)) {
|
|
4469
4521
|
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
4470
|
-
if (isPlainObject(embeddedConfig)) return
|
|
4471
|
-
|
|
4472
|
-
|
|
4522
|
+
if (isPlainObject(embeddedConfig)) return embeddedConfig;
|
|
4523
|
+
}
|
|
4524
|
+
} catch {}
|
|
4525
|
+
return null;
|
|
4526
|
+
};
|
|
4527
|
+
const loadPackageJsonConfig = (directory) => {
|
|
4528
|
+
const embeddedConfig = readEmbeddedPackageJsonConfig(directory);
|
|
4529
|
+
if (!embeddedConfig) return null;
|
|
4530
|
+
return {
|
|
4531
|
+
config: validateConfigTypes(embeddedConfig),
|
|
4532
|
+
sourceDirectory: directory,
|
|
4533
|
+
configFilePath: path.join(directory, PACKAGE_JSON_FILENAME),
|
|
4534
|
+
format: "package-json"
|
|
4535
|
+
};
|
|
4536
|
+
};
|
|
4537
|
+
const loadConfigFromDirectory = async (directory) => {
|
|
4538
|
+
let sawBrokenConfigFile = false;
|
|
4539
|
+
for (const extension of CONFIG_EXTENSIONS) {
|
|
4540
|
+
const filePath = path.join(directory, `${CONFIG_BASENAME}.${extension}`);
|
|
4541
|
+
if (!isFile(filePath)) continue;
|
|
4542
|
+
const isDataFile = DATA_CONFIG_EXTENSIONS.has(extension);
|
|
4543
|
+
try {
|
|
4544
|
+
const parsed = isDataFile ? readDataConfig(filePath) : await loadModuleConfig(filePath);
|
|
4545
|
+
if (isPlainObject(parsed)) return {
|
|
4546
|
+
status: "found",
|
|
4547
|
+
loaded: {
|
|
4548
|
+
config: validateConfigTypes(parsed),
|
|
4549
|
+
sourceDirectory: directory,
|
|
4550
|
+
configFilePath: filePath,
|
|
4551
|
+
format: isDataFile ? "json" : "module"
|
|
4552
|
+
}
|
|
4473
4553
|
};
|
|
4554
|
+
warn(`${CONFIG_BASENAME}.${extension} must export an object, ignoring.`);
|
|
4555
|
+
sawBrokenConfigFile = true;
|
|
4556
|
+
} catch (error) {
|
|
4557
|
+
warn(`Failed to load ${CONFIG_BASENAME}.${extension}: ${formatError(error)}`);
|
|
4558
|
+
sawBrokenConfigFile = true;
|
|
4474
4559
|
}
|
|
4475
|
-
} catch {
|
|
4476
|
-
return null;
|
|
4477
4560
|
}
|
|
4478
|
-
|
|
4561
|
+
const packageJsonConfig = loadPackageJsonConfig(directory);
|
|
4562
|
+
if (packageJsonConfig) return {
|
|
4563
|
+
status: "found",
|
|
4564
|
+
loaded: packageJsonConfig
|
|
4565
|
+
};
|
|
4566
|
+
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).`);
|
|
4567
|
+
return {
|
|
4568
|
+
status: sawBrokenConfigFile ? "invalid" : "absent",
|
|
4569
|
+
loaded: null
|
|
4570
|
+
};
|
|
4479
4571
|
};
|
|
4480
4572
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
4481
4573
|
const clearConfigCache = () => {
|
|
4482
4574
|
cachedConfigs.clear();
|
|
4483
4575
|
};
|
|
4484
|
-
const
|
|
4485
|
-
const
|
|
4486
|
-
if (
|
|
4487
|
-
|
|
4488
|
-
if (localConfig) {
|
|
4489
|
-
cachedConfigs.set(rootDirectory, localConfig);
|
|
4490
|
-
return localConfig;
|
|
4491
|
-
}
|
|
4492
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
4493
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4494
|
-
return null;
|
|
4495
|
-
}
|
|
4576
|
+
const loadConfigWalkingUp = async (rootDirectory) => {
|
|
4577
|
+
const localResult = await loadConfigFromDirectory(rootDirectory);
|
|
4578
|
+
if (localResult.status === "found") return localResult.loaded;
|
|
4579
|
+
if (localResult.status === "invalid" || isProjectBoundary(rootDirectory)) return null;
|
|
4496
4580
|
let ancestorDirectory = path.dirname(rootDirectory);
|
|
4497
4581
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
4498
|
-
const
|
|
4499
|
-
if (
|
|
4500
|
-
|
|
4501
|
-
return ancestorConfig;
|
|
4502
|
-
}
|
|
4503
|
-
if (isProjectBoundary(ancestorDirectory)) {
|
|
4504
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4505
|
-
return null;
|
|
4506
|
-
}
|
|
4582
|
+
const ancestorResult = await loadConfigFromDirectory(ancestorDirectory);
|
|
4583
|
+
if (ancestorResult.status === "found") return ancestorResult.loaded;
|
|
4584
|
+
if (isProjectBoundary(ancestorDirectory)) return null;
|
|
4507
4585
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
4508
4586
|
}
|
|
4509
|
-
cachedConfigs.set(rootDirectory, null);
|
|
4510
4587
|
return null;
|
|
4511
4588
|
};
|
|
4589
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
4590
|
+
const cached = cachedConfigs.get(rootDirectory);
|
|
4591
|
+
if (cached !== void 0) return cached;
|
|
4592
|
+
const loadPromise = loadConfigWalkingUp(rootDirectory);
|
|
4593
|
+
cachedConfigs.set(rootDirectory, loadPromise);
|
|
4594
|
+
return loadPromise;
|
|
4595
|
+
};
|
|
4512
4596
|
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
4513
4597
|
if (!config || !configSourceDirectory) return null;
|
|
4514
4598
|
const rawRootDir = config.rootDir;
|
|
@@ -4536,8 +4620,7 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
|
4536
4620
|
* (`inspect()`, `diagnose()`, and the CLI's `inspectAction`):
|
|
4537
4621
|
*
|
|
4538
4622
|
* 1. Resolve the requested directory to absolute.
|
|
4539
|
-
* 2. Load `
|
|
4540
|
-
* if present.
|
|
4623
|
+
* 2. Load `doctor.config.*` / `package.json#reactDoctor` if present.
|
|
4541
4624
|
* 3. Honor `config.rootDir` to redirect the scan to a nested
|
|
4542
4625
|
* project root, if configured.
|
|
4543
4626
|
* 4. Walk into a nested React subproject when the requested
|
|
@@ -4555,9 +4638,9 @@ const resolveDiagnoseTarget = (directory, options = {}) => {
|
|
|
4555
4638
|
* via its own cache). Routing through `resolveScanTarget` keeps every
|
|
4556
4639
|
* shell in agreement on what "the scan directory" means.
|
|
4557
4640
|
*/
|
|
4558
|
-
const resolveScanTarget = (requestedDirectory, options = {}) => {
|
|
4641
|
+
const resolveScanTarget = async (requestedDirectory, options = {}) => {
|
|
4559
4642
|
const absoluteRequested = path.resolve(requestedDirectory);
|
|
4560
|
-
const loadedConfig = loadConfigWithSource(absoluteRequested);
|
|
4643
|
+
const loadedConfig = await loadConfigWithSource(absoluteRequested);
|
|
4561
4644
|
const userConfig = loadedConfig?.config ?? null;
|
|
4562
4645
|
const configSourceDirectory = loadedConfig?.sourceDirectory ?? null;
|
|
4563
4646
|
const redirectedDirectory = resolveConfigRootDir(userConfig, configSourceDirectory);
|
|
@@ -5607,8 +5690,8 @@ var Config = class Config extends Context.Service()("react-doctor/Config") {
|
|
|
5607
5690
|
const cache = yield* Cache.make({
|
|
5608
5691
|
capacity: 16,
|
|
5609
5692
|
timeToLive: CONFIG_CACHE_TTL_MS,
|
|
5610
|
-
lookup: (directory) => Effect.
|
|
5611
|
-
const loaded = loadConfigWithSource(directory);
|
|
5693
|
+
lookup: (directory) => Effect.promise(async () => {
|
|
5694
|
+
const loaded = await loadConfigWithSource(directory);
|
|
5612
5695
|
const redirected = resolveConfigRootDir(loaded?.config ?? null, loaded?.sourceDirectory ?? null);
|
|
5613
5696
|
return {
|
|
5614
5697
|
config: loaded?.config ?? null,
|
|
@@ -6483,6 +6566,44 @@ const dedupeDiagnostics = (diagnostics) => {
|
|
|
6483
6566
|
}
|
|
6484
6567
|
return uniqueDiagnostics;
|
|
6485
6568
|
};
|
|
6569
|
+
/**
|
|
6570
|
+
* Runs `task` over `items` with at most `concurrency` tasks in flight at
|
|
6571
|
+
* once, returning results in input order. A pool of workers each pulls the
|
|
6572
|
+
* next not-yet-started index until the list drains — so a worker that
|
|
6573
|
+
* finishes a fast task immediately picks up the next one (greedy load
|
|
6574
|
+
* balancing), which matters when tasks have uneven durations (oxlint
|
|
6575
|
+
* batches do).
|
|
6576
|
+
*
|
|
6577
|
+
* Failure semantics mirror a bounded `Promise.all`: on the first rejection
|
|
6578
|
+
* no further tasks are started, the already-in-flight tasks are awaited to
|
|
6579
|
+
* settle (so no subprocess is orphaned mid-write), and the returned promise
|
|
6580
|
+
* rejects with that first error. This keeps the caller's fail-fast retry
|
|
6581
|
+
* path (e.g. oxlint's retry-without-extends) from spawning a second wave on
|
|
6582
|
+
* top of a still-running first one.
|
|
6583
|
+
*/
|
|
6584
|
+
const mapWithConcurrency = async (items, concurrency, task) => {
|
|
6585
|
+
const results = new Array(items.length);
|
|
6586
|
+
if (items.length === 0) return results;
|
|
6587
|
+
const workerCount = Math.min(Math.max(1, Math.floor(concurrency) || 1), items.length);
|
|
6588
|
+
let nextIndex = 0;
|
|
6589
|
+
const errors = [];
|
|
6590
|
+
const runWorker = async () => {
|
|
6591
|
+
while (errors.length === 0) {
|
|
6592
|
+
const index = nextIndex;
|
|
6593
|
+
nextIndex += 1;
|
|
6594
|
+
if (index >= items.length) return;
|
|
6595
|
+
try {
|
|
6596
|
+
results[index] = await task(items[index], index);
|
|
6597
|
+
} catch (error) {
|
|
6598
|
+
errors.push(error);
|
|
6599
|
+
return;
|
|
6600
|
+
}
|
|
6601
|
+
}
|
|
6602
|
+
};
|
|
6603
|
+
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
|
6604
|
+
if (errors.length > 0) throw errors[0];
|
|
6605
|
+
return results;
|
|
6606
|
+
};
|
|
6486
6607
|
const getPublicEnvPrefix = (framework) => {
|
|
6487
6608
|
switch (framework) {
|
|
6488
6609
|
case "nextjs": return "NEXT_PUBLIC_*";
|
|
@@ -7165,6 +7286,7 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath, spawnTimeoutMs = OXLIN
|
|
|
7165
7286
|
*/
|
|
7166
7287
|
const spawnLintBatches = async (input) => {
|
|
7167
7288
|
const { baseArgs, fileBatches, rootDirectory, nodeBinaryPath, project, onPartialFailure, onFileProgress, spawnTimeoutMs, outputMaxBytes } = input;
|
|
7289
|
+
const concurrency = resolveScanConcurrency(input.concurrency ?? 1);
|
|
7168
7290
|
const totalFileCount = fileBatches.reduce((sum, batch) => sum + batch.length, 0);
|
|
7169
7291
|
const allDiagnostics = [];
|
|
7170
7292
|
const droppedFiles = [];
|
|
@@ -7184,23 +7306,31 @@ const spawnLintBatches = async (input) => {
|
|
|
7184
7306
|
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
7185
7307
|
}
|
|
7186
7308
|
};
|
|
7309
|
+
let startedFileCount = 0;
|
|
7187
7310
|
let scannedFileCount = 0;
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
const
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7311
|
+
let displayedFileCount = 0;
|
|
7312
|
+
const progressTimer = onFileProgress && totalFileCount > 1 ? setInterval(() => {
|
|
7313
|
+
const ceiling = Math.min(startedFileCount, totalFileCount - 1);
|
|
7314
|
+
if (displayedFileCount < ceiling) {
|
|
7315
|
+
displayedFileCount += 1;
|
|
7316
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
7317
|
+
}
|
|
7318
|
+
}, 50) : null;
|
|
7319
|
+
progressTimer?.unref?.();
|
|
7320
|
+
try {
|
|
7321
|
+
const batchResults = await mapWithConcurrency(fileBatches, concurrency, async (batch) => {
|
|
7322
|
+
startedFileCount += batch.length;
|
|
7197
7323
|
const batchDiagnostics = await spawnLintBatch(batch);
|
|
7198
|
-
allDiagnostics.push(...batchDiagnostics);
|
|
7199
7324
|
scannedFileCount += batch.length;
|
|
7200
|
-
onFileProgress
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7325
|
+
if (onFileProgress) {
|
|
7326
|
+
displayedFileCount = Math.min(Math.max(displayedFileCount, scannedFileCount), totalFileCount);
|
|
7327
|
+
onFileProgress(displayedFileCount, totalFileCount);
|
|
7328
|
+
}
|
|
7329
|
+
return batchDiagnostics;
|
|
7330
|
+
});
|
|
7331
|
+
for (const batchDiagnostics of batchResults) allDiagnostics.push(...batchDiagnostics);
|
|
7332
|
+
} finally {
|
|
7333
|
+
if (progressTimer !== null) clearInterval(progressTimer);
|
|
7204
7334
|
}
|
|
7205
7335
|
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
7206
7336
|
const previewFiles = droppedFiles.slice(0, 3).join(", ");
|
|
@@ -7327,7 +7457,8 @@ const runOxlint = async (options) => {
|
|
|
7327
7457
|
onPartialFailure,
|
|
7328
7458
|
onFileProgress: options.onFileProgress,
|
|
7329
7459
|
spawnTimeoutMs,
|
|
7330
|
-
outputMaxBytes
|
|
7460
|
+
outputMaxBytes,
|
|
7461
|
+
concurrency: options.concurrency
|
|
7331
7462
|
});
|
|
7332
7463
|
writeOxlintConfig(configPath, buildConfig(extendsPaths));
|
|
7333
7464
|
try {
|
|
@@ -7395,6 +7526,7 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
7395
7526
|
const partialFailures = yield* LintPartialFailures;
|
|
7396
7527
|
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
|
|
7397
7528
|
const outputMaxBytes = yield* OxlintOutputMaxBytes;
|
|
7529
|
+
const concurrency = yield* OxlintConcurrency;
|
|
7398
7530
|
const collectedFailures = [];
|
|
7399
7531
|
const diagnostics = yield* Effect.tryPromise({
|
|
7400
7532
|
try: () => runOxlint({
|
|
@@ -7413,7 +7545,8 @@ var Linter = class Linter extends Context.Service()("react-doctor/Linter") {
|
|
|
7413
7545
|
},
|
|
7414
7546
|
onFileProgress: input.onFileProgress,
|
|
7415
7547
|
spawnTimeoutMs,
|
|
7416
|
-
outputMaxBytes
|
|
7548
|
+
outputMaxBytes,
|
|
7549
|
+
concurrency
|
|
7417
7550
|
}),
|
|
7418
7551
|
catch: ensureReactDoctorError
|
|
7419
7552
|
});
|
|
@@ -7737,7 +7870,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7737
7870
|
const afterLint = hooks.afterLint ?? NO_HOOKS.afterLint;
|
|
7738
7871
|
yield* beforeLint(project, lintIncludePaths ?? void 0);
|
|
7739
7872
|
const isDiffMode = input.includePaths.length > 0;
|
|
7740
|
-
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ??
|
|
7873
|
+
const showWarnings = input.warnings ?? resolvedConfig.config?.warnings ?? true;
|
|
7741
7874
|
const transform = buildDiagnosticPipeline({
|
|
7742
7875
|
rootDirectory: scanDirectory,
|
|
7743
7876
|
userConfig: resolvedConfig.config,
|
|
@@ -7762,6 +7895,8 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7762
7895
|
didFail: false,
|
|
7763
7896
|
reason: null
|
|
7764
7897
|
});
|
|
7898
|
+
const scanConcurrency = yield* OxlintConcurrency;
|
|
7899
|
+
const workerCountSuffix = scanConcurrency > 1 ? ` · ${scanConcurrency} workers` : "";
|
|
7765
7900
|
const scanProgress = yield* progressService.start("Scanning...");
|
|
7766
7901
|
const scanStartTime = Date.now();
|
|
7767
7902
|
let lastReportedTotalFileCount = 0;
|
|
@@ -7778,7 +7913,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7778
7913
|
configSourceDirectory: resolvedConfig.configSourceDirectory ?? void 0,
|
|
7779
7914
|
onFileProgress: (scannedFileCount, totalFileCount) => {
|
|
7780
7915
|
lastReportedTotalFileCount = totalFileCount;
|
|
7781
|
-
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})...`));
|
|
7916
|
+
Effect.runSync(scanProgress.update(`Scanning files (${scannedFileCount}/${totalFileCount})${workerCountSuffix}...`));
|
|
7782
7917
|
}
|
|
7783
7918
|
}).pipe(Stream.catchTag("ReactDoctorError", (error) => Stream.unwrap(Effect.gen(function* () {
|
|
7784
7919
|
yield* Ref.set(lintFailure, {
|
|
@@ -7810,7 +7945,7 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7810
7945
|
const totalFileCount = lastReportedTotalFileCount || (lintIncludePaths?.length ?? project.sourceFileCount);
|
|
7811
7946
|
if (!lintFailureState.didFail) if (deadCodeFailureState.didFail) yield* scanProgress.fail(DEAD_CODE_FAIL_TEXT);
|
|
7812
7947
|
else if (input.suppressScanSummary) yield* scanProgress.stop();
|
|
7813
|
-
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s`);
|
|
7948
|
+
else yield* scanProgress.succeed(`Scanned ${totalFileCount} ${totalFileCount === 1 ? "file" : "files"} in ${scanElapsedSeconds}s${workerCountSuffix}`);
|
|
7814
7949
|
yield* reporterService.finalize;
|
|
7815
7950
|
const finalDiagnostics = [
|
|
7816
7951
|
...envCollected,
|
|
@@ -7862,7 +7997,6 @@ const runInspect = (input, hooks = {}) => Effect.gen(function* () {
|
|
|
7862
7997
|
"inspect.isCi": input.isCi,
|
|
7863
7998
|
"inspect.scoreSurface": input.scoreSurface ?? "score"
|
|
7864
7999
|
} }));
|
|
7865
|
-
Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
7866
8000
|
const parseNodeVersion = (versionString) => {
|
|
7867
8001
|
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
7868
8002
|
return {
|
|
@@ -8161,6 +8295,26 @@ const buildJsonReport = (input) => {
|
|
|
8161
8295
|
};
|
|
8162
8296
|
};
|
|
8163
8297
|
/**
|
|
8298
|
+
* Single source of truth for the skipped-check accounting shared by the
|
|
8299
|
+
* CLI renderer (`react-doctor/src/inspect.ts → finalizeAndRender`) and the
|
|
8300
|
+
* programmatic shell (`@react-doctor/api → diagnose()`). Both surface a
|
|
8301
|
+
* failed lint / dead-code pass instead of a false "all clear", so the
|
|
8302
|
+
* branch logic lives here once.
|
|
8303
|
+
*/
|
|
8304
|
+
const buildSkippedChecks = (input) => {
|
|
8305
|
+
const skippedChecks = [];
|
|
8306
|
+
if (input.didLintFail) skippedChecks.push("lint");
|
|
8307
|
+
if (input.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
8308
|
+
const skippedCheckReasons = {};
|
|
8309
|
+
if (input.didLintFail && input.lintFailureReason !== null) skippedCheckReasons.lint = input.lintFailureReason;
|
|
8310
|
+
else if (input.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = input.lintPartialFailures.join("; ");
|
|
8311
|
+
if (input.didDeadCodeFail && input.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = input.deadCodeFailureReason;
|
|
8312
|
+
return {
|
|
8313
|
+
skippedChecks,
|
|
8314
|
+
skippedCheckReasons
|
|
8315
|
+
};
|
|
8316
|
+
};
|
|
8317
|
+
/**
|
|
8164
8318
|
* Programmatic façade over `Git.diffSelection`. Async because the
|
|
8165
8319
|
* Git service runs through Effect's `ChildProcess` (true subprocess
|
|
8166
8320
|
* spawn, not `spawnSync`).
|
|
@@ -8266,7 +8420,7 @@ import_picocolors.default.red, import_picocolors.default.yellow, import_picocolo
|
|
|
8266
8420
|
const clearAutoSuppressionCaches = () => {};
|
|
8267
8421
|
//#endregion
|
|
8268
8422
|
//#region ../api/dist/index.js
|
|
8269
|
-
const
|
|
8423
|
+
const buildDiagnoseLayer = (configLayer = Config.layerNode) => Layer.mergeAll(Project.layerNode, configLayer, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
|
|
8270
8424
|
const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
8271
8425
|
const effectiveConfig = configOverride ?? scanTarget.userConfig;
|
|
8272
8426
|
const includePaths = options.includePaths ?? [];
|
|
@@ -8275,7 +8429,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
8275
8429
|
includePaths,
|
|
8276
8430
|
customRulesOnly: effectiveConfig?.customRulesOnly ?? false,
|
|
8277
8431
|
respectInlineDisables: options.respectInlineDisables ?? effectiveConfig?.respectInlineDisables ?? true,
|
|
8278
|
-
warnings: options.warnings ?? effectiveConfig?.warnings ??
|
|
8432
|
+
warnings: options.warnings ?? effectiveConfig?.warnings ?? true,
|
|
8279
8433
|
adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
|
|
8280
8434
|
ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
|
|
8281
8435
|
runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
|
|
@@ -8285,13 +8439,7 @@ const buildInspectProgram = (scanTarget, options, configOverride) => {
|
|
|
8285
8439
|
};
|
|
8286
8440
|
const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
8287
8441
|
if (output.didLintFail && output.lintFailureReason !== null) console.error("Lint failed:", output.lintFailureReason);
|
|
8288
|
-
const skippedChecks =
|
|
8289
|
-
if (output.didLintFail) skippedChecks.push("lint");
|
|
8290
|
-
if (output.didDeadCodeFail) skippedChecks.push("dead-code");
|
|
8291
|
-
const skippedCheckReasons = {};
|
|
8292
|
-
if (output.didLintFail && output.lintFailureReason !== null) skippedCheckReasons.lint = output.lintFailureReason;
|
|
8293
|
-
else if (output.lintPartialFailures.length > 0) skippedCheckReasons["lint:partial"] = output.lintPartialFailures.join("; ");
|
|
8294
|
-
if (output.didDeadCodeFail && output.deadCodeFailureReason !== null) skippedCheckReasons["dead-code"] = output.deadCodeFailureReason;
|
|
8442
|
+
const { skippedChecks, skippedCheckReasons } = buildSkippedChecks(output);
|
|
8295
8443
|
return {
|
|
8296
8444
|
diagnostics: [...output.diagnostics],
|
|
8297
8445
|
score: output.score,
|
|
@@ -8303,8 +8451,8 @@ const outputToDiagnoseResult = (output, elapsedMilliseconds) => {
|
|
|
8303
8451
|
};
|
|
8304
8452
|
const diagnose = async (directory, options = {}) => {
|
|
8305
8453
|
const startTime = globalThis.performance.now();
|
|
8306
|
-
const program = buildInspectProgram(resolveScanTarget(directory), options);
|
|
8307
|
-
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(
|
|
8454
|
+
const program = buildInspectProgram(await resolveScanTarget(directory), options);
|
|
8455
|
+
return outputToDiagnoseResult(await Effect.runPromise(restoreLegacyThrow(program.pipe(Effect.provide(buildDiagnoseLayer()), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
|
|
8308
8456
|
};
|
|
8309
8457
|
//#endregion
|
|
8310
8458
|
//#region src/index.ts
|