react-doctor 0.6.2-dev.b4faf74 → 0.6.2-dev.d8628d7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="eb5a21fd-fd10-5eb2-9ce3-11e38406dc1d")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="d9bbcb80-9ea3-576e-8f35-8215ed87eca7")}catch(e){}}();
3
3
  import { createRequire } from "node:module";
4
4
  import * as NodeChildProcess from "node:child_process";
5
5
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
@@ -38678,7 +38678,7 @@ const resolveAutoScanConcurrency = (facts = readSystemFacts$1()) => {
38678
38678
  return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
38679
38679
  };
38680
38680
  const resolveLintBatchOrdering = () => {
38681
- return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "cost" ? "cost" : "arrival";
38681
+ return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "arrival" ? "arrival" : "cost";
38682
38682
  };
38683
38683
  /**
38684
38684
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -38775,17 +38775,19 @@ var DeadCodeOverlap = class extends Reference("react-doctor/DeadCodeOverlap", {
38775
38775
  return "auto";
38776
38776
  } }) {};
38777
38777
  /**
38778
- * How the full-scan lint pass orders its file batches. `"arrival"` (the
38779
- * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
38780
- * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
38781
- * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
38782
- * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
38783
- * batch is a straggler (and can trip the per-batch timeout + split), measurably
38784
- * regressing the common full-scan case. LPT needs the heavy files SPREAD across
38785
- * batches before `cost` earns the default. Tests override via
38786
- * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
38787
- * — they pass user-scoped `includePaths` that skip discovery and stay in
38788
- * arrival order; only the full-scan branch reads it.
38778
+ * How the full-scan lint pass plans its file batches. `"cost"` (the default)
38779
+ * builds size-balanced LPT batches (`planLintBatches`): the same mandatory
38780
+ * batch count as greedy chunking (`ceil(files / 100)`), but every batch gets
38781
+ * an even share of files AND bytes, so no 100-file chunk is a straggler while
38782
+ * the remainder-batch worker idles — and the heavy files are SPREAD across
38783
+ * batches, the precondition the old sort-desc-then-chunk-100 `cost` mode
38784
+ * lacked (it packed the heaviest files into one wave-1 straggler batch,
38785
+ * measurably regressing size-skewed repos, which is why it never earned the
38786
+ * default). `"arrival"` (`REACT_DOCTOR_LINT_BATCH_ORDERING=arrival`) is the
38787
+ * rollback hatch to plain greedy 100-file chunking in discovery order. Tests
38788
+ * override via `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans
38789
+ * never reach this — they pass user-scoped `includePaths` that skip discovery
38790
+ * and stay in arrival order; only the full-scan branch reads it.
38789
38791
  */
38790
38792
  var LintBatchOrdering = class extends Reference("react-doctor/LintBatchOrdering", { defaultValue: resolveLintBatchOrdering }) {};
38791
38793
  const CACHE_DISABLED_VALUES$1 = new Set(["1", "true"]);
@@ -43075,6 +43077,70 @@ const hashFileContents = (filePath) => {
43075
43077
  return null;
43076
43078
  }
43077
43079
  };
