react-doctor 0.6.1 → 0.6.2-dev.173cc0a

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.
Files changed (4) hide show
  1. package/dist/cli.js +132 -44
  2. package/dist/index.js +129 -41
  3. package/dist/lsp.js +130 -42
  4. package/package.json +6 -6
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]="a28b8f59-6a38-551a-b87f-c7af571b4588")}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]="2921b8ff-14cf-5604-8ae5-b65b4f1347fc")}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"]);
@@ -40170,6 +40172,7 @@ function* collectSecurityScanFiles(rootDirectory) {
40170
40172
  isGeneratedBundleByName: classification.isGeneratedBundleByName
40171
40173
  });
40172
40174
  }
40175
+ yield null;
40173
40176
  }
40174
40177
  for (const candidates of [
40175
40178
  priorityCandidates,
@@ -40273,31 +40276,39 @@ const createSecurityScanSession = (rootDirectory, options) => {
40273
40276
  }
40274
40277
  return status === true;
40275
40278
  };
40276
- const scanFile = (file) => {
40277
- for (const { entry, scan, committedFilesOnly } of enabledScanRules) for (const finding of scan(file)) {
40278
- if (committedFilesOnly && isFileGitIgnored(file)) continue;
40279
- const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
40280
- const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
40281
- if (seen.has(key)) continue;
40282
- seen.add(key);
40283
- diagnostics.push(diagnostic);
40279
+ const scanFileByRule = function* (file) {
40280
+ for (const { entry, scan, committedFilesOnly } of enabledScanRules) {
40281
+ for (const finding of scan(file)) {
40282
+ if (committedFilesOnly && isFileGitIgnored(file)) continue;
40283
+ const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
40284
+ const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
40285
+ if (seen.has(key)) continue;
40286
+ seen.add(key);
40287
+ diagnostics.push(diagnostic);
40288
+ }
40289
+ yield;
40284
40290
  }
40285
40291
  };
40286
40292
  return {
40287
- scanFile,
40293
+ scanFileByRule,
40288
40294
  diagnostics
40289
40295
  };
40290
40296
  };
40291
40297
  const checkSecurityScanCooperative = async (rootDirectory, options = {}) => {
40292
40298
  const session = createSecurityScanSession(rootDirectory, options);
40293
40299
  if (session === null) return [];
40294
- let filesSinceYield = 0;
40300
+ let sliceStartedAt = performance.now();
40295
40301
  for (const file of collectSecurityScanFiles(rootDirectory)) {
40296
- session.scanFile(file);
40297
- filesSinceYield += 1;
40298
- if (filesSinceYield >= 16) {
40299
- filesSinceYield = 0;
40302
+ if (file === null) {
40303
+ if (performance.now() - sliceStartedAt >= 12) {
40304
+ await yieldToEventLoop();
40305
+ sliceStartedAt = performance.now();
40306
+ }
40307
+ continue;
40308
+ }
40309
+ for (const _ruleStep of session.scanFileByRule(file)) if (performance.now() - sliceStartedAt >= 12) {
40300
40310
  await yieldToEventLoop();
40311
+ sliceStartedAt = performance.now();
40301
40312
  }
40302
40313
  }
40303
40314
  return session.diagnostics;
@@ -43076,6 +43087,70 @@ const hashFileContents = (filePath) => {
43076
43087
  return null;
43077
43088
  }
43078
43089
  };
43090
+ /**
43091
+ * Balanced LPT (longest-processing-time-first) lint batch planner. Where
43092
+ * `batchIncludePaths` greedily fills 100-file batches and leaves a small
43093
+ * remainder batch (617 files → 6×100 + 17, so the parallel pool's wall clock
43094
+ * is set by whichever 100-file chunk happened to collect the most work while
43095
+ * the remainder's worker idles), this planner keeps the SAME mandatory batch
43096
+ * count — `ceil(files / OXLINT_MAX_FILES_PER_BATCH)`, the JS-plugin
43097
+ * perf-cliff / native-binding SIGABRT guard — and assigns files largest-first,
43098
+ * each to the least-loaded batch by cumulative byte size. Every batch ends up
43099
+ * with an even share of files AND bytes, so no batch is a straggler; heavy
43100
+ * files are SPREAD across batches (the documented precondition for `cost`
43101
+ * ordering to beat `arrival`) instead of concentrated in one.
43102
+ *
43103
+ * Deliberately NOT worker-count-aware: splitting into extra batches to feed
43104
+ * idle workers was measured (619-file corpus, 10 cores) to REGRESS the lint
43105
+ * wall — each additional concurrent subprocess pays a contended cold start
43106
+ * (node boot + plugin import + native dlopen, ~180 ms uncontended and 2-3×
43107
+ * that in a full wave) that outweighs the smaller per-batch file share.
43108
+ *
43109
+ * Every batch also respects the spawn-args char budget; a file that fits no
43110
+ * existing batch opens a new one, mirroring `batchIncludePaths`. Within a
43111
+ * batch, files return in arrival order, and the assignment is fully
43112
+ * deterministic (ties broken by lowest batch index), so a plan is
43113
+ * reproducible for a fixed (files, sizes) input. Batch composition cannot
43114
+ * change diagnostics — rules that look beyond one file (`CROSS_FILE_RULE_IDS`)
43115
+ * read the OTHER files via the filesystem, never via batch-mates — so the
43116
+ * plan only moves wall-clock time.
43117
+ *
43118
+ * O(files × batches); the linear least-loaded scan is fine at real-world
43119
+ * scale (a 3,500-file monorepo is 36 batches ≈ 126k comparisons).
43120
+ */
43121
+ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
43122
+ if (files.length === 0) return [];
43123
+ const batchCount = Math.ceil(files.length / 100);
43124
+ const maxFilesPerBatch = Math.ceil(files.length / batchCount);
43125
+ const baseArgsLengthChars = estimateArgsLength(baseArgs);
43126
+ const descendingSizeIndexes = [...files.keys()].sort((leftIndex, rightIndex) => (sizeByFile.get(files[rightIndex]) ?? 0) - (sizeByFile.get(files[leftIndex]) ?? 0));
43127
+ const batches = Array.from({ length: batchCount }, () => ({
43128
+ fileIndexes: [],
43129
+ totalSizeBytes: 0,
43130
+ argsLengthChars: baseArgsLengthChars
43131
+ }));
43132
+ for (const fileIndex of descendingSizeIndexes) {
43133
+ const entryLengthChars = files[fileIndex].length + 1;
43134
+ let targetBatch = null;
43135
+ for (const batch of batches) {
43136
+ if (batch.fileIndexes.length >= maxFilesPerBatch) continue;
43137
+ if (batch.fileIndexes.length > 0 && batch.argsLengthChars + entryLengthChars > 24e3) continue;
43138
+ if (targetBatch === null || batch.totalSizeBytes < targetBatch.totalSizeBytes || batch.totalSizeBytes === targetBatch.totalSizeBytes && batch.fileIndexes.length < targetBatch.fileIndexes.length) targetBatch = batch;
43139
+ }
43140
+ if (targetBatch === null) {
43141
+ targetBatch = {
43142
+ fileIndexes: [],
43143
+ totalSizeBytes: 0,
43144
+ argsLengthChars: baseArgsLengthChars
43145
+ };
43146
+ batches.push(targetBatch);
43147
+ }
43148
+ targetBatch.fileIndexes.push(fileIndex);
43149
+ targetBatch.totalSizeBytes += sizeByFile.get(files[fileIndex]) ?? 0;
43150
+ targetBatch.argsLengthChars += entryLengthChars;
43151
+ }
43152
+ return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
43153
+ };
43079
43154
  const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
43080
43155
  const resolveReactDoctorCacheDir = (projectDirectory) => {
43081
43156
  const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
@@ -43091,7 +43166,6 @@ const resolveUserCacheScope = () => {
43091
43166
  return "shared";
43092
43167
  }
43093
43168
  };
43094
- const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
43095
43169
  /**
43096
43170
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
43097
43171
  * the runner: once for the primary scan, once for the
@@ -43150,7 +43224,7 @@ const reactHooksJsPluginDropNote = (error) => {
43150
43224
  * 6. always restore disable directives + clean up the temp dir
43151
43225
  */
43152
43226
  const runOxlint = async (options) => {
43153
- 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;
43227
+ 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;
43154
43228
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
43155
43229
  const severityControls = buildRuleSeverityControls(userConfig);
43156
43230
  validateRuleRegistration();
@@ -43202,8 +43276,15 @@ const runOxlint = async (options) => {
43202
43276
  "json",
43203
43277
  ...sharedArgs
43204
43278
  ];
43205
- const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
43206
- const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
43279
+ await yieldToEventLoop();
43280
+ const sizedScanFiles = includePaths === void 0 ? listSourceFilesWithSize(rootDirectory) : null;
43281
+ const candidateFiles = includePaths !== void 0 ? includePaths : (sizedScanFiles ?? []).map((entry) => entry.path);
43282
+ const sizeByFile = sizedScanFiles !== null && lintBatchOrdering === "cost" ? new Map(sizedScanFiles.map((entry) => [entry.path, entry.sizeBytes])) : null;
43283
+ const buildFileBatches = (passBaseArgs, passFiles) => sizeByFile !== null ? planLintBatches({
43284
+ baseArgs: passBaseArgs,
43285
+ files: passFiles,
43286
+ sizeByFile
43287
+ }) : batchIncludePaths(passBaseArgs, passFiles);
43207
43288
  const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
43208
43289
  if (files.length === 0) return {
43209
43290
  diagnostics: [],
@@ -43217,7 +43298,7 @@ const runOxlint = async (options) => {
43217
43298
  };
43218
43299
  const passConfigPath = Path.join(configDirectory, configFileName);
43219
43300
  const passBaseArgs = makeBaseArgs(passConfigPath);
43220
- const passFileBatches = batchIncludePaths(passBaseArgs, files);
43301
+ const passFileBatches = buildFileBatches(passBaseArgs, files);
43221
43302
  const spawnPass = () => spawnLintBatches({
43222
43303
  baseArgs: passBaseArgs,
43223
43304
  fileBatches: passFileBatches,
@@ -43266,6 +43347,7 @@ const runOxlint = async (options) => {
43266
43347
  const cacheKeyByFile = /* @__PURE__ */ new Map();
43267
43348
  const missFiles = [];
43268
43349
  const replayedDiagnostics = [];
43350
+ let hashSliceStartedAt = performance.now();
43269
43351
  for (const candidateFile of candidateFiles) {
43270
43352
  const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
43271
43353
  if (contentHash === null) {
@@ -43277,23 +43359,29 @@ const runOxlint = async (options) => {
43277
43359
  const cachedDiagnostics = cache.lookup(cacheKey);
43278
43360
  if (cachedDiagnostics === null) missFiles.push(candidateFile);
43279
43361
  else replayedDiagnostics.push(...cachedDiagnostics);
43362
+ if (performance.now() - hashSliceStartedAt >= 12) {
43363
+ await yieldToEventLoop();
43364
+ hashSliceStartedAt = performance.now();
43365
+ }
43280
43366
  }
43281
43367
  const cacheHitFileCount = candidateFiles.length - missFiles.length;
43282
- const cacheableResult = await runConfigOverFiles((overrides) => buildConfig({
43368
+ const fullResult = await runConfigOverFiles((overrides) => buildConfig({
43283
43369
  extendsPaths: [],
43284
- ruleSelection: "cacheable",
43285
43370
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
43286
- }), "oxlintrc.cacheable.json", missFiles, void 0);
43371
+ }), "oxlintrc.full.json", missFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(scannedFileCount, candidateFiles.length)));
43372
+ const missFileSet = new Set(missFiles);
43287
43373
  const sidecarResult = await runConfigOverFiles(() => buildConfig({
43288
43374
  extendsPaths: [],
43289
43375
  ruleSelection: "sidecar"
43290
- }), "oxlintrc.sidecar.json", candidateFiles, options.onFileProgress);
43376
+ }), "oxlintrc.sidecar.json", candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile)), options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + scannedFileCount, candidateFiles.length)));
43377
+ const freshCacheableDiagnostics = [];
43378
+ for (const diagnostic of fullResult.diagnostics) if (!CROSS_FILE_RULE_IDS.has(diagnostic.rule)) freshCacheableDiagnostics.push(diagnostic);
43291
43379
  onCacheStats?.(cacheHitFileCount, candidateFiles.length);
43292
43380
  const missFileByNormalizedPath = /* @__PURE__ */ new Map();
43293
43381
  for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
43294
43382
  const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
43295
43383
  let isAttributionSound = true;
43296
- for (const diagnostic of cacheableResult.diagnostics) {
43384
+ for (const diagnostic of freshCacheableDiagnostics) {
43297
43385
  const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
43298
43386
  if (missFile === void 0) {
43299
43387
  isAttributionSound = false;
@@ -43303,7 +43391,7 @@ const runOxlint = async (options) => {
43303
43391
  fileDiagnostics.push(diagnostic);
43304
43392
  freshDiagnosticsByFile.set(missFile, fileDiagnostics);
43305
43393
  }
43306
- if (!cacheableResult.didDropReactHooksJsPlugin && !cacheableResult.hadPartialFailure && isAttributionSound) {
43394
+ if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && isAttributionSound) {
43307
43395
  for (const missFile of missFiles) {
43308
43396
  const cacheKey = cacheKeyByFile.get(missFile);
43309
43397
  if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
@@ -43312,12 +43400,12 @@ const runOxlint = async (options) => {
43312
43400
  }
43313
43401
  return dedupeDiagnostics([
43314
43402
  ...replayedDiagnostics,
43315
- ...cacheableResult.diagnostics,
43403
+ ...fullResult.diagnostics,
43316
43404
  ...sidecarResult.diagnostics
43317
43405
  ]);
43318
43406
  }
43319
43407
  const baseArgs = makeBaseArgs(configPath);
43320
- const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
43408
+ const fileBatches = buildFileBatches(baseArgs, candidateFiles);
43321
43409
  const runBatches = () => spawnLintBatches({
43322
43410
  baseArgs,
43323
43411
  fileBatches,
@@ -45232,7 +45320,7 @@ const makeNoopConsole = () => ({
45232
45320
  });
45233
45321
  //#endregion
45234
45322
  //#region src/cli/utils/version.ts
45235
- const VERSION = "0.6.1";
45323
+ const VERSION = "0.6.2-dev.173cc0a";
45236
45324
  //#endregion
45237
45325
  //#region src/cli/utils/json-mode.ts
45238
45326
  let context = null;
@@ -45601,13 +45689,13 @@ const isDevVersion = (version) => version === "0.0.0" || version.includes("-");
45601
45689
  * uploads source-map artifacts under, so stack frames symbolicate. Honors the
45602
45690
  * standard `SENTRY_RELEASE` override.
45603
45691
  */
45604
- const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.6.1`;
45692
+ const resolveSentryRelease = () => process.env.SENTRY_RELEASE || `react-doctor@0.6.2-dev.173cc0a`;
45605
45693
  /**
45606
45694
  * Deployment environment shown in Sentry's environment filter. Defaults to
45607
45695
  * `production` for tagged releases and `development` for dev/unbuilt versions,
45608
45696
  * overridable via the standard `SENTRY_ENVIRONMENT` env var.
45609
45697
  */
45610
- const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.6.1") ? "development" : "production");
45698
+ const resolveSentryEnvironment = () => process.env.SENTRY_ENVIRONMENT || (isDevVersion("0.6.2-dev.173cc0a") ? "development" : "production");
45611
45699
  /**
45612
45700
  * Performance-tracing sample rate in `[0, 1]`. Reads `SENTRY_TRACES_SAMPLE_RATE`
45613
45701
  * (set to `0` to disable tracing) and falls back to
@@ -63590,4 +63678,4 @@ Promise.resolve().then(() => assertNoRemovedFlags(process.argv)).then(() => prog
63590
63678
  export {};
63591
63679
 
63592
63680
  //# sourceMappingURL=cli.js.map
63593
- //# debugId=a28b8f59-6a38-551a-b87f-c7af571b4588
63681
+ //# debugId=2921b8ff-14cf-5604-8ae5-b65b4f1347fc
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]="62cf4740-7938-5db0-8b80-dc81b60af36a")}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]="8565c6ea-39f3-51e6-895a-ae9f7c026d0e")}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"]);
@@ -36943,6 +36945,7 @@ function* collectSecurityScanFiles(rootDirectory) {
36943
36945
  isGeneratedBundleByName: classification.isGeneratedBundleByName
36944
36946
  });
36945
36947
  }
36948
+ yield null;
36946
36949
  }
36947
36950
  for (const candidates of [
36948
36951
  priorityCandidates,
@@ -37046,31 +37049,39 @@ const createSecurityScanSession = (rootDirectory, options) => {
37046
37049
  }
37047
37050
  return status === true;
37048
37051
  };
37049
- const scanFile = (file) => {
37050
- for (const { entry, scan, committedFilesOnly } of enabledScanRules) for (const finding of scan(file)) {
37051
- if (committedFilesOnly && isFileGitIgnored(file)) continue;
37052
- const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
37053
- const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
37054
- if (seen.has(key)) continue;
37055
- seen.add(key);
37056
- diagnostics.push(diagnostic);
37052
+ const scanFileByRule = function* (file) {
37053
+ for (const { entry, scan, committedFilesOnly } of enabledScanRules) {
37054
+ for (const finding of scan(file)) {
37055
+ if (committedFilesOnly && isFileGitIgnored(file)) continue;
37056
+ const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
37057
+ const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
37058
+ if (seen.has(key)) continue;
37059
+ seen.add(key);
37060
+ diagnostics.push(diagnostic);
37061
+ }
37062
+ yield;
37057
37063
  }
37058
37064
  };
37059
37065
  return {
37060
- scanFile,
37066
+ scanFileByRule,
37061
37067
  diagnostics
37062
37068
  };
37063
37069
  };
37064
37070
  const checkSecurityScanCooperative = async (rootDirectory, options = {}) => {
37065
37071
  const session = createSecurityScanSession(rootDirectory, options);
37066
37072
  if (session === null) return [];
37067
- let filesSinceYield = 0;
37073
+ let sliceStartedAt = performance.now();
37068
37074
  for (const file of collectSecurityScanFiles(rootDirectory)) {
37069
- session.scanFile(file);
37070
- filesSinceYield += 1;
37071
- if (filesSinceYield >= 16) {
37072
- filesSinceYield = 0;
37075
+ if (file === null) {
37076
+ if (performance.now() - sliceStartedAt >= 12) {
37077
+ await yieldToEventLoop();
37078
+ sliceStartedAt = performance.now();
37079
+ }
37080
+ continue;
37081
+ }
37082
+ for (const _ruleStep of session.scanFileByRule(file)) if (performance.now() - sliceStartedAt >= 12) {
37073
37083
  await yieldToEventLoop();
37084
+ sliceStartedAt = performance.now();
37074
37085
  }
37075
37086
  }
37076
37087
  return session.diagnostics;
@@ -39833,6 +39844,70 @@ const hashFileContents = (filePath) => {
39833
39844
  return null;
39834
39845
  }
39835
39846
  };
39847
+ /**
39848
+ * Balanced LPT (longest-processing-time-first) lint batch planner. Where
39849
+ * `batchIncludePaths` greedily fills 100-file batches and leaves a small
39850
+ * remainder batch (617 files → 6×100 + 17, so the parallel pool's wall clock
39851
+ * is set by whichever 100-file chunk happened to collect the most work while
39852
+ * the remainder's worker idles), this planner keeps the SAME mandatory batch
39853
+ * count — `ceil(files / OXLINT_MAX_FILES_PER_BATCH)`, the JS-plugin
39854
+ * perf-cliff / native-binding SIGABRT guard — and assigns files largest-first,
39855
+ * each to the least-loaded batch by cumulative byte size. Every batch ends up
39856
+ * with an even share of files AND bytes, so no batch is a straggler; heavy
39857
+ * files are SPREAD across batches (the documented precondition for `cost`
39858
+ * ordering to beat `arrival`) instead of concentrated in one.
39859
+ *
39860
+ * Deliberately NOT worker-count-aware: splitting into extra batches to feed
39861
+ * idle workers was measured (619-file corpus, 10 cores) to REGRESS the lint
39862
+ * wall — each additional concurrent subprocess pays a contended cold start
39863
+ * (node boot + plugin import + native dlopen, ~180 ms uncontended and 2-3×
39864
+ * that in a full wave) that outweighs the smaller per-batch file share.
39865
+ *
39866
+ * Every batch also respects the spawn-args char budget; a file that fits no
39867
+ * existing batch opens a new one, mirroring `batchIncludePaths`. Within a
39868
+ * batch, files return in arrival order, and the assignment is fully
39869
+ * deterministic (ties broken by lowest batch index), so a plan is
39870
+ * reproducible for a fixed (files, sizes) input. Batch composition cannot
39871
+ * change diagnostics — rules that look beyond one file (`CROSS_FILE_RULE_IDS`)
39872
+ * read the OTHER files via the filesystem, never via batch-mates — so the
39873
+ * plan only moves wall-clock time.
39874
+ *
39875
+ * O(files × batches); the linear least-loaded scan is fine at real-world
39876
+ * scale (a 3,500-file monorepo is 36 batches ≈ 126k comparisons).
39877
+ */
39878
+ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
39879
+ if (files.length === 0) return [];
39880
+ const batchCount = Math.ceil(files.length / 100);
39881
+ const maxFilesPerBatch = Math.ceil(files.length / batchCount);
39882
+ const baseArgsLengthChars = estimateArgsLength(baseArgs);
39883
+ const descendingSizeIndexes = [...files.keys()].sort((leftIndex, rightIndex) => (sizeByFile.get(files[rightIndex]) ?? 0) - (sizeByFile.get(files[leftIndex]) ?? 0));
39884
+ const batches = Array.from({ length: batchCount }, () => ({
39885
+ fileIndexes: [],
39886
+ totalSizeBytes: 0,
39887
+ argsLengthChars: baseArgsLengthChars
39888
+ }));
39889
+ for (const fileIndex of descendingSizeIndexes) {
39890
+ const entryLengthChars = files[fileIndex].length + 1;
39891
+ let targetBatch = null;
39892
+ for (const batch of batches) {
39893
+ if (batch.fileIndexes.length >= maxFilesPerBatch) continue;
39894
+ if (batch.fileIndexes.length > 0 && batch.argsLengthChars + entryLengthChars > 24e3) continue;
39895
+ if (targetBatch === null || batch.totalSizeBytes < targetBatch.totalSizeBytes || batch.totalSizeBytes === targetBatch.totalSizeBytes && batch.fileIndexes.length < targetBatch.fileIndexes.length) targetBatch = batch;
39896
+ }
39897
+ if (targetBatch === null) {
39898
+ targetBatch = {
39899
+ fileIndexes: [],
39900
+ totalSizeBytes: 0,
39901
+ argsLengthChars: baseArgsLengthChars
39902
+ };
39903
+ batches.push(targetBatch);
39904
+ }
39905
+ targetBatch.fileIndexes.push(fileIndex);
39906
+ targetBatch.totalSizeBytes += sizeByFile.get(files[fileIndex]) ?? 0;
39907
+ targetBatch.argsLengthChars += entryLengthChars;
39908
+ }
39909
+ return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
39910
+ };
39836
39911
  const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
39837
39912
  const resolveReactDoctorCacheDir = (projectDirectory) => {
39838
39913
  const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
@@ -39848,7 +39923,6 @@ const resolveUserCacheScope = () => {
39848
39923
  return "shared";
39849
39924
  }
39850
39925
  };
39851
- const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
39852
39926
  /**
39853
39927
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39854
39928
  * the runner: once for the primary scan, once for the
@@ -39907,7 +39981,7 @@ const reactHooksJsPluginDropNote = (error) => {
39907
39981
  * 6. always restore disable directives + clean up the temp dir
39908
39982
  */
39909
39983
  const runOxlint = async (options) => {
39910
- 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;
39984
+ 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;
39911
39985
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39912
39986
  const severityControls = buildRuleSeverityControls(userConfig);
39913
39987
  validateRuleRegistration();
@@ -39959,8 +40033,15 @@ const runOxlint = async (options) => {
39959
40033
  "json",
39960
40034
  ...sharedArgs
39961
40035
  ];
39962
- const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
39963
- const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
40036
+ await yieldToEventLoop();
40037
+ const sizedScanFiles = includePaths === void 0 ? listSourceFilesWithSize(rootDirectory) : null;
40038
+ const candidateFiles = includePaths !== void 0 ? includePaths : (sizedScanFiles ?? []).map((entry) => entry.path);
40039
+ const sizeByFile = sizedScanFiles !== null && lintBatchOrdering === "cost" ? new Map(sizedScanFiles.map((entry) => [entry.path, entry.sizeBytes])) : null;
40040
+ const buildFileBatches = (passBaseArgs, passFiles) => sizeByFile !== null ? planLintBatches({
40041
+ baseArgs: passBaseArgs,
40042
+ files: passFiles,
40043
+ sizeByFile
40044
+ }) : batchIncludePaths(passBaseArgs, passFiles);
39964
40045
  const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
39965
40046
  if (files.length === 0) return {
39966
40047
  diagnostics: [],
@@ -39974,7 +40055,7 @@ const runOxlint = async (options) => {
39974
40055
  };
39975
40056
  const passConfigPath = Path.join(configDirectory, configFileName);
39976
40057
  const passBaseArgs = makeBaseArgs(passConfigPath);
39977
- const passFileBatches = batchIncludePaths(passBaseArgs, files);
40058
+ const passFileBatches = buildFileBatches(passBaseArgs, files);
39978
40059
  const spawnPass = () => spawnLintBatches({
39979
40060
  baseArgs: passBaseArgs,
39980
40061
  fileBatches: passFileBatches,
@@ -40023,6 +40104,7 @@ const runOxlint = async (options) => {
40023
40104
  const cacheKeyByFile = /* @__PURE__ */ new Map();
40024
40105
  const missFiles = [];
40025
40106
  const replayedDiagnostics = [];
40107
+ let hashSliceStartedAt = performance.now();
40026
40108
  for (const candidateFile of candidateFiles) {
40027
40109
  const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
40028
40110
  if (contentHash === null) {
@@ -40034,23 +40116,29 @@ const runOxlint = async (options) => {
40034
40116
  const cachedDiagnostics = cache.lookup(cacheKey);
40035
40117
  if (cachedDiagnostics === null) missFiles.push(candidateFile);
40036
40118
  else replayedDiagnostics.push(...cachedDiagnostics);
40119
+ if (performance.now() - hashSliceStartedAt >= 12) {
40120
+ await yieldToEventLoop();
40121
+ hashSliceStartedAt = performance.now();
40122
+ }
40037
40123
  }
40038
40124
  const cacheHitFileCount = candidateFiles.length - missFiles.length;
40039
- const cacheableResult = await runConfigOverFiles((overrides) => buildConfig({
40125
+ const fullResult = await runConfigOverFiles((overrides) => buildConfig({
40040
40126
  extendsPaths: [],
40041
- ruleSelection: "cacheable",
40042
40127
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
40043
- }), "oxlintrc.cacheable.json", missFiles, void 0);
40128
+ }), "oxlintrc.full.json", missFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(scannedFileCount, candidateFiles.length)));
40129
+ const missFileSet = new Set(missFiles);
40044
40130
  const sidecarResult = await runConfigOverFiles(() => buildConfig({
40045
40131
  extendsPaths: [],
40046
40132
  ruleSelection: "sidecar"
40047
- }), "oxlintrc.sidecar.json", candidateFiles, options.onFileProgress);
40133
+ }), "oxlintrc.sidecar.json", candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile)), options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + scannedFileCount, candidateFiles.length)));
40134
+ const freshCacheableDiagnostics = [];
40135
+ for (const diagnostic of fullResult.diagnostics) if (!CROSS_FILE_RULE_IDS.has(diagnostic.rule)) freshCacheableDiagnostics.push(diagnostic);
40048
40136
  onCacheStats?.(cacheHitFileCount, candidateFiles.length);
40049
40137
  const missFileByNormalizedPath = /* @__PURE__ */ new Map();
40050
40138
  for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
40051
40139
  const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
40052
40140
  let isAttributionSound = true;
40053
- for (const diagnostic of cacheableResult.diagnostics) {
40141
+ for (const diagnostic of freshCacheableDiagnostics) {
40054
40142
  const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
40055
40143
  if (missFile === void 0) {
40056
40144
  isAttributionSound = false;
@@ -40060,7 +40148,7 @@ const runOxlint = async (options) => {
40060
40148
  fileDiagnostics.push(diagnostic);
40061
40149
  freshDiagnosticsByFile.set(missFile, fileDiagnostics);
40062
40150
  }
40063
- if (!cacheableResult.didDropReactHooksJsPlugin && !cacheableResult.hadPartialFailure && isAttributionSound) {
40151
+ if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && isAttributionSound) {
40064
40152
  for (const missFile of missFiles) {
40065
40153
  const cacheKey = cacheKeyByFile.get(missFile);
40066
40154
  if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
@@ -40069,12 +40157,12 @@ const runOxlint = async (options) => {
40069
40157
  }
40070
40158
  return dedupeDiagnostics([
40071
40159
  ...replayedDiagnostics,
40072
- ...cacheableResult.diagnostics,
40160
+ ...fullResult.diagnostics,
40073
40161
  ...sidecarResult.diagnostics
40074
40162
  ]);
40075
40163
  }
40076
40164
  const baseArgs = makeBaseArgs(configPath);
40077
- const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
40165
+ const fileBatches = buildFileBatches(baseArgs, candidateFiles);
40078
40166
  const runBatches = () => spawnLintBatches({
40079
40167
  baseArgs,
40080
40168
  fileBatches,
@@ -41758,4 +41846,4 @@ const toJsonReport = (result, options) => buildJsonReport({
41758
41846
  export { AmbiguousProjectError, NoReactDependencyError, NotADirectoryError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, defineConfig, diagnose, filterSourceFiles, getDiffInfo, isProjectDiscoveryError, isReactDoctorError, summarizeDiagnostics, toJsonReport };
41759
41847
 
41760
41848
  //# sourceMappingURL=index.js.map
41761
- //# debugId=62cf4740-7938-5db0-8b80-dc81b60af36a
41849
+ //# debugId=8565c6ea-39f3-51e6-895a-ae9f7c026d0e
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"]);
@@ -36928,6 +36930,7 @@ function* collectSecurityScanFiles(rootDirectory) {
36928
36930
  isGeneratedBundleByName: classification.isGeneratedBundleByName
36929
36931
  });
36930
36932
  }
36933
+ yield null;
36931
36934
  }
36932
36935
  for (const candidates of [
36933
36936
  priorityCandidates,
@@ -37031,31 +37034,39 @@ const createSecurityScanSession = (rootDirectory, options) => {
37031
37034
  }
37032
37035
  return status === true;
37033
37036
  };
37034
- const scanFile = (file) => {
37035
- for (const { entry, scan, committedFilesOnly } of enabledScanRules) for (const finding of scan(file)) {
37036
- if (committedFilesOnly && isFileGitIgnored(file)) continue;
37037
- const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
37038
- const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
37039
- if (seen.has(key)) continue;
37040
- seen.add(key);
37041
- diagnostics.push(diagnostic);
37037
+ const scanFileByRule = function* (file) {
37038
+ for (const { entry, scan, committedFilesOnly } of enabledScanRules) {
37039
+ for (const finding of scan(file)) {
37040
+ if (committedFilesOnly && isFileGitIgnored(file)) continue;
37041
+ const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath);
37042
+ const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`;
37043
+ if (seen.has(key)) continue;
37044
+ seen.add(key);
37045
+ diagnostics.push(diagnostic);
37046
+ }
37047
+ yield;
37042
37048
  }
37043
37049
  };
37044
37050
  return {
37045
- scanFile,
37051
+ scanFileByRule,
37046
37052
  diagnostics
37047
37053
  };
37048
37054
  };
37049
37055
  const checkSecurityScanCooperative = async (rootDirectory, options = {}) => {
37050
37056
  const session = createSecurityScanSession(rootDirectory, options);
37051
37057
  if (session === null) return [];
37052
- let filesSinceYield = 0;
37058
+ let sliceStartedAt = performance.now();
37053
37059
  for (const file of collectSecurityScanFiles(rootDirectory)) {
37054
- session.scanFile(file);
37055
- filesSinceYield += 1;
37056
- if (filesSinceYield >= 16) {
37057
- filesSinceYield = 0;
37060
+ if (file === null) {
37061
+ if (performance.now() - sliceStartedAt >= 12) {
37062
+ await yieldToEventLoop();
37063
+ sliceStartedAt = performance.now();
37064
+ }
37065
+ continue;
37066
+ }
37067
+ for (const _ruleStep of session.scanFileByRule(file)) if (performance.now() - sliceStartedAt >= 12) {
37058
37068
  await yieldToEventLoop();
37069
+ sliceStartedAt = performance.now();
37059
37070
  }
37060
37071
  }
37061
37072
  return session.diagnostics;
@@ -39818,6 +39829,70 @@ const hashFileContents = (filePath) => {
39818
39829
  return null;
39819
39830
  }
39820
39831
  };
39832
+ /**
39833
+ * Balanced LPT (longest-processing-time-first) lint batch planner. Where
39834
+ * `batchIncludePaths` greedily fills 100-file batches and leaves a small
39835
+ * remainder batch (617 files → 6×100 + 17, so the parallel pool's wall clock
39836
+ * is set by whichever 100-file chunk happened to collect the most work while
39837
+ * the remainder's worker idles), this planner keeps the SAME mandatory batch
39838
+ * count — `ceil(files / OXLINT_MAX_FILES_PER_BATCH)`, the JS-plugin
39839
+ * perf-cliff / native-binding SIGABRT guard — and assigns files largest-first,
39840
+ * each to the least-loaded batch by cumulative byte size. Every batch ends up
39841
+ * with an even share of files AND bytes, so no batch is a straggler; heavy
39842
+ * files are SPREAD across batches (the documented precondition for `cost`
39843
+ * ordering to beat `arrival`) instead of concentrated in one.
39844
+ *
39845
+ * Deliberately NOT worker-count-aware: splitting into extra batches to feed
39846
+ * idle workers was measured (619-file corpus, 10 cores) to REGRESS the lint
39847
+ * wall — each additional concurrent subprocess pays a contended cold start
39848
+ * (node boot + plugin import + native dlopen, ~180 ms uncontended and 2-3×
39849
+ * that in a full wave) that outweighs the smaller per-batch file share.
39850
+ *
39851
+ * Every batch also respects the spawn-args char budget; a file that fits no
39852
+ * existing batch opens a new one, mirroring `batchIncludePaths`. Within a
39853
+ * batch, files return in arrival order, and the assignment is fully
39854
+ * deterministic (ties broken by lowest batch index), so a plan is
39855
+ * reproducible for a fixed (files, sizes) input. Batch composition cannot
39856
+ * change diagnostics — rules that look beyond one file (`CROSS_FILE_RULE_IDS`)
39857
+ * read the OTHER files via the filesystem, never via batch-mates — so the
39858
+ * plan only moves wall-clock time.
39859
+ *
39860
+ * O(files × batches); the linear least-loaded scan is fine at real-world
39861
+ * scale (a 3,500-file monorepo is 36 batches ≈ 126k comparisons).
39862
+ */
39863
+ const planLintBatches = ({ baseArgs, files, sizeByFile }) => {
39864
+ if (files.length === 0) return [];
39865
+ const batchCount = Math.ceil(files.length / 100);
39866
+ const maxFilesPerBatch = Math.ceil(files.length / batchCount);
39867
+ const baseArgsLengthChars = estimateArgsLength(baseArgs);
39868
+ const descendingSizeIndexes = [...files.keys()].sort((leftIndex, rightIndex) => (sizeByFile.get(files[rightIndex]) ?? 0) - (sizeByFile.get(files[leftIndex]) ?? 0));
39869
+ const batches = Array.from({ length: batchCount }, () => ({
39870
+ fileIndexes: [],
39871
+ totalSizeBytes: 0,
39872
+ argsLengthChars: baseArgsLengthChars
39873
+ }));
39874
+ for (const fileIndex of descendingSizeIndexes) {
39875
+ const entryLengthChars = files[fileIndex].length + 1;
39876
+ let targetBatch = null;
39877
+ for (const batch of batches) {
39878
+ if (batch.fileIndexes.length >= maxFilesPerBatch) continue;
39879
+ if (batch.fileIndexes.length > 0 && batch.argsLengthChars + entryLengthChars > 24e3) continue;
39880
+ if (targetBatch === null || batch.totalSizeBytes < targetBatch.totalSizeBytes || batch.totalSizeBytes === targetBatch.totalSizeBytes && batch.fileIndexes.length < targetBatch.fileIndexes.length) targetBatch = batch;
39881
+ }
39882
+ if (targetBatch === null) {
39883
+ targetBatch = {
39884
+ fileIndexes: [],
39885
+ totalSizeBytes: 0,
39886
+ argsLengthChars: baseArgsLengthChars
39887
+ };
39888
+ batches.push(targetBatch);
39889
+ }
39890
+ targetBatch.fileIndexes.push(fileIndex);
39891
+ targetBatch.totalSizeBytes += sizeByFile.get(files[fileIndex]) ?? 0;
39892
+ targetBatch.argsLengthChars += entryLengthChars;
39893
+ }
39894
+ return batches.filter((batch) => batch.fileIndexes.length > 0).map((batch) => batch.fileIndexes.sort((leftIndex, rightIndex) => leftIndex - rightIndex).map((fileIndex) => files[fileIndex]));
39895
+ };
39821
39896
  const projectCacheSubdir = (projectDirectory) => crypto.createHash("sha256").update(projectDirectory).digest("hex").slice(0, 16);
39822
39897
  const resolveReactDoctorCacheDir = (projectDirectory) => {
39823
39898
  const cacheDirOverride = process.env["REACT_DOCTOR_CACHE_DIR"]?.trim();
@@ -39833,7 +39908,6 @@ const resolveUserCacheScope = () => {
39833
39908
  return "shared";
39834
39909
  }
39835
39910
  };
39836
- const sortSourceFilesByCost = (entries) => [...entries].sort((left, right) => right.sizeBytes - left.sizeBytes).map((entry) => entry.path);
39837
39911
  /**
39838
39912
  * Atomically (re)writes the generated oxlintrc.json. Used twice in
39839
39913
  * the runner: once for the primary scan, once for the
@@ -39892,7 +39966,7 @@ const reactHooksJsPluginDropNote = (error) => {
39892
39966
  * 6. always restore disable directives + clean up the temp dir
39893
39967
  */
39894
39968
  const runOxlint = async (options) => {
39895
- 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;
39969
+ 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;
39896
39970
  const serverAuthFunctionNames = Array.isArray(userConfig?.serverAuthFunctionNames) ? userConfig.serverAuthFunctionNames.filter((entry) => typeof entry === "string" && entry.length > 0) : void 0;
39897
39971
  const severityControls = buildRuleSeverityControls(userConfig);
39898
39972
  validateRuleRegistration();
@@ -39944,8 +40018,15 @@ const runOxlint = async (options) => {
39944
40018
  "json",
39945
40019
  ...sharedArgs
39946
40020
  ];
39947
- const discoverScanFiles = () => lintBatchOrdering === "cost" ? sortSourceFilesByCost(listSourceFilesWithSize(rootDirectory)) : listSourceFiles(rootDirectory);
39948
- const candidateFiles = includePaths !== void 0 ? includePaths : discoverScanFiles();
40021
+ await yieldToEventLoop();
40022
+ const sizedScanFiles = includePaths === void 0 ? listSourceFilesWithSize(rootDirectory) : null;
40023
+ const candidateFiles = includePaths !== void 0 ? includePaths : (sizedScanFiles ?? []).map((entry) => entry.path);
40024
+ const sizeByFile = sizedScanFiles !== null && lintBatchOrdering === "cost" ? new Map(sizedScanFiles.map((entry) => [entry.path, entry.sizeBytes])) : null;
40025
+ const buildFileBatches = (passBaseArgs, passFiles) => sizeByFile !== null ? planLintBatches({
40026
+ baseArgs: passBaseArgs,
40027
+ files: passFiles,
40028
+ sizeByFile
40029
+ }) : batchIncludePaths(passBaseArgs, passFiles);
39949
40030
  const runConfigOverFiles = async (buildConfigForPass, configFileName, files, fileProgress) => {
39950
40031
  if (files.length === 0) return {
39951
40032
  diagnostics: [],
@@ -39959,7 +40040,7 @@ const runOxlint = async (options) => {
39959
40040
  };
39960
40041
  const passConfigPath = Path.join(configDirectory, configFileName);
39961
40042
  const passBaseArgs = makeBaseArgs(passConfigPath);
39962
- const passFileBatches = batchIncludePaths(passBaseArgs, files);
40043
+ const passFileBatches = buildFileBatches(passBaseArgs, files);
39963
40044
  const spawnPass = () => spawnLintBatches({
39964
40045
  baseArgs: passBaseArgs,
39965
40046
  fileBatches: passFileBatches,
@@ -40008,6 +40089,7 @@ const runOxlint = async (options) => {
40008
40089
  const cacheKeyByFile = /* @__PURE__ */ new Map();
40009
40090
  const missFiles = [];
40010
40091
  const replayedDiagnostics = [];
40092
+ let hashSliceStartedAt = performance.now();
40011
40093
  for (const candidateFile of candidateFiles) {
40012
40094
  const contentHash = hashFileContents(Path.resolve(rootDirectory, candidateFile));
40013
40095
  if (contentHash === null) {
@@ -40019,23 +40101,29 @@ const runOxlint = async (options) => {
40019
40101
  const cachedDiagnostics = cache.lookup(cacheKey);
40020
40102
  if (cachedDiagnostics === null) missFiles.push(candidateFile);
40021
40103
  else replayedDiagnostics.push(...cachedDiagnostics);
40104
+ if (performance.now() - hashSliceStartedAt >= 12) {
40105
+ await yieldToEventLoop();
40106
+ hashSliceStartedAt = performance.now();
40107
+ }
40022
40108
  }
40023
40109
  const cacheHitFileCount = candidateFiles.length - missFiles.length;
40024
- const cacheableResult = await runConfigOverFiles((overrides) => buildConfig({
40110
+ const fullResult = await runConfigOverFiles((overrides) => buildConfig({
40025
40111
  extendsPaths: [],
40026
- ruleSelection: "cacheable",
40027
40112
  disableReactHooksJsPlugin: overrides.disableReactHooksJsPlugin
40028
- }), "oxlintrc.cacheable.json", missFiles, void 0);
40113
+ }), "oxlintrc.full.json", missFiles, options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(scannedFileCount, candidateFiles.length)));
40114
+ const missFileSet = new Set(missFiles);
40029
40115
  const sidecarResult = await runConfigOverFiles(() => buildConfig({
40030
40116
  extendsPaths: [],
40031
40117
  ruleSelection: "sidecar"
40032
- }), "oxlintrc.sidecar.json", candidateFiles, options.onFileProgress);
40118
+ }), "oxlintrc.sidecar.json", candidateFiles.filter((candidateFile) => !missFileSet.has(candidateFile)), options.onFileProgress && ((scannedFileCount) => options.onFileProgress?.(missFiles.length + scannedFileCount, candidateFiles.length)));
40119
+ const freshCacheableDiagnostics = [];
40120
+ for (const diagnostic of fullResult.diagnostics) if (!CROSS_FILE_RULE_IDS.has(diagnostic.rule)) freshCacheableDiagnostics.push(diagnostic);
40033
40121
  onCacheStats?.(cacheHitFileCount, candidateFiles.length);
40034
40122
  const missFileByNormalizedPath = /* @__PURE__ */ new Map();
40035
40123
  for (const missFile of missFiles) missFileByNormalizedPath.set(missFile.replaceAll("\\", "/"), missFile);
40036
40124
  const freshDiagnosticsByFile = /* @__PURE__ */ new Map();
40037
40125
  let isAttributionSound = true;
40038
- for (const diagnostic of cacheableResult.diagnostics) {
40126
+ for (const diagnostic of freshCacheableDiagnostics) {
40039
40127
  const missFile = missFileByNormalizedPath.get(diagnostic.filePath);
40040
40128
  if (missFile === void 0) {
40041
40129
  isAttributionSound = false;
@@ -40045,7 +40133,7 @@ const runOxlint = async (options) => {
40045
40133
  fileDiagnostics.push(diagnostic);
40046
40134
  freshDiagnosticsByFile.set(missFile, fileDiagnostics);
40047
40135
  }
40048
- if (!cacheableResult.didDropReactHooksJsPlugin && !cacheableResult.hadPartialFailure && isAttributionSound) {
40136
+ if (!fullResult.didDropReactHooksJsPlugin && !fullResult.hadPartialFailure && isAttributionSound) {
40049
40137
  for (const missFile of missFiles) {
40050
40138
  const cacheKey = cacheKeyByFile.get(missFile);
40051
40139
  if (cacheKey !== void 0) cache.store(cacheKey, freshDiagnosticsByFile.get(missFile) ?? []);
@@ -40054,12 +40142,12 @@ const runOxlint = async (options) => {
40054
40142
  }
40055
40143
  return dedupeDiagnostics([
40056
40144
  ...replayedDiagnostics,
40057
- ...cacheableResult.diagnostics,
40145
+ ...fullResult.diagnostics,
40058
40146
  ...sidecarResult.diagnostics
40059
40147
  ]);
40060
40148
  }
40061
40149
  const baseArgs = makeBaseArgs(configPath);
40062
- const fileBatches = batchIncludePaths(baseArgs, candidateFiles);
40150
+ const fileBatches = buildFileBatches(baseArgs, candidateFiles);
40063
40151
  const runBatches = () => spawnLintBatches({
40064
40152
  baseArgs,
40065
40153
  fileBatches,
@@ -41432,7 +41520,7 @@ const computeConfigFingerprint = (projectDirectory, version) => {
41432
41520
  /** Display name used in client-facing messages and progress titles. */
41433
41521
  const SERVER_DISPLAY_NAME = "React Doctor";
41434
41522
  /** Server version reported in `serverInfo`; injected at build, `dev` from source. */
41435
- const SERVER_VERSION = "0.6.1";
41523
+ const SERVER_VERSION = "0.6.2";
41436
41524
  /** `Diagnostic.source` shown next to every published diagnostic. */
41437
41525
  const DIAGNOSTIC_SOURCE = "react-doctor";
41438
41526
  /**
@@ -43482,5 +43570,5 @@ const startLanguageServer = () => {
43482
43570
  };
43483
43571
  //#endregion
43484
43572
  export { startLanguageServer };
43485
- !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]="8f8aecec-ef3d-52f9-9f55-e02e7e4631ba")}catch(e){}}();
43486
- //# debugId=8f8aecec-ef3d-52f9-9f55-e02e7e4631ba
43573
+ !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]="ec0a0a63-f78d-58b0-abbd-1faf2b2f859a")}catch(e){}}();
43574
+ //# debugId=ec0a0a63-f78d-58b0-abbd-1faf2b2f859a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor",
3
- "version": "0.6.1",
3
+ "version": "0.6.2-dev.173cc0a",
4
4
  "description": "Your agent writes bad React. This catches it",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -65,8 +65,8 @@
65
65
  "vscode-languageserver-textdocument": "^1.0.12",
66
66
  "vscode-uri": "^3.1.0",
67
67
  "yaml": "^2.9.0",
68
- "deslop-js": "0.6.1",
69
- "oxlint-plugin-react-doctor": "0.6.1"
68
+ "deslop-js": "0.6.2",
69
+ "oxlint-plugin-react-doctor": "0.6.2-dev.173cc0a"
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/api": "0.6.1",
78
- "@react-doctor/core": "0.6.1",
79
- "@react-doctor/language-server": "0.6.1"
77
+ "@react-doctor/core": "0.6.2",
78
+ "@react-doctor/api": "0.6.2",
79
+ "@react-doctor/language-server": "0.6.2"
80
80
  },
81
81
  "engines": {
82
82
  "node": "^20.19.0 || >=22.13.0"