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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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: `false`
104
- * — only `"error"`-severity findings reach any surface (CLI, PR comment,
105
- * score, `--fail-on`).
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 `true` to surface every warning on every surface. This is the
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 `react-doctor.config.json`
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 are hidden unless this (or
424
- * the config) opts them back in.
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 `react-doctor.config.json` for this project's scan.
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/can-oxlint-extend-config.d.ts
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
- "react-doctor.config.json",
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 CONFIG_FILENAME = "react-doctor.config.json";
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 loadConfigFromDirectory = (directory) => {
4452
- const configFilePath = path.join(directory, CONFIG_FILENAME);
4453
- if (isFile(configFilePath)) try {
4454
- const fileContent = fs.readFileSync(configFilePath, "utf-8");
4455
- const parsed = JSON.parse(fileContent);
4456
- if (isPlainObject(parsed)) return {
4457
- config: validateConfigTypes(parsed),
4458
- sourceDirectory: directory
4459
- };
4460
- warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
4461
- } catch (error) {
4462
- warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
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
- config: validateConfigTypes(embeddedConfig),
4472
- sourceDirectory: directory
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
- return null;
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 loadConfigWithSource = (rootDirectory) => {
4485
- const cached = cachedConfigs.get(rootDirectory);
4486
- if (cached !== void 0) return cached;
4487
- const localConfig = loadConfigFromDirectory(rootDirectory);
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 ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
4499
- if (ancestorConfig) {
4500
- cachedConfigs.set(rootDirectory, ancestorConfig);
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 `react-doctor.config.(json|js)` / `package.json#reactDoctor`
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.sync(() => {
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
- for (const batch of fileBatches) {
7189
- let batchFileIndex = 0;
7190
- const progressInterval = onFileProgress && batch.length > 1 ? setInterval(() => {
7191
- if (batchFileIndex < batch.length) {
7192
- batchFileIndex += 1;
7193
- onFileProgress(scannedFileCount + batchFileIndex, totalFileCount);
7194
- }
7195
- }, 50) : null;
7196
- try {
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?.(scannedFileCount, totalFileCount);
7201
- } finally {
7202
- if (progressInterval !== null) clearInterval(progressInterval);
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 ?? false;
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 DEFAULT_LAYER = Layer.mergeAll(Project.layerNode, Config.layerNode, DeadCode.layerNode, Files.layerNode, Git.layerNode, Linter.layerOxlint, LintPartialFailures.layerLive, Progress.layerNoop, Reporter.layerNoop, Score.layerHttp);
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 ?? false,
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(DEFAULT_LAYER), Effect.provide(layerOtlp)))), globalThis.performance.now() - startTime);
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