43080
+ /**
43081
+ * Balanced LPT (longest-processing-time-first) lint batch planner. Where
43082
+ * `batchIncludePaths` greedily fills 100-file batches and leaves a small
43083
+ * remainder batch (617 files → 6×100 + 17, so the parallel pool's wall clock
43084
+ * is set by whichever 100-file chunk happened to collect the most work while
43085
+ * the remainder's worker idles), this planner keeps the SAME mandatory batch
43086
+ * count — `ceil(files / OXLINT_MAX_FILES_PER_BATCH)`, the JS-plugin
43087
+ * perf-cliff / native-binding SIGABRT guard — and assigns files largest-first,
43088
+ * each to the least-loaded batch by cumulative byte size. Every batch ends up
43089
+ * with an even share of files AND bytes, so no batch is a straggler; heavy
43090
+ * files are SPREAD across batches (the documented precondition for `cost`
43091
+ * ordering to beat `arrival`) instead of concentrated in one.
43092
+ *
43093
+ * Deliberately NOT worker-count-aware: splitting into extra batches to feed
43094
+ * idle workers was measured (619-file corpus, 10 cores) to REGRESS the lint
43095
+ * wall — each additional concurrent subprocess pays a contended cold start
43096
+ * (node boot + plugin import + native dlopen, ~180 ms uncontended and 2-3×
43097
+ * that in a full wave) that outweighs the smaller per-batch file share.
43098
+ *
43099
+ * Every batch also respects the spawn-args char budget; a file that fits no
43100
+ * existing batch opens a new one, mirroring `batchIncludePaths`. Within a
43101
+ * batch, files return in arrival order, and the assignment is fully
43102
+ * deterministic (ties broken by lowest batch index), so a plan is
43103
+ * reproducible for a fixed (files, sizes) input. Batch composition cannot
43104
+ * change diagnostics — rules that look beyond one file (`CROSS_FILE_RULE_IDS`)
43105
+ * read the OTHER files via the filesystem, never via batch-mates — so the
43106
+ * plan only moves wall-clock time.
43107
+ *
43108
+ * O(files × batches); the linear least-loaded scan is fine at real-world
43109
+ * scale (a 3,500-file monorepo is 36 batches ≈ 126k comparisons).
43110
+ */
43111
+ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
43112
+ if (files.length === 0) return [];
43113
+ const batchCount = Math.ceil(files.length / 100);
43114
+ const maxFilesPerBatch = Math.ceil(files.length / batchCount);
43115
+ const baseArgsLengthChars = estimateArgsLength(baseArgs);
43116
+ const descendingSizeIndexes = [...files.keys()].sort((leftIndex, rightIndex) => (sizeByFile.get(files[rightIndex]) ?? 0) - (sizeByFile.get(files[leftIndex]) ?? 0));
43117
+ const batches = Array.from({ length: batchCount }, () => ({
43118
+ fileIndexes: [],
43119
+ totalSizeBytes: 0,
43120
+ argsLengthChars: baseArgsLengthChars
43121
+ }));
43122
+ for (const fileIndex of descendingSizeIndexes) {
43123
+ const entryLengthChars = files[fileIndex].length + 1;
43124
+ let targetBatch = null;
43125
+ for (const batch of batches) {
43126
+ if (batch.fileIndexes.length >= maxFilesPerBatch) continue;
43127
+ if (batch.fileIndexes.length > 0 && batch.argsLengthChars + entryLengthChars > 24e3) continue;
43128
+ if (targetBatch === null || batch.totalSizeBytes < targetBatch.totalSizeBytes || batch.totalSizeBytes === targetBatch.totalSizeBytes && batch.fileIndexes.length < targetBatch.fileIndexes.length) targetBatch = batch;
43129
+ }
43130
+ if (targetBatch === null) {
43131
+ targetBatch = {
43132
+ fileIndexes: [],
43133
+ totalSizeBytes: 0,
43134
+ argsLengthChars: baseArgsLengthChars
43135
+ };
43136
+ batches.push(targetBatch);
43137
+ }
43138
+ targetBatch.fileIndexes.push(fileIndex);
43139
+ targetBatch.totalSizeBytes += sizeByFile.get(files[fileIndex]) ?? 0;
43140
+ targetBatch.argsLengthChars += entryLengthChars;
43141
+ }
43142
+ return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
43143
+ };
43078
43144
  const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
43079
43145
  const resolveReactDoctorCacheDir = (projectDirectory) => {
43080
43146
  const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
@@ -43090,7 +43156,6 @@ const resolveUserCacheScope = () => {
43090
43156
  return "shared";
43091
43157
  }
43092
43158
  };
43093
- const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
43094
43159
  /**
43095
43160
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
43096
43161
  * the runner: once for the primary scan, once for the
@@ -43149,7 +43214,7 @@ const reactHooksJsPluginDropNote = (error) => {
43149
43214
  * 6. always restore disable directives + clean up the temp dir
43150
43215
  */
43151
43216
  const runOxlint = async (options) => {
43152
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, onCacheStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "arrival" } = options;
43217
+ const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, onCacheStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "cost" } = options;
43153
43218
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
43154
43219
  const severityControls = buildRuleSeverityControls(userConfig);
43155
43220
  validateRuleRegistration();
@@ -43201,8 +43266,14 @@ const runOxlint = async (options) => {
43201
43266
  "json",
43202
43267
  ...sharedArgs
43203
43268
  ];
43204
- const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
43205
- const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
43269
+ const sizedScanFiles = includePaths === void 0 ? listSourceFilesWithSize(rootDirectory) : null;
43270
+ const candidateFiles = includePaths !== void 0 ? includePaths : (sizedScanFiles ?? []).map((entry) => entry.path);
43271
+ const sizeByFile = sizedScanFiles !== null && lintBatchOrdering === "cost" ? new Map(sizedScanFiles.map((entry) => [entry.path, entry.sizeBytes])) : null;
43272
+ const buildFileBatches = (passBaseArgs, passFiles) => sizeByFile !== null ? planLintBatches({
43273
+ baseArgs: passBaseArgs,
43274
+ files: passFiles,
43275
+ sizeByFile
43276
+ }) : batchIncludePaths(passBaseArgs, passFiles);
43206
43277
  const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
43207
43278
  if (files.length === 0) return {
43208
43279
  diagnostics: [],
@@ -43216,7 +43287,7 @@ const runOxlint = async (options) => {
43216
43287
  };
43217
43288
  const passConfigPath = Path.join(configDirectory, configFileName);
43218
43289
  const passBaseArgs = makeBaseArgs(passConfigPath);
43219
- const passFileBatches = batchIncludePaths(passBaseArgs, files);
43290
+ const passFileBatches = buildFileBatches(passBaseArgs, files);
43220
43291
  const spawnPass = () => spawnLintBatches({
43221
43292
  baseArgs: passBaseArgs,
43222
43293
  fileBatches: passFileBatches,
@@ -43318,7 +43389,7 @@ const runOxlint = async (options) => {
43318
43389
  ]);
43319
43390
  }
43320
43391
  const baseArgs = makeBaseArgs(configPath);
43321
- const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
43392
+ const fileBatches = buildFileBatches(baseArgs, candidateFiles);
43322
43393
  const runBatches = () => spawnLintBatches({
43323
43394
  baseArgs,
43324
43395
  fileBatches,
@@ -45233,7 +45304,7 @@ const makeNoopConsole = () => ({
45233
45304
  });
45234
45305
  //#endregion
45235
45306
  //#region src/cli/utils/version.ts
45236
- const VERSION = "0.6.2-dev.b4faf74";
45307
+ const VERSION = "0.6.2-dev.d8628d7";
45237
45308
  //#endregion
45238
45309
  //#region src/cli/utils/json-mode.ts
45239
45310
  let context = null;
@@ -45602,13 +45673,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
45602
45673
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
45603
45674
  * standard `SENTRY_RELEASE` override.
45604
45675
  */
45605
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.6.2-dev.b4faf74`;
45676
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.6.2-dev.d8628d7`;
45606
45677
  /**
45607
45678
  * Deployment environment shown in Sentry's environment filter. Defaults to
45608
45679
  * `production` for tagged releases and `development` for dev/unbuilt versions,
45609
45680
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
45610
45681
  */
45611
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.6.2-dev.b4faf74") ? "development" : "production");
45682
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.6.2-dev.d8628d7") ? "development" : "production");
45612
45683
  /**
45613
45684
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
45614
45685
  * (set to `0` to disable tracing) and falls back to
@@ -63591,4 +63662,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
63591
63662
  export {};
63592
63663
 
63593
63664
  //# sourceMappingURL=cli.js.map
63594
- //# debugId=eb5a21fd-fd10-5eb2-9ce3-11e38406dc1d
63665
+ //# debugId=d9bbcb80-9ea3-576e-8f35-8215ed87eca7
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="be2212ec-649e-55f5-a360-fc2eea17659d")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1b2a9bb2-d6bc-5c9b-afbb-5b639438c617")}catch(e){}}();
3
3
  import { r as __toESM$1, t as __commonJSMin$1 } from "./chunk-N93fKeF6.js";
4
4
  import { createRequire } from "node:module";
5
5
  import * as NFS from "node:fs";
@@ -35479,7 +35479,7 @@ const resolveAutoScanConcurrency = (facts = readSystemFacts$1()) => {
35479
35479
  return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
35480
35480
  };
35481
35481
  const resolveLintBatchOrdering = () => {
35482
- return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "cost" ? "cost" : "arrival";
35482
+ return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "arrival" ? "arrival" : "cost";
35483
35483
  };
35484
35484
  /**
35485
35485
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -35576,17 +35576,19 @@ var DeadCodeOverlap = class extends Reference("react-doctor/DeadCodeOverlap", {
35576
35576
  return "auto";
35577
35577
  } }) {};
35578
35578
  /**
35579
- * How the full-scan lint pass orders its file batches. `"arrival"` (the
35580
- * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
35581
- * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
35582
- * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
35583
- * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
35584
- * batch is a straggler (and can trip the per-batch timeout + split), measurably
35585
- * regressing the common full-scan case. LPT needs the heavy files SPREAD across
35586
- * batches before `cost` earns the default. Tests override via
35587
- * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
35588
- * — they pass user-scoped `includePaths` that skip discovery and stay in
35589
- * arrival order; only the full-scan branch reads it.
35579
+ * How the full-scan lint pass plans its file batches. `"cost"` (the default)
35580
+ * builds size-balanced LPT batches (`planLintBatches`): the same mandatory
35581
+ * batch count as greedy chunking (`ceil(files / 100)`), but every batch gets
35582
+ * an even share of files AND bytes, so no 100-file chunk is a straggler while
35583
+ * the remainder-batch worker idles — and the heavy files are SPREAD across
35584
+ * batches, the precondition the old sort-desc-then-chunk-100 `cost` mode
35585
+ * lacked (it packed the heaviest files into one wave-1 straggler batch,
35586
+ * measurably regressing size-skewed repos, which is why it never earned the
35587
+ * default). `"arrival"` (`REACT_DOCTOR_LINT_BATCH_ORDERING=arrival`) is the
35588
+ * rollback hatch to plain greedy 100-file chunking in discovery order. Tests
35589
+ * override via `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans
35590
+ * never reach this — they pass user-scoped `includePaths` that skip discovery
35591
+ * and stay in arrival order; only the full-scan branch reads it.
35590
35592
  */
35591
35593
  var LintBatchOrdering = class extends Reference("react-doctor/LintBatchOrdering", { defaultValue: resolveLintBatchOrdering }) {};
35592
35594
  const CACHE_DISABLED_VALUES = new Set(["1", "true"]);
@@ -39832,6 +39834,70 @@ const hashFileContents = (filePath) => {
39832
39834
  return null;
39833
39835
  }
39834
39836
  };
39837
+ /**
39838
+ * Balanced LPT (longest-processing-time-first) lint batch planner. Where
39839
+ * `batchIncludePaths` greedily fills 100-file batches and leaves a small
39840
+ * remainder batch (617 files → 6×100 + 17, so the parallel pool's wall clock
39841
+ * is set by whichever 100-file chunk happened to collect the most work while
39842
+ * the remainder's worker idles), this planner keeps the SAME mandatory batch
39843
+ * count — `ceil(files / OXLINT_MAX_FILES_PER_BATCH)`, the JS-plugin
39844
+ * perf-cliff / native-binding SIGABRT guard — and assigns files largest-first,
39845
+ * each to the least-loaded batch by cumulative byte size. Every batch ends up
39846
+ * with an even share of files AND bytes, so no batch is a straggler; heavy
39847
+ * files are SPREAD across batches (the documented precondition for `cost`
39848
+ * ordering to beat `arrival`) instead of concentrated in one.
39849
+ *
39850
+ * Deliberately NOT worker-count-aware: splitting into extra batches to feed
39851
+ * idle workers was measured (619-file corpus, 10 cores) to REGRESS the lint
39852
+ * wall — each additional concurrent subprocess pays a contended cold start
39853
+ * (node boot + plugin import + native dlopen, ~180 ms uncontended and 2-3×
39854
+ * that in a full wave) that outweighs the smaller per-batch file share.
39855
+ *
39856
+ * Every batch also respects the spawn-args char budget; a file that fits no
39857
+ * existing batch opens a new one, mirroring `batchIncludePaths`. Within a
39858
+ * batch, files return in arrival order, and the assignment is fully
39859
+ * deterministic (ties broken by lowest batch index), so a plan is
39860
+ * reproducible for a fixed (files, sizes) input. Batch composition cannot
39861
+ * change diagnostics — rules that look beyond one file (`CROSS_FILE_RULE_IDS`)
39862
+ * read the OTHER files via the filesystem, never via batch-mates — so the
39863
+ * plan only moves wall-clock time.
39864
+ *
39865
+ * O(files × batches); the linear least-loaded scan is fine at real-world
39866
+ * scale (a 3,500-file monorepo is 36 batches ≈ 126k comparisons).
39867
+ */
39868
+ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
39869
+ if (files.length === 0) return [];
39870
+ const batchCount = Math.ceil(files.length / 100);
39871
+ const maxFilesPerBatch = Math.ceil(files.length / batchCount);
39872
+ const baseArgsLengthChars = estimateArgsLength(baseArgs);
39873
+ const descendingSizeIndexes = [...files.keys()].sort((leftIndex, rightIndex) => (sizeByFile.get(files[rightIndex]) ?? 0) - (sizeByFile.get(files[leftIndex]) ?? 0));
39874
+ const batches = Array.from({ length: batchCount }, () => ({
39875
+ fileIndexes: [],
39876
+ totalSizeBytes: 0,
39877
+ argsLengthChars: baseArgsLengthChars
39878
+ }));
39879
+ for (const fileIndex of descendingSizeIndexes) {
39880
+ const entryLengthChars = files[fileIndex].length + 1;
39881
+ let targetBatch = null;
39882
+ for (const batch of batches) {
39883
+ if (batch.fileIndexes.length >= maxFilesPerBatch) continue;
39884
+ if (batch.fileIndexes.length > 0 && batch.argsLengthChars + entryLengthChars > 24e3) continue;
39885
+ if (targetBatch === null || batch.totalSizeBytes < targetBatch.totalSizeBytes || batch.totalSizeBytes === targetBatch.totalSizeBytes && batch.fileIndexes.length < targetBatch.fileIndexes.length) targetBatch = batch;
39886
+ }
39887
+ if (targetBatch === null) {
39888
+ targetBatch = {
39889
+ fileIndexes: [],
39890
+ totalSizeBytes: 0,
39891
+ argsLengthChars: baseArgsLengthChars
39892
+ };
39893
+ batches.push(targetBatch);
39894
+ }
39895
+ targetBatch.fileIndexes.push(fileIndex);
39896
+ targetBatch.totalSizeBytes += sizeByFile.get(files[fileIndex]) ?? 0;
39897
+ targetBatch.argsLengthChars += entryLengthChars;
39898
+ }
39899
+ return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
39900
+ };
39835
39901
  const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
39836
39902
  const resolveReactDoctorCacheDir = (projectDirectory) => {
39837
39903
  const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
@@ -39847,7 +39913,6 @@ const resolveUserCacheScope = () => {
39847
39913
  return "shared";
39848
39914
  }
39849
39915
  };
39850
- const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
39851
39916
  /**
39852
39917
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39853
39918
  * the runner: once for the primary scan, once for the
@@ -39906,7 +39971,7 @@ const reactHooksJsPluginDropNote = (error) => {
39906
39971
  * 6. always restore disable directives + clean up the temp dir
39907
39972
  */
39908
39973
  const runOxlint = async (options) => {
39909
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, onCacheStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "arrival" } = options;
39974
+ const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, onCacheStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "cost" } = options;
39910
39975
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39911
39976
  const severityControls = buildRuleSeverityControls(userConfig);
39912
39977
  validateRuleRegistration();
@@ -39958,8 +40023,14 @@ const runOxlint = async (options) => {
39958
40023
  "json",
39959
40024
  ...sharedArgs
39960
40025
  ];
39961
- const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
39962
- const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
40026
+ const sizedScanFiles = includePaths === void 0 ? listSourceFilesWithSize(rootDirectory) : null;
40027
+ const candidateFiles = includePaths !== void 0 ? includePaths : (sizedScanFiles ?? []).map((entry) => entry.path);
40028
+ const sizeByFile = sizedScanFiles !== null && lintBatchOrdering === "cost" ? new Map(sizedScanFiles.map((entry) => [entry.path, entry.sizeBytes])) : null;
40029
+ const buildFileBatches = (passBaseArgs, passFiles) => sizeByFile !== null ? planLintBatches({
40030
+ baseArgs: passBaseArgs,
40031
+ files: passFiles,
40032
+ sizeByFile
40033
+ }) : batchIncludePaths(passBaseArgs, passFiles);
39963
40034
  const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
39964
40035
  if (files.length === 0) return {
39965
40036
  diagnostics: [],
@@ -39973,7 +40044,7 @@ const runOxlint = async (options) => {
39973
40044
  };
39974
40045
  const passConfigPath = Path.join(configDirectory, configFileName);
39975
40046
  const passBaseArgs = makeBaseArgs(passConfigPath);
39976
- const passFileBatches = batchIncludePaths(passBaseArgs, files);
40047
+ const passFileBatches = buildFileBatches(passBaseArgs, files);
39977
40048
  const spawnPass = () => spawnLintBatches({
39978
40049
  baseArgs: passBaseArgs,
39979
40050
  fileBatches: passFileBatches,
@@ -40075,7 +40146,7 @@ const runOxlint = async (options) => {
40075
40146
  ]);
40076
40147
  }
40077
40148
  const baseArgs = makeBaseArgs(configPath);
40078
- const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
40149
+ const fileBatches = buildFileBatches(baseArgs, candidateFiles);
40079
40150
  const runBatches = () => spawnLintBatches({
40080
40151
  baseArgs,
40081
40152
  fileBatches,
@@ -41759,4 +41830,4 @@ const toJsonReport = (result, options) => buildJsonReport({
41759
41830
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
41760
41831
 
41761
41832
  //# sourceMappingURL=index.js.map
41762
- //# debugId=be2212ec-649e-55f5-a360-fc2eea17659d
41833
+ //# debugId=1b2a9bb2-d6bc-5c9b-afbb-5b639438c617
package/dist/lsp.js CHANGED
@@ -35512,7 +35512,7 @@ const resolveAutoScanConcurrency = (facts = readSystemFacts$1()) => {
35512
35512
  return resolveScanConcurrency(Math.min(facts.availableCores, memoryBoundedWorkers));
35513
35513
  };
35514
35514
  const resolveLintBatchOrdering = () => {
35515
- return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "cost" ? "cost" : "arrival";
35515
+ return process.env["REACT_DOCTOR_LINT_BATCH_ORDERING"]?.trim().toLowerCase() === "arrival" ? "arrival" : "cost";
35516
35516
  };
35517
35517
  /**
35518
35518
  * Per-batch oxlint wall-clock budget. Reads from the env var on
@@ -35609,17 +35609,19 @@ var DeadCodeOverlap = class extends Reference("react-doctor/DeadCodeOverlap", {
35609
35609
  return "auto";
35610
35610
  } }) {};
35611
35611
  /**
35612
- * How the full-scan lint pass orders its file batches. `"arrival"` (the
35613
- * default) keeps `git ls-files` discovery order. `"cost"` opts into LPT (feed
35614
- * the largest files first); set `REACT_DOCTOR_LINT_BATCH_ORDERING=cost`. NOTE:
35615
- * `cost` is OFF by default because the current sort-desc-then-chunk-100 packs
35616
- * the heaviest files into one wave-1 batch — on size-skewed repos that mega-
35617
- * batch is a straggler (and can trip the per-batch timeout + split), measurably
35618
- * regressing the common full-scan case. LPT needs the heavy files SPREAD across
35619
- * batches before `cost` earns the default. Tests override via
35620
- * `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans never reach this
35621
- * — they pass user-scoped `includePaths` that skip discovery and stay in
35622
- * arrival order; only the full-scan branch reads it.
35612
+ * How the full-scan lint pass plans its file batches. `"cost"` (the default)
35613
+ * builds size-balanced LPT batches (`planLintBatches`): the same mandatory
35614
+ * batch count as greedy chunking (`ceil(files / 100)`), but every batch gets
35615
+ * an even share of files AND bytes, so no 100-file chunk is a straggler while
35616
+ * the remainder-batch worker idles — and the heavy files are SPREAD across
35617
+ * batches, the precondition the old sort-desc-then-chunk-100 `cost` mode
35618
+ * lacked (it packed the heaviest files into one wave-1 straggler batch,
35619
+ * measurably regressing size-skewed repos, which is why it never earned the
35620
+ * default). `"arrival"` (`REACT_DOCTOR_LINT_BATCH_ORDERING=arrival`) is the
35621
+ * rollback hatch to plain greedy 100-file chunking in discovery order. Tests
35622
+ * override via `Layer.succeed(LintBatchOrdering, ...)`. Diff / staged scans
35623
+ * never reach this — they pass user-scoped `includePaths` that skip discovery
35624
+ * and stay in arrival order; only the full-scan branch reads it.
35623
35625
  */
35624
35626
  var LintBatchOrdering = class extends Reference("react-doctor/LintBatchOrdering", { defaultValue: resolveLintBatchOrdering }) {};
35625
35627
  const CACHE_DISABLED_VALUES = new Set(["1", "true"]);
@@ -39817,6 +39819,70 @@ const hashFileContents = (filePath) => {
39817
39819
  return null;
39818
39820
  }
39819
39821
  };
39822
+ /**
39823
+ * Balanced LPT (longest-processing-time-first) lint batch planner. Where
39824
+ * `batchIncludePaths` greedily fills 100-file batches and leaves a small
39825
+ * remainder batch (617 files → 6×100 + 17, so the parallel pool's wall clock
39826
+ * is set by whichever 100-file chunk happened to collect the most work while
39827
+ * the remainder's worker idles), this planner keeps the SAME mandatory batch
39828
+ * count — `ceil(files / OXLINT_MAX_FILES_PER_BATCH)`, the JS-plugin
39829
+ * perf-cliff / native-binding SIGABRT guard — and assigns files largest-first,
39830
+ * each to the least-loaded batch by cumulative byte size. Every batch ends up
39831
+ * with an even share of files AND bytes, so no batch is a straggler; heavy
39832
+ * files are SPREAD across batches (the documented precondition for `cost`
39833
+ * ordering to beat `arrival`) instead of concentrated in one.
39834
+ *
39835
+ * Deliberately NOT worker-count-aware: splitting into extra batches to feed
39836
+ * idle workers was measured (619-file corpus, 10 cores) to REGRESS the lint
39837
+ * wall — each additional concurrent subprocess pays a contended cold start
39838
+ * (node boot + plugin import + native dlopen, ~180 ms uncontended and 2-3×
39839
+ * that in a full wave) that outweighs the smaller per-batch file share.
39840
+ *
39841
+ * Every batch also respects the spawn-args char budget; a file that fits no
39842
+ * existing batch opens a new one, mirroring `batchIncludePaths`. Within a
39843
+ * batch, files return in arrival order, and the assignment is fully
39844
+ * deterministic (ties broken by lowest batch index), so a plan is
39845
+ * reproducible for a fixed (files, sizes) input. Batch composition cannot
39846
+ * change diagnostics — rules that look beyond one file (`CROSS_FILE_RULE_IDS`)
39847
+ * read the OTHER files via the filesystem, never via batch-mates — so the
39848
+ * plan only moves wall-clock time.
39849
+ *
39850
+ * O(files × batches); the linear least-loaded scan is fine at real-world
39851
+ * scale (a 3,500-file monorepo is 36 batches ≈ 126k comparisons).
39852
+ */
39853
+ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
39854
+ if (files.length === 0) return [];
39855
+ const batchCount = Math.ceil(files.length / 100);
39856
+ const maxFilesPerBatch = Math.ceil(files.length / batchCount);
39857
+ const baseArgsLengthChars = estimateArgsLength(baseArgs);
39858
+ const descendingSizeIndexes = [...files.keys()].sort((leftIndex, rightIndex) => (sizeByFile.get(files[rightIndex]) ?? 0) - (sizeByFile.get(files[leftIndex]) ?? 0));
39859
+ const batches = Array.from({ length: batchCount }, () => ({
39860
+ fileIndexes: [],
39861
+ totalSizeBytes: 0,
39862
+ argsLengthChars: baseArgsLengthChars
39863
+ }));
39864
+ for (const fileIndex of descendingSizeIndexes) {
39865
+ const entryLengthChars = files[fileIndex].length + 1;
39866
+ let targetBatch = null;
39867
+ for (const batch of batches) {
39868
+ if (batch.fileIndexes.length >= maxFilesPerBatch) continue;
39869
+ if (batch.fileIndexes.length > 0 && batch.argsLengthChars + entryLengthChars > 24e3) continue;
39870
+ if (targetBatch === null || batch.totalSizeBytes < targetBatch.totalSizeBytes || batch.totalSizeBytes === targetBatch.totalSizeBytes && batch.fileIndexes.length < targetBatch.fileIndexes.length) targetBatch = batch;
39871
+ }
39872
+ if (targetBatch === null) {
39873
+ targetBatch = {
39874
+ fileIndexes: [],
39875
+ totalSizeBytes: 0,
39876
+ argsLengthChars: baseArgsLengthChars
39877
+ };
39878
+ batches.push(targetBatch);
39879
+ }
39880
+ targetBatch.fileIndexes.push(fileIndex);
39881
+ targetBatch.totalSizeBytes += sizeByFile.get(files[fileIndex]) ?? 0;
39882
+ targetBatch.argsLengthChars += entryLengthChars;
39883
+ }
39884
+ return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
39885
+ };
39820
39886
  const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
39821
39887
  const resolveReactDoctorCacheDir = (projectDirectory) => {
39822
39888
  const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
@@ -39832,7 +39898,6 @@ const resolveUserCacheScope = () => {
39832
39898
  return "shared";
39833
39899
  }
39834
39900
  };
39835
- const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
39836
39901
  /**
39837
39902
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39838
39903
  * the runner: once for the primary scan, once for the
@@ -39891,7 +39956,7 @@ const reactHooksJsPluginDropNote = (error) => {
39891
39956
  * 6. always restore disable directives + clean up the temp dir
39892
39957
  */
39893
39958
  const runOxlint = async (options) => {
39894
- const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, onCacheStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "arrival" } = options;
39959
+ const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), userConfig, configSourceDirectory = rootDirectory, onPartialFailure, perFileLintCacheEnabled = false, onCacheStats, spawnTimeoutMs, outputMaxBytes, lintBatchOrdering = "cost" } = options;
39895
39960
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39896
39961
  const severityControls = buildRuleSeverityControls(userConfig);
39897
39962
  validateRuleRegistration();
@@ -39943,8 +40008,14 @@ const runOxlint = async (options) => {
39943
40008
  "json",
39944
40009
  ...sharedArgs
39945
40010
  ];
39946
- const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
39947
- const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
40011
+ const sizedScanFiles = includePaths === void 0 ? listSourceFilesWithSize(rootDirectory) : null;
40012
+ const candidateFiles = includePaths !== void 0 ? includePaths : (sizedScanFiles ?? []).map((entry) => entry.path);
40013
+ const sizeByFile = sizedScanFiles !== null && lintBatchOrdering === "cost" ? new Map(sizedScanFiles.map((entry) => [entry.path, entry.sizeBytes])) : null;
40014
+ const buildFileBatches = (passBaseArgs, passFiles) => sizeByFile !== null ? planLintBatches({
40015
+ baseArgs: passBaseArgs,
40016
+ files: passFiles,
40017
+ sizeByFile
40018
+ }) : batchIncludePaths(passBaseArgs, passFiles);
39948
40019
  const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
39949
40020
  if (files.length === 0) return {
39950
40021
  diagnostics: [],
@@ -39958,7 +40029,7 @@ const runOxlint = async (options) => {
39958
40029
  };
39959
40030
  const passConfigPath = Path.join(configDirectory, configFileName);
39960
40031
  const passBaseArgs = makeBaseArgs(passConfigPath);
39961
- const passFileBatches = batchIncludePaths(passBaseArgs, files);
40032
+ const passFileBatches = buildFileBatches(passBaseArgs, files);
39962
40033
  const spawnPass = () => spawnLintBatches({
39963
40034
  baseArgs: passBaseArgs,
39964
40035
  fileBatches: passFileBatches,
@@ -40060,7 +40131,7 @@ const runOxlint = async (options) => {
40060
40131
  ]);
40061
40132
  }
40062
40133
  const baseArgs = makeBaseArgs(configPath);
40063
- const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
40134
+ const fileBatches = buildFileBatches(baseArgs, candidateFiles);
40064
40135
  const runBatches = () => spawnLintBatches({
40065
40136
  baseArgs,
40066
40137
  fileBatches,
@@ -43483,5 +43554,5 @@ const startLanguageServer = () => {
43483
43554
  };
43484
43555
  //#endregion
43485
43556
  export { startLanguageServer };
43486
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="9ba45115-9e66-529f-a5b1-8a80c03af114")}catch(e){}}();
43487
- //# debugId=9ba45115-9e66-529f-a5b1-8a80c03af114
43557
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="75579803-66de-5f78-8ded-3ed584f2e100")}catch(e){}}();
43558
+ //# debugId=75579803-66de-5f78-8ded-3ed584f2e100
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor",
3
- "version": "0.6.2-dev.b4faf74",
3
+ "version": "0.6.2-dev.d8628d7",
4
4
  "description": "Your agent writes bad React. This catches it",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -66,7 +66,7 @@
66
66
  "vscode-uri": "^3.1.0",
67
67
  "yaml": "^2.9.0",
68
68
  "deslop-js": "0.6.2",
69
- "oxlint-plugin-react-doctor": "0.6.2-dev.b4faf74"
69
+ "oxlint-plugin-react-doctor": "0.6.2-dev.d8628d7"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/babel__code-frame": "^7.27.0",
@@ -74,9 +74,9 @@
74
74
  "@xterm/headless": "^6.0.0",
75
75
  "commander": "^14.0.3",
76
76
  "ora": "^9.4.0",
77
- "@react-doctor/core": "0.6.2",
78
77
  "@react-doctor/api": "0.6.2",
79
- "@react-doctor/language-server": "0.6.2"
78
+ "@react-doctor/language-server": "0.6.2",
79
+ "@react-doctor/core": "0.6.2"
80
80
  },
81
81
  "engines": {
82
82
  "node": "^20.19.0 || >=22.13.0"