@isentinel/eslint-config 6.0.0 → 6.1.0

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/lint-cli.mjs CHANGED
@@ -1,18 +1,21 @@
1
1
  import { createRequire } from "node:module";
2
2
  import process$1 from "node:process";
3
3
  import { isPackageExists } from "local-pkg";
4
+ import { Buffer as Buffer$1 } from "node:buffer";
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
4
7
  import yargs from "yargs";
5
8
  import "find-up-simple";
6
- import fs from "node:fs";
7
- import path from "node:path";
9
+ import fs$1 from "node:fs";
8
10
  import "semver";
9
11
  import crypto from "node:crypto";
10
12
  import ansis from "ansis";
11
- import concurrently from "concurrently";
13
+ import { KillOnSignal, LogError, LogExit, LogOutput, Logger, createConcurrently } from "concurrently";
12
14
  import { execFileSync, spawn, spawnSync } from "node:child_process";
15
+ import os, { availableParallelism } from "node:os";
13
16
  import { Writable } from "node:stream";
17
+ import v8 from "node:v8";
14
18
  import { fileURLToPath } from "node:url";
15
- import os, { availableParallelism } from "node:os";
16
19
  import fileEntryCache from "file-entry-cache";
17
20
  //#region \0rolldown/runtime.js
18
21
  var __create = Object.create;
@@ -38,7 +41,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
38
41
  }) : target, mod));
39
42
  //#endregion
40
43
  //#region package.json
41
- var version = "6.0.0";
44
+ var version = "6.1.0";
42
45
  //#endregion
43
46
  //#region src/lint-cli/lib/cli/types.ts
44
47
  /**
@@ -48,6 +51,78 @@ var version = "6.0.0";
48
51
  var CliError = class extends Error {
49
52
  name = "CliError";
50
53
  };
54
+ //#endregion
55
+ //#region src/lint-cli/lib/cli/file-list.ts
56
+ /** The `--files0-from` value that names stdin rather than a file. */
57
+ const STDIN_SOURCE = "-";
58
+ /**
59
+ * Split a NUL-delimited, UTF-8 path list (the `git ls-files -z` / `find
60
+ * -print0` shape) into its paths.
61
+ *
62
+ * Every path is terminated by a NUL, though the last terminator may be left off
63
+ * as GNU's own `--files0-from` allows. Nothing else is interpreted: spaces,
64
+ * newlines, quotes, shell metacharacters and leading hyphens all stay part of
65
+ * the name. The bytes must be valid UTF-8 — a lossy decode would lint a file
66
+ * that is not the one selected — and no entry may be empty, since an empty
67
+ * path is never a file and most likely a broken producer.
68
+ *
69
+ * @param bytes - The raw list.
70
+ * @returns The paths, in list order. Empty for an empty list.
71
+ */
72
+ function decodeFileList(bytes) {
73
+ const decoder = new TextDecoder("utf-8", {
74
+ fatal: true,
75
+ ignoreBOM: true
76
+ });
77
+ let text;
78
+ try {
79
+ text = decoder.decode(bytes);
80
+ } catch {
81
+ throw new CliError("--files0-from: the file list is not valid UTF-8.");
82
+ }
83
+ if (text.length === 0) return [];
84
+ const entries = text.split("\0");
85
+ if (entries.at(-1) === "") entries.pop();
86
+ const empty = entries.indexOf("");
87
+ if (empty !== -1) throw new CliError(`--files0-from: entry ${empty + 1} of the file list is empty; expected NUL-terminated paths.`);
88
+ return entries;
89
+ }
90
+ /**
91
+ * Fill the run's paths from its `--files0-from` list, reading it once. Options
92
+ * without a list are returned as they are.
93
+ *
94
+ * An empty list stays empty: the caller treats it as nothing to lint, and it
95
+ * must never become the whole-tree default `.`.
96
+ *
97
+ * @param options - The parsed CLI options.
98
+ * @param cwd - The directory a relative list path resolves against.
99
+ * @param stdin - The stream `-` reads.
100
+ * @returns The options with `paths` set from the list.
101
+ * @rejects {CliError} When the list cannot be read or is malformed.
102
+ */
103
+ async function readFileList(options, cwd, stdin) {
104
+ const source = options.filesFrom;
105
+ if (source === void 0) return options;
106
+ const bytes = source === STDIN_SOURCE ? await readAll(stdin) : await readFile(source, cwd);
107
+ return {
108
+ ...options,
109
+ paths: decodeFileList(bytes)
110
+ };
111
+ }
112
+ async function readFile(source, cwd) {
113
+ try {
114
+ return await fs.readFile(path.resolve(cwd, source));
115
+ } catch (err) {
116
+ throw new CliError(`--files0-from: cannot read "${source}": ${err instanceof Error ? err.message : String(err)}`);
117
+ }
118
+ }
119
+ async function readAll(stream) {
120
+ const chunks = [];
121
+ for await (const chunk of stream) if (typeof chunk === "string") chunks.push(Buffer$1.from(chunk, "utf8"));
122
+ else if (chunk instanceof Uint8Array) chunks.push(Buffer$1.from(chunk));
123
+ else throw new CliError("--files0-from: stdin produced a non-byte chunk.");
124
+ return Buffer$1.concat(chunks);
125
+ }
51
126
  /**
52
127
  * The real TS/JS-family file extensions {@link GLOB_SRC_EXT} encodes (the glob
53
128
  * additionally matches non-existent combinations such as `cjsx`). Kept next to
@@ -288,6 +363,13 @@ function splitArgs(input) {
288
363
  //#endregion
289
364
  //#region src/lint-cli/lib/cli/options.ts
290
365
  /**
366
+ * The ESLint flags that make it lint source text read from its own stdin. The
367
+ * engine's stdin carries its argument list (see `bootstrap.ts`), so these can
368
+ * never work through the runner and are refused rather than silently linting
369
+ * empty input.
370
+ */
371
+ const STDIN_SOURCE_FLAGS = ["--stdin", "--stdin-filename"];
372
+ /**
291
373
  * Parse and validate an `isentinel-lint` argv slice (without the node/bin
292
374
  * prefix). Throws {@link CliError} on any invalid combination.
293
375
  *
@@ -305,7 +387,11 @@ function parseArguments(argv, environment = process$1.env) {
305
387
  const tail = separator === -1 ? [] : argv.slice(separator);
306
388
  const eslintExtract = extractValueOption(head, "eslint-args");
307
389
  const oxlintExtract = extractValueOption(eslintExtract.rest, "oxlint-args");
308
- const parsed = yargs([...oxlintExtract.rest, ...tail]).scriptName("isentinel-lint").parserConfiguration({
390
+ const filesExtract = extractValueOption(oxlintExtract.rest, "files0-from");
391
+ if (filesExtract.occurrences > 1) throw new CliError("Option --files0-from may only be given once.");
392
+ const filesFrom = filesExtract.value;
393
+ if (filesFrom?.length === 0) throw new CliError("Option --files0-from requires a path, or - for stdin.");
394
+ const parsed = yargs([...filesExtract.rest, ...tail]).scriptName("isentinel-lint").parserConfiguration({
309
395
  "boolean-negation": true,
310
396
  "populate--": true
311
397
  }).option("eslint", {
@@ -359,18 +445,22 @@ function parseArguments(argv, environment = process$1.env) {
359
445
  if (eslintOnly) eslintArgs.push(...passthrough);
360
446
  else oxlintArgs.push(...passthrough);
361
447
  }
448
+ const stdinFlag = eslintArgs.find(isStdinSourceFlag);
449
+ if (stdinFlag !== void 0) throw new CliError(`Cannot forward ${stdinFlag} to ESLint: isentinel-lint lints files, not source text on stdin. Run ESLint directly to lint stdin.`);
362
450
  const paths = parsed._.map(String).filter((value) => value.length > 0);
451
+ if (filesFrom !== void 0 && paths.length > 0) throw new CliError("Cannot combine --files0-from with positional paths; put every path in the list.");
363
452
  return {
364
453
  agents: parsed.agents ?? isInAgentSession(environment),
365
454
  cache: parsed.cache,
366
455
  concurrency: parseConcurrency(parsed.concurrency),
367
456
  eslint: eslintOnly,
368
457
  eslintArgs,
458
+ filesFrom,
369
459
  fix,
370
460
  oxlint: oxlintOnly,
371
461
  oxlintArgs,
372
462
  oxlintTypeAware: parsed.oxlintTypeAware,
373
- paths: paths.length > 0 ? paths : ["."],
463
+ paths: filesFrom !== void 0 || paths.length > 0 ? paths : ["."],
374
464
  print: parsed.print === true,
375
465
  typeAware
376
466
  };
@@ -389,6 +479,7 @@ function extractValueOption(argv, name) {
389
479
  const inline = `${flag}=`;
390
480
  const rest = [];
391
481
  let value;
482
+ let occurrences = 0;
392
483
  for (let index = 0; index < argv.length; index += 1) {
393
484
  const token = argv[index];
394
485
  if (token === void 0) continue;
@@ -396,20 +487,26 @@ function extractValueOption(argv, name) {
396
487
  const next = argv[index + 1];
397
488
  if (next === void 0) throw new CliError(`Option ${flag} requires a value.`);
398
489
  value = next;
490
+ occurrences += 1;
399
491
  index += 1;
400
492
  continue;
401
493
  }
402
494
  if (token.startsWith(inline)) {
403
495
  value = token.slice(inline.length);
496
+ occurrences += 1;
404
497
  continue;
405
498
  }
406
499
  rest.push(token);
407
500
  }
408
501
  return {
502
+ occurrences,
409
503
  rest,
410
504
  value
411
505
  };
412
506
  }
507
+ function isStdinSourceFlag(token) {
508
+ return STDIN_SOURCE_FLAGS.some((flag) => token === flag || token.startsWith(`${flag}=`));
509
+ }
413
510
  function parseConcurrency(value) {
414
511
  if (value === void 0) return;
415
512
  if (value === "off") return "off";
@@ -474,6 +571,188 @@ function resolveRunContext(cwd, environment, mutate) {
474
571
  };
475
572
  }
476
573
  //#endregion
574
+ //#region src/lint-cli/lib/plan/heap.ts
575
+ /**
576
+ * Environment variable that sets the heap of a program-building ESLint child.
577
+ */
578
+ const HEAP_ENV = "LINT_TYPED_HEAP_MB";
579
+ /**
580
+ * Share of the machine's memory the program-building workers may claim between
581
+ * them. The rest is left for the oxlint child, the fast pass, the editor and
582
+ * the OS, all of which run alongside the type-aware pass locally.
583
+ */
584
+ const HEAP_BUDGET_SHARE = .5;
585
+ const BYTES_PER_MB = 1024 * 1024;
586
+ const MAX_OLD_SPACE = /--max[-_]old[-_]space[-_]size\b/;
587
+ /**
588
+ * Read the machine's memory facts.
589
+ *
590
+ * @returns The available memory and V8's default heap limit, in MB.
591
+ */
592
+ function probeMemory() {
593
+ const total = os.totalmem();
594
+ const constrained = process$1.constrainedMemory();
595
+ return {
596
+ availableMb: Math.floor((constrained > 0 ? Math.min(total, constrained) : total) / BYTES_PER_MB),
597
+ defaultHeapMb: Math.floor(v8.getHeapStatistics().heap_size_limit / BYTES_PER_MB)
598
+ };
599
+ }
600
+ /**
601
+ * Resolve the heap setting from the environment. An invalid {@link HEAP_ENV}
602
+ * falls back to `auto`, as the other numeric knobs fall back to their
603
+ * defaults.
604
+ *
605
+ * @param environment - Where {@link HEAP_ENV} and `NODE_OPTIONS` are read.
606
+ * @returns Which of the four modes applies, with its size when fixed.
607
+ */
608
+ function resolveHeapSetting(environment) {
609
+ if (MAX_OLD_SPACE.test(environment["NODE_OPTIONS"] ?? "")) return { kind: "inherited" };
610
+ const raw = environment[HEAP_ENV]?.trim();
611
+ if (raw === "off") return { kind: "off" };
612
+ const mb = parseBoundedInteger(raw, 1);
613
+ return mb === void 0 ? { kind: "auto" } : {
614
+ kind: "fixed",
615
+ mb
616
+ };
617
+ }
618
+ /**
619
+ * The memory all program-building workers may claim together, in MB.
620
+ *
621
+ * @param memory - What the machine offers.
622
+ * @returns The budget share of the available memory, in MB.
623
+ */
624
+ function heapBudgetMb(memory) {
625
+ return Math.floor(memory.availableMb * HEAP_BUDGET_SHARE);
626
+ }
627
+ /**
628
+ * The most program-building workers the budget can hold, or `undefined` when
629
+ * the setting places no limit. Each worker is counted at the heap it may grow
630
+ * to: the requested size, or {@link AUTO_HEAP_FLOOR} times Node's default
631
+ * under `auto`. Never below one.
632
+ *
633
+ * @param budget - The setting and the machine it applies to.
634
+ * @returns How many workers fit, if the setting limits them.
635
+ */
636
+ function heapWorkerCap({ memory, setting }) {
637
+ if (setting.kind !== "auto" && setting.kind !== "fixed") return;
638
+ const perWorker = setting.kind === "fixed" ? setting.mb : memory.defaultHeapMb * 2;
639
+ return Math.max(1, Math.floor(heapBudgetMb(memory) / perWorker));
640
+ }
641
+ /**
642
+ * The heap each worker of a program-building pass gets, or `undefined` for
643
+ * Node's default. `auto` splits the budget across the workers and only raises
644
+ * the limit: a share below the default adds no flag.
645
+ *
646
+ * @param budget - The setting and the machine it applies to.
647
+ * @param concurrency - The pass's resolved concurrency (`off` lints on the
648
+ * main thread, which is one heap).
649
+ * @returns The size in MB to pass to Node, if it should change.
650
+ */
651
+ function heapPerWorkerMb({ memory, setting }, concurrency) {
652
+ if (setting.kind === "fixed") return setting.mb;
653
+ if (setting.kind !== "auto") return;
654
+ const share = Math.floor(heapBudgetMb(memory) / workerCount(concurrency));
655
+ return share > memory.defaultHeapMb ? share : void 0;
656
+ }
657
+ /**
658
+ * Pair the run's heap setting with the machine it applies to.
659
+ *
660
+ * @param environment - Where {@link HEAP_ENV} and `NODE_OPTIONS` are read.
661
+ * @param memory - What the machine offers.
662
+ * @returns Everything sizing needs to give a program-building pass its heap.
663
+ */
664
+ function resolveHeapBudget(environment, memory) {
665
+ return {
666
+ memory,
667
+ nodeOptions: environment["NODE_OPTIONS"],
668
+ setting: resolveHeapSetting(environment)
669
+ };
670
+ }
671
+ /**
672
+ * The worker cap of a program-building pass once the heap budget is applied.
673
+ * An explicit `LINT_MAX_WORKERS` is never tightened.
674
+ *
675
+ * @param maxWorkers - The pass's cap before the budget.
676
+ * @param explicit - Whether `LINT_MAX_WORKERS` set that cap.
677
+ * @param budget - The setting and the machine it applies to.
678
+ * @returns The cap to size the pass against.
679
+ */
680
+ function capWorkersForHeap(maxWorkers, explicit, budget) {
681
+ const cap = explicit ? void 0 : heapWorkerCap(budget);
682
+ return cap === void 0 ? maxWorkers : Math.min(maxWorkers, cap);
683
+ }
684
+ /**
685
+ * Plan the heap of one program-building child from its sizing.
686
+ *
687
+ * @param label - The child's label, for the `--print` line.
688
+ * @param budget - The setting and the machine it applies to.
689
+ * @param sizing - The child's concurrency and file count.
690
+ * @returns Its `NODE_OPTIONS`, its `--print` line and whether it is watched.
691
+ */
692
+ function planPassHeap(label, budget, sizing) {
693
+ const heapMb = heapPerWorkerMb(budget, sizing.concurrency);
694
+ return {
695
+ nodeOptions: heapNodeOptions(budget, heapMb),
696
+ note: describeHeapPlan(label, budget, sizing, heapMb),
697
+ watch: budget.setting.kind !== "off"
698
+ };
699
+ }
700
+ /**
701
+ * The number of heaps a concurrency value runs: `off` lints on the main
702
+ * thread, which is one.
703
+ *
704
+ * @param concurrency - A resolved ESLint concurrency.
705
+ * @returns How many threads lint.
706
+ */
707
+ function workerCount(concurrency) {
708
+ return concurrency === "off" ? 1 : concurrency;
709
+ }
710
+ /**
711
+ * The `NODE_OPTIONS` a program-building child runs under: the inherited value
712
+ * with the heap flag appended. Node's `worker_threads` take their heap limit
713
+ * from `--max-old-space-size` when it is set, so this one flag reaches every
714
+ * ESLint worker.
715
+ *
716
+ * @param budget - Carries the inherited `NODE_OPTIONS`.
717
+ * @param heapMb - The size in MB for each worker, if it should change.
718
+ * @returns The value to set, or `undefined` to leave `NODE_OPTIONS` alone.
719
+ */
720
+ function heapNodeOptions({ nodeOptions }, heapMb) {
721
+ if (heapMb === void 0) return;
722
+ const flag = `--max-old-space-size=${String(heapMb)}`;
723
+ const inherited = nodeOptions?.trim() ?? "";
724
+ return inherited.length > 0 ? `${inherited} ${flag}` : flag;
725
+ }
726
+ /**
727
+ * How the `--print` line names a heap the runner left alone.
728
+ *
729
+ * @param budget - Says whether the user's `NODE_OPTIONS` set it.
730
+ * @returns The user's heap, or Node's default with its size.
731
+ */
732
+ function unchangedHeap({ memory, setting }) {
733
+ return setting.kind === "inherited" ? "the NODE_OPTIONS heap" : `Node's default heap (${String(memory.defaultHeapMb)} MB)`;
734
+ }
735
+ /**
736
+ * Describe the memory plan of a program-building pass for `--print`.
737
+ *
738
+ * @param label - The pass's child label.
739
+ * @param budget - The setting and the machine it applies to.
740
+ * @param sizing - The pass's concurrency and file count.
741
+ * @param heapMb - The size given to each worker, if it changed.
742
+ * @returns One stderr line naming the workers, files per worker and heap.
743
+ */
744
+ function describeHeapPlan(label, budget, { concurrency, dirtyCount }, heapMb) {
745
+ const workers = workerCount(concurrency);
746
+ const heap = heapMb === void 0 ? unchangedHeap(budget) : `${String(heapMb)} MB heap`;
747
+ const source = {
748
+ auto: `auto, budget ${String(heapBudgetMb(budget.memory))} MB`,
749
+ fixed: HEAP_ENV,
750
+ inherited: "NODE_OPTIONS",
751
+ off: `${HEAP_ENV}=off`
752
+ }[budget.setting.kind];
753
+ return `isentinel-lint: ${label} pass: ${String(workers)} worker(s), ~${String(Math.ceil(dirtyCount / workers))} files per worker, ${heap} per worker (${source}).\n`;
754
+ }
755
+ //#endregion
477
756
  //#region src/lint-cli/lib/state.ts
478
757
  /**
479
758
  * Directory (relative to the project root) where the CLI keeps its persisted
@@ -514,7 +793,7 @@ function statePath(cwd, name, ...parts) {
514
793
  */
515
794
  function readFileIfPresent(filePath) {
516
795
  try {
517
- return fs.readFileSync(filePath, "utf8");
796
+ return fs$1.readFileSync(filePath, "utf8");
518
797
  } catch {
519
798
  return;
520
799
  }
@@ -555,12 +834,12 @@ function writeState(filePath, data) {
555
834
  })}\n`;
556
835
  const temporary = `${filePath}.${process$1.pid}.tmp`;
557
836
  try {
558
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
559
- fs.writeFileSync(temporary, content);
560
- fs.renameSync(temporary, filePath);
837
+ fs$1.mkdirSync(path.dirname(filePath), { recursive: true });
838
+ fs$1.writeFileSync(temporary, content);
839
+ fs$1.renameSync(temporary, filePath);
561
840
  } catch {
562
841
  try {
563
- fs.rmSync(temporary, { force: true });
842
+ fs$1.rmSync(temporary, { force: true });
564
843
  } catch {}
565
844
  }
566
845
  }
@@ -595,7 +874,7 @@ function swapState(filePath, value) {
595
874
  function touchState(filePath) {
596
875
  try {
597
876
  const now = Date.now() / 1e3;
598
- fs.utimesSync(filePath, now, now);
877
+ fs$1.utimesSync(filePath, now, now);
599
878
  } catch {}
600
879
  }
601
880
  /**
@@ -688,7 +967,23 @@ function resolveAgentsFormatter() {
688
967
  */
689
968
  function resolveIgnoredHelper() {
690
969
  const built = fileURLToPath(new URL("./lint-ignored.mjs", import.meta.url));
691
- return fs.existsSync(built) ? built : fileURLToPath(new URL("../../ignored-child.ts", import.meta.url));
970
+ return fs$1.existsSync(built) ? built : fileURLToPath(new URL("../../ignored-child.ts", import.meta.url));
971
+ }
972
+ /**
973
+ * Resolve the engine bootstrap shipped alongside this entry (built from
974
+ * `src/lint-cli/bootstrap.ts` to `lint-bootstrap.mjs`), which every engine
975
+ * child is launched through so its arguments travel over stdin.
976
+ *
977
+ * Falls back to the TypeScript source exactly as {@link resolveIgnoredHelper}
978
+ * does, for the same reason: the fixture tests run from `src`. Unlike that
979
+ * helper there is no degraded mode — without the bootstrap no engine runs — so
980
+ * the shipped path is checked by the integration tests against the build.
981
+ *
982
+ * @returns The absolute path to the bootstrap module.
983
+ */
984
+ function resolveBootstrap() {
985
+ const built = fileURLToPath(new URL("./lint-bootstrap.mjs", import.meta.url));
986
+ return fs$1.existsSync(built) ? built : fileURLToPath(new URL("../../bootstrap.ts", import.meta.url));
692
987
  }
693
988
  /**
694
989
  * The path a package's manifest declares as the bin of the package's own name,
@@ -735,65 +1030,33 @@ function resolveManifest(name, cwd) {
735
1030
  }
736
1031
  }
737
1032
  //#endregion
738
- //#region src/lint-cli/lib/exec/shell.ts
739
- const SAFE_TOKEN = /^[\w@+=:,./-]+$/;
1033
+ //#region src/lint-cli/lib/exec/execute.ts
740
1034
  /**
741
- * Render a command as a shell-equivalent line (env prefix + logical binary +
742
- * arguments) for `--print`. Uses POSIX quoting for stable, cross-platform
743
- * output.
744
- *
745
- * @param command - The child command to render.
746
- * @returns The shell-equivalent command line.
1035
+ * What V8 writes when a heap fills: `ERR_WORKER_OUT_OF_MEMORY` from a worker,
1036
+ * `JavaScript heap out of memory` from the main thread.
747
1037
  */
748
- function formatCommandLine(command) {
749
- const environmentPrefix = Object.entries(command.env).flatMap(([key, value]) => value === void 0 ? [] : [`${key}=${quotePosix(value)}`]).join(" ");
750
- const body = [command.bin, ...command.args].map(quotePosix).join(" ");
751
- return environmentPrefix.length > 0 ? `${environmentPrefix} ${body}` : body;
752
- }
1038
+ const OUT_OF_MEMORY = /ERR_WORKER_OUT_OF_MEMORY|heap out of memory/;
1039
+ /** The signals that cancel a run, matching `concurrently`'s `KillOnSignal`. */
1040
+ const CANCEL_SIGNALS = [
1041
+ "SIGHUP",
1042
+ "SIGINT",
1043
+ "SIGTERM"
1044
+ ];
753
1045
  /**
754
- * Build the command string concurrently runs through a shell. The tool is
755
- * launched via `node <binJs>` so no `.cmd`/`.ps1` shim quoting is involved.
756
- *
757
- * @param nodePath - Absolute path to the Node executable.
758
- * @param binJsPath - Absolute path to the tool's JavaScript entry.
759
- * @param args - The tool arguments.
760
- * @param platform - The platform whose shell quoting rules to apply.
761
- * @returns The shell command string.
1046
+ * The first cancelling signal received while this run's children were running.
1047
+ * Shared across `execute` calls on purpose: a run is a sequence of them (three
1048
+ * for a `--fix` run), and once the user has cancelled one of them none of
1049
+ * the rest may start. {@link resetCancellation} Scopes it to one run.
762
1050
  */
763
- function buildShellCommand(nodePath, binJsPath, args, platform) {
764
- const tokens = [
765
- nodePath,
766
- binJsPath,
767
- ...args
768
- ];
769
- if (platform === "win32") {
770
- const offending = tokens.find((token) => token.includes("%"));
771
- if (offending !== void 0) throw new CliError(`Cannot safely pass "${offending}" to cmd.exe: "%" triggers environment-variable expansion even inside quotes. Remove it, or run the tool directly.`);
772
- }
773
- const quote = platform === "win32" ? quoteWindows : quotePosix;
774
- return tokens.map(quote).join(" ");
775
- }
776
- function quotePosix(token) {
777
- if (token.length > 0 && SAFE_TOKEN.test(token)) return token;
778
- return `'${token.replaceAll("'", "'\\''")}'`;
779
- }
780
- function quoteWindows(token) {
781
- if (token.length > 0 && SAFE_TOKEN.test(token)) return token;
782
- let escaped = "";
783
- let slashes = 0;
784
- for (const char of token) {
785
- if (char === "\\") {
786
- slashes += 1;
787
- escaped += char;
788
- continue;
789
- }
790
- escaped += char === "\"" ? `${"\\".repeat(slashes)}\\"` : char;
791
- slashes = 0;
792
- }
793
- return `"${escaped}${"\\".repeat(slashes)}"`;
1051
+ let cancelledBy;
1052
+ /**
1053
+ * Forget any cancellation recorded by an earlier run in this process. Called
1054
+ * as a run starts, so a second `runLint` in the same process is not refused
1055
+ * for a signal the first one received.
1056
+ */
1057
+ function resetCancellation() {
1058
+ cancelledBy = void 0;
794
1059
  }
795
- //#endregion
796
- //#region src/lint-cli/lib/exec/execute.ts
797
1060
  /** Prefix colour per child label for `concurrently`; kept visually distinct. */
798
1061
  const PREFIX_COLOR = {
799
1062
  eslint: "blue",
@@ -802,6 +1065,17 @@ const PREFIX_COLOR = {
802
1065
  typed: "cyan"
803
1066
  };
804
1067
  /**
1068
+ * The hint printed after a program-building child ran out of heap, below the
1069
+ * engine's own stack trace.
1070
+ *
1071
+ * @param label - The child's label.
1072
+ * @returns One stderr line.
1073
+ */
1074
+ function outOfMemoryHint(label) {
1075
+ return `isentinel-lint: the ${label} ESLint pass ran out of memory. Set ${HEAP_ENV}=<mb> to give each worker more heap; the runner starts fewer workers to keep them in budget.
1076
+ `;
1077
+ }
1078
+ /**
805
1079
  * Run the composed children and aggregate their exit codes.
806
1080
  *
807
1081
  * Sequential when the caller asks for it: a lone child gains nothing from the
@@ -818,7 +1092,8 @@ const PREFIX_COLOR = {
818
1092
  * @returns The aggregated exit code.
819
1093
  */
820
1094
  async function execute(commands, cwd, sequential) {
821
- return sequential ? runSequential(commands, cwd) : startGroup(commands, cwd);
1095
+ const code = await (sequential ? runSequential(commands, cwd) : startGroup(commands, cwd));
1096
+ return cancelledCode() ?? code;
822
1097
  }
823
1098
  /**
824
1099
  * Run a staged set of children: spawn `commands` now, then resolve the rest and
@@ -845,13 +1120,19 @@ async function execute(commands, cwd, sequential) {
845
1120
  async function executeStaged(commands, cwd, resolveDeferred) {
846
1121
  const eager = startGroup(commands, cwd);
847
1122
  const deferred = resolveDeferred();
848
- if (deferred.length === 0) return eager;
1123
+ await new Promise((resolve) => {
1124
+ setImmediate(resolve);
1125
+ });
1126
+ if (cancelledBy !== void 0 || deferred.length === 0) {
1127
+ const eagerCode = await eager;
1128
+ return cancelledCode() ?? eagerCode;
1129
+ }
849
1130
  const held = createHeldOutput();
850
1131
  const later = startGroup(deferred, cwd, held.stream);
851
1132
  const eagerCode = await eager;
852
1133
  held.release();
853
1134
  const deferredCode = await later;
854
- return eagerCode === 0 ? deferredCode : eagerCode;
1135
+ return cancelledCode() ?? (eagerCode === 0 ? deferredCode : eagerCode);
855
1136
  }
856
1137
  /**
857
1138
  * Announce a child on stderr once it has been spawned. `concurrently` groups
@@ -867,30 +1148,188 @@ function announce(command) {
867
1148
  process$1.stderr.write(`${prefix} spawned ${command.bin}\n`);
868
1149
  }
869
1150
  /**
1151
+ * The names the children of one group are registered and logged under: the
1152
+ * engine, never its arguments. `concurrently` prints the name on each child's
1153
+ * exit line, so it must stay short however many files the child lints. The
1154
+ * line already carries the pass as its prefix (`[fast] eslint exited with code
1155
+ * 1`), so the pass only joins the name when two children share an engine.
1156
+ *
1157
+ * @param commands - The group's children.
1158
+ * @returns Each child paired with a name unique within the group.
1159
+ */
1160
+ function nameCommands(commands) {
1161
+ const taken = /* @__PURE__ */ new Set();
1162
+ return commands.map((command) => {
1163
+ const base = commands.filter((other) => other.bin === command.bin).length > 1 ? `${command.bin} (${command.label})` : command.bin;
1164
+ let name = base;
1165
+ let suffix = 1;
1166
+ while (taken.has(name)) {
1167
+ suffix += 1;
1168
+ name = `${base} #${String(suffix)}`;
1169
+ }
1170
+ taken.add(name);
1171
+ return [name, command];
1172
+ });
1173
+ }
1174
+ /**
1175
+ * Spawn an engine child through the bootstrap and stream it its arguments.
1176
+ *
1177
+ * The OS command line is always `node <bootstrap> <bin>`, with no shell, so its
1178
+ * length does not depend on the file list and no path is ever interpreted by a
1179
+ * shell. The complete argument list follows as JSON on the child's stdin,
1180
+ * which is why stdin is always a pipe here whatever the caller asked for.
1181
+ *
1182
+ * @param launch - The child and its resolved bin.
1183
+ * @param options - Spawn options (cwd, env, stdout/stderr handling).
1184
+ * @returns The spawned child.
1185
+ */
1186
+ function launchEngine({ bin, command }, options) {
1187
+ const stdio = Array.isArray(options.stdio) ? options.stdio : [];
1188
+ const child = spawn(process$1.execPath, [resolveBootstrap(), bin], {
1189
+ ...options,
1190
+ shell: false,
1191
+ stdio: [
1192
+ "pipe",
1193
+ stdio[1] ?? "inherit",
1194
+ stdio[2] ?? "inherit"
1195
+ ]
1196
+ });
1197
+ child.stdin?.on("error", () => {});
1198
+ child.stdin?.end(JSON.stringify(command.args));
1199
+ return child;
1200
+ }
1201
+ /**
1202
+ * Kill a group's children and stop it spawning more when its output stream
1203
+ * fails — typically `EPIPE` once the reader of a piped report (`| head`) has
1204
+ * gone. Mirrors `concurrently`'s own `OutputErrorHandler`, which its package
1205
+ * exports do not expose.
1206
+ *
1207
+ * @param outputStream - The stream the group writes to.
1208
+ * @param abortController - Aborts the group.
1209
+ * @returns The flow controller.
1210
+ */
1211
+ function killOnOutputError(outputStream, abortController) {
1212
+ return { handle: (commands) => {
1213
+ function onError() {
1214
+ for (const command of commands) command.kill();
1215
+ abortController.abort();
1216
+ }
1217
+ outputStream.on("error", onError);
1218
+ return {
1219
+ commands,
1220
+ onFinish: () => {
1221
+ outputStream.off("error", onError);
1222
+ }
1223
+ };
1224
+ } };
1225
+ }
1226
+ /**
1227
+ * Record a cancelling signal for as long as children are running, and hand it
1228
+ * to `onSignal`. Listening at all replaces Node's default of exiting on the
1229
+ * signal, which is what lets the children be stopped and reaped first rather
1230
+ * than orphaned.
1231
+ *
1232
+ * @param onSignal - Forwards the signal to the running children; omitted when
1233
+ * something else (`concurrently`'s `KillOnSignal`) already does.
1234
+ * @returns Removes the listeners.
1235
+ */
1236
+ function trackCancellation(onSignal) {
1237
+ function listener(signal) {
1238
+ cancelledBy ??= signal;
1239
+ onSignal?.(signal);
1240
+ }
1241
+ for (const signal of CANCEL_SIGNALS) process$1.on(signal, listener);
1242
+ return () => {
1243
+ for (const signal of CANCEL_SIGNALS) process$1.off(signal, listener);
1244
+ };
1245
+ }
1246
+ /**
1247
+ * Watch a child's stderr for V8's out-of-memory report. Reads alongside
1248
+ * whatever else consumes the stream, and never alters it.
1249
+ *
1250
+ * @param child - The spawned child.
1251
+ * @param onExhausted - Called once, when the report is seen.
1252
+ */
1253
+ function watchOutOfMemory(child, onExhausted) {
1254
+ let tail = "";
1255
+ let seen = false;
1256
+ child.stderr?.on("data", (chunk) => {
1257
+ if (seen) return;
1258
+ const text = tail + String(chunk);
1259
+ if (OUT_OF_MEMORY.test(text)) {
1260
+ seen = true;
1261
+ onExhausted();
1262
+ return;
1263
+ }
1264
+ tail = text.slice(-64);
1265
+ });
1266
+ }
1267
+ /**
870
1268
  * Start every command as one `concurrently` group and announce the children.
871
1269
  * Unlike the previous `killOthersOn: ["failure"]` behaviour, an ordinary lint
872
1270
  * failure in one child no longer kills its siblings — each runs to the end so
873
1271
  * the user keeps every result. The resolved code is non-zero when any child
874
1272
  * exited non-zero; the promise never rejects.
875
1273
  *
1274
+ * Built on `concurrently`'s core rather than its default export, because the
1275
+ * wrapper always launches a command *string* through a shell (`cmd.exe` on
1276
+ * Windows), and that string would have to carry every lint target. Here the
1277
+ * group's spawner launches each engine directly through the bootstrap (see
1278
+ * {@link launchEngine}); the command string is only the label `concurrently`
1279
+ * logs. The controllers are the ones the wrapper wires that do anything under
1280
+ * the options this runner always used — output and error logging, exit lines,
1281
+ * signal forwarding and the broken-output-pipe guard — and nothing else.
1282
+ *
876
1283
  * @param commands - The child commands to run.
877
1284
  * @param cwd - The working directory.
878
1285
  * @param outputStream - Where the group writes its child output; defaults to
879
1286
  * stdout. A staged run passes a held stream (see {@link createHeldOutput}).
880
1287
  * @returns The aggregated exit code.
881
1288
  */
882
- async function startGroup(commands, cwd, outputStream) {
883
- const { result } = concurrently(commands.map((command) => {
1289
+ async function startGroup(commands, cwd, outputStream = process$1.stdout) {
1290
+ if (cancelledBy !== void 0 || commands.length === 0) return 0;
1291
+ const launches = new Map(nameCommands(commands).map(([name, command]) => {
1292
+ return [name, {
1293
+ bin: resolveLocalBin(command.bin, cwd),
1294
+ command
1295
+ }];
1296
+ }));
1297
+ const logger = new Logger({});
1298
+ const exhausted = /* @__PURE__ */ new Set();
1299
+ const abortController = new AbortController();
1300
+ const stopTracking = trackCancellation();
1301
+ const { result } = createConcurrently(Array.from(launches, ([name, { command }]) => {
884
1302
  return {
885
1303
  name: command.label,
886
- command: buildShellCommand(process$1.execPath, resolveLocalBin(command.bin, cwd), command.args, process$1.platform),
1304
+ command: name,
887
1305
  env: command.env,
888
1306
  prefixColor: PREFIX_COLOR[command.label]
889
1307
  };
890
1308
  }), {
1309
+ abortSignal: abortController.signal,
1310
+ controllers: [
1311
+ new LogError({ logger }),
1312
+ new LogOutput({ logger }),
1313
+ new LogExit({ logger }),
1314
+ new KillOnSignal({
1315
+ abortController,
1316
+ process: process$1
1317
+ }),
1318
+ killOnOutputError(outputStream, abortController)
1319
+ ],
891
1320
  cwd,
892
1321
  group: true,
893
- outputStream
1322
+ logger,
1323
+ outputStream,
1324
+ spawn: (name, options) => {
1325
+ const launch = launches.get(name);
1326
+ if (launch === void 0) throw new Error(`isentinel-lint: no engine registered as "${name}".`);
1327
+ const child = launchEngine(launch, options);
1328
+ if (launch.command.watchHeap) watchOutOfMemory(child, () => {
1329
+ exhausted.add(launch.command.label);
1330
+ });
1331
+ return child;
1332
+ }
894
1333
  });
895
1334
  for (const command of commands) announce(command);
896
1335
  try {
@@ -898,31 +1337,70 @@ async function startGroup(commands, cwd, outputStream) {
898
1337
  return 0;
899
1338
  } catch {
900
1339
  return 1;
1340
+ } finally {
1341
+ stopTracking();
1342
+ for (const label of exhausted) process$1.stderr.write(outOfMemoryHint(label));
901
1343
  }
902
1344
  }
1345
+ function signalExitCode(signal) {
1346
+ return 128 + os.constants.signals[signal];
1347
+ }
1348
+ /**
1349
+ * The exit code for a cancelled run (`128 + signal number`, as a shell reports
1350
+ * it), or `undefined` when nothing cancelled it.
1351
+ *
1352
+ * @returns The exit code, when cancelled.
1353
+ */
1354
+ function cancelledCode() {
1355
+ return cancelledBy === void 0 ? void 0 : signalExitCode(cancelledBy);
1356
+ }
903
1357
  async function spawnChild(command, cwd) {
904
- const binJsPath = resolveLocalBin(command.bin, cwd);
1358
+ const bin = resolveLocalBin(command.bin, cwd);
1359
+ const watched = command.watchHeap;
1360
+ let exhausted = false;
905
1361
  return new Promise((resolve) => {
906
- const child = spawn(process$1.execPath, [binJsPath, ...command.args], {
1362
+ const child = launchEngine({
1363
+ bin,
1364
+ command
1365
+ }, {
907
1366
  cwd,
908
1367
  env: {
909
1368
  ...process$1.env,
910
1369
  ...command.env
911
1370
  },
912
- stdio: "inherit"
1371
+ stdio: [
1372
+ "inherit",
1373
+ "inherit",
1374
+ watched ? "pipe" : "inherit"
1375
+ ]
913
1376
  });
1377
+ if (watched) {
1378
+ child.stderr?.on("data", (chunk) => {
1379
+ process$1.stderr.write(chunk);
1380
+ });
1381
+ watchOutOfMemory(child, () => {
1382
+ exhausted = true;
1383
+ });
1384
+ }
914
1385
  announce(command);
1386
+ const stopTracking = trackCancellation((signal) => {
1387
+ child.kill(signal);
1388
+ });
915
1389
  child.on("error", () => {
1390
+ stopTracking();
916
1391
  resolve(1);
917
1392
  });
918
- child.on("close", (code) => {
919
- resolve(code ?? 1);
1393
+ child.on("close", (code, signal) => {
1394
+ stopTracking();
1395
+ if (exhausted) process$1.stderr.write(outOfMemoryHint(command.label));
1396
+ resolve(code ?? (signal === null ? 1 : signalExitCode(signal)));
920
1397
  });
921
1398
  });
922
1399
  }
923
1400
  async function runSequential(commands, cwd) {
924
1401
  let exitCode = 0;
925
1402
  for (const command of commands) {
1403
+ if (cancelledBy !== void 0) break;
926
1404
  const code = await spawnChild(command, cwd);
927
1405
  if (code !== 0) exitCode = code;
928
1406
  }
@@ -961,6 +1439,26 @@ function createHeldOutput() {
961
1439
  };
962
1440
  }
963
1441
  //#endregion
1442
+ //#region src/lint-cli/lib/exec/shell.ts
1443
+ const SAFE_TOKEN = /^[\w@+=:,./-]+$/;
1444
+ /**
1445
+ * Render a command as a shell-equivalent line (env prefix + logical binary +
1446
+ * arguments) for `--print`. Uses POSIX quoting for stable, cross-platform
1447
+ * output.
1448
+ *
1449
+ * @param command - The child command to render.
1450
+ * @returns The shell-equivalent command line.
1451
+ */
1452
+ function formatCommandLine(command) {
1453
+ const environmentPrefix = Object.entries(command.env).flatMap(([key, value]) => value === void 0 ? [] : [`${key}=${quotePosix(value)}`]).join(" ");
1454
+ const body = [command.bin, ...command.args].map(quotePosix).join(" ");
1455
+ return environmentPrefix.length > 0 ? `${environmentPrefix} ${body}` : body;
1456
+ }
1457
+ function quotePosix(token) {
1458
+ if (token.length > 0 && SAFE_TOKEN.test(token)) return token;
1459
+ return `'${token.replaceAll("'", "'\\''")}'`;
1460
+ }
1461
+ //#endregion
964
1462
  //#region src/lint-cli/lib/plan/command.ts
965
1463
  /**
966
1464
  * Compose the oxlint child command.
@@ -974,12 +1472,13 @@ function composeOxlintCommand(options, context) {
974
1472
  if (options.agents) args.push("--format", "agent");
975
1473
  if (context.oxlintTypeAware) args.push("--type-aware");
976
1474
  if (options.fix) args.push("--fix");
977
- args.push("--no-error-on-unmatched-pattern", ...options.oxlintArgs, ...context.paths);
1475
+ args.push("--no-error-on-unmatched-pattern", ...options.oxlintArgs, ...pathArguments(context.paths));
978
1476
  return {
979
1477
  args,
980
1478
  bin: "oxlint",
981
1479
  env: {},
982
- label: "oxc"
1480
+ label: "oxc",
1481
+ watchHeap: false
983
1482
  };
984
1483
  }
985
1484
  /**
@@ -996,14 +1495,30 @@ function composeEslintCommand(options, context) {
996
1495
  if (options.cache && context.ci) args.push("--cache-strategy", "content");
997
1496
  if (options.agents) args.push("--format", context.agentsFormatterPath);
998
1497
  if (context.fix) args.push("--fix");
999
- args.push(...options.eslintArgs, ...context.paths);
1498
+ args.push(...options.eslintArgs, ...pathArguments(context.paths));
1499
+ const environment = { ESLINT_TYPE_AWARE: context.typeAwareEnv };
1500
+ if (context.heap?.nodeOptions !== void 0) environment["NODE_OPTIONS"] = context.heap.nodeOptions;
1000
1501
  return {
1001
1502
  args,
1002
1503
  bin: "eslint",
1003
- env: { ESLINT_TYPE_AWARE: context.typeAwareEnv },
1004
- label: context.eslintLabel
1504
+ env: environment,
1505
+ label: context.eslintLabel,
1506
+ watchHeap: context.heap?.watch ?? false
1005
1507
  };
1006
1508
  }
1509
+ /**
1510
+ * The lint targets as the tail of an engine's argument list. A relative target
1511
+ * that starts with `-` would be read as a flag, so it is passed as `./<path>`.
1512
+ * A `--` separator is not enough: ESLint's bin scans the raw argv for
1513
+ * `--init`, `--mcp`, `--stdin` and `--debug` regardless of it, so a listed file
1514
+ * named `--init` would start the config wizard.
1515
+ *
1516
+ * @param paths - The lint targets.
1517
+ * @returns The arguments that pass them as paths.
1518
+ */
1519
+ function pathArguments(paths) {
1520
+ return paths.map((target) => target.startsWith("-") ? `./${target}` : target);
1521
+ }
1007
1522
  //#endregion
1008
1523
  //#region src/lint-cli/lib/plan/compose.ts
1009
1524
  /** The stderr notice emitted when a lint target resolves outside the cwd. */
@@ -1036,6 +1551,7 @@ function composePasses(passes, runPlan, options) {
1036
1551
  concurrency: pass.concurrency,
1037
1552
  eslintLabel: pass.descriptor.label,
1038
1553
  fix: false,
1554
+ heap: pass.heap,
1039
1555
  paths: options.paths,
1040
1556
  typeAwareEnv: pass.descriptor.typeAwareEnv
1041
1557
  }));
@@ -1170,7 +1686,7 @@ function applyHashBust(run, bust, hash) {
1170
1686
  const cleared = [];
1171
1687
  for (const base of bust.caches) {
1172
1688
  const cacheFilePath = path.resolve(run.cwd, cacheFileFor(base, run.key));
1173
- fs.rmSync(cacheFilePath, { force: true });
1689
+ fs$1.rmSync(cacheFilePath, { force: true });
1174
1690
  cleared.push(cacheFilePath);
1175
1691
  }
1176
1692
  return {
@@ -1447,7 +1963,7 @@ function normalizePath(filePath, platform = process$1.platform) {
1447
1963
  * @returns The loaded cache, or `undefined` when the file does not exist.
1448
1964
  */
1449
1965
  function openCache(cacheFilePath, useChecksum) {
1450
- if (!fs.existsSync(cacheFilePath)) return;
1966
+ if (!fs$1.existsSync(cacheFilePath)) return;
1451
1967
  const cache = load(cacheFilePath, useChecksum);
1452
1968
  return {
1453
1969
  filesWithMessages: (files) => filesWithMessagesIn(cache, files),
@@ -1457,7 +1973,7 @@ function openCache(cacheFilePath, useChecksum) {
1457
1973
  }
1458
1974
  function safeMtimeMs(filePath) {
1459
1975
  try {
1460
- return fs.statSync(filePath).mtimeMs;
1976
+ return fs$1.statSync(filePath).mtimeMs;
1461
1977
  } catch {
1462
1978
  return;
1463
1979
  }
@@ -1474,7 +1990,7 @@ function safeMtimeMs(filePath) {
1474
1990
  function listCacheFiles(cwd) {
1475
1991
  let entries;
1476
1992
  try {
1477
- entries = fs.readdirSync(cwd);
1993
+ entries = fs$1.readdirSync(cwd);
1478
1994
  } catch {
1479
1995
  return [];
1480
1996
  }
@@ -1482,7 +1998,7 @@ function listCacheFiles(cwd) {
1482
1998
  }
1483
1999
  function removeCacheFile(cacheFilePath) {
1484
2000
  try {
1485
- fs.rmSync(cacheFilePath, { force: true });
2001
+ fs$1.rmSync(cacheFilePath, { force: true });
1486
2002
  } catch {}
1487
2003
  }
1488
2004
  /**
@@ -3491,7 +4007,7 @@ const WALK_BUDGET = 8192;
3491
4007
  function findWorkspaceRoot(cwd) {
3492
4008
  let current = cwd;
3493
4009
  for (;;) {
3494
- for (const marker of WORKSPACE_ROOT_MARKERS) if (fs.existsSync(path.join(current, marker))) return current;
4010
+ for (const marker of WORKSPACE_ROOT_MARKERS) if (fs$1.existsSync(path.join(current, marker))) return current;
3495
4011
  const parent = path.dirname(current);
3496
4012
  if (parent === current) return cwd;
3497
4013
  current = parent;
@@ -3535,7 +4051,7 @@ function findWorkspaceMembers(root) {
3535
4051
  function walk(directory, depth) {
3536
4052
  let entries;
3537
4053
  try {
3538
- entries = fs.readdirSync(directory, { withFileTypes: true });
4054
+ entries = fs$1.readdirSync(directory, { withFileTypes: true });
3539
4055
  } catch {
3540
4056
  return true;
3541
4057
  }
@@ -3545,7 +4061,7 @@ function findWorkspaceMembers(root) {
3545
4061
  if (budget < 0) return false;
3546
4062
  const absolute = path.join(directory, entry.name);
3547
4063
  const relative = toPosix(path.relative(root, absolute));
3548
- if (isMember(relative) && fs.existsSync(path.join(absolute, "package.json"))) members.push(absolute);
4064
+ if (isMember(relative) && fs$1.existsSync(path.join(absolute, "package.json"))) members.push(absolute);
3549
4065
  if (depth < maxDepth && !walk(absolute, depth + 1)) return false;
3550
4066
  }
3551
4067
  return true;
@@ -3873,7 +4389,7 @@ function oxlintTargets(cwd, targets) {
3873
4389
  const extension = path.extname(target).toLowerCase();
3874
4390
  if (extension === "" || TYPE_AWARE_EXTENSION_SET.has(extension)) return true;
3875
4391
  try {
3876
- return fs.statSync(path.resolve(cwd, target)).isDirectory();
4392
+ return fs$1.statSync(path.resolve(cwd, target)).isDirectory();
3877
4393
  } catch {
3878
4394
  return false;
3879
4395
  }
@@ -3910,7 +4426,7 @@ function collectAncestorBustFiles(cwd) {
3910
4426
  for (;;) {
3911
4427
  let entries = [];
3912
4428
  try {
3913
- entries = fs.readdirSync(current, { withFileTypes: true });
4429
+ entries = fs$1.readdirSync(current, { withFileTypes: true });
3914
4430
  } catch {
3915
4431
  entries = [];
3916
4432
  }
@@ -3933,7 +4449,7 @@ function collectAncestorBustFiles(cwd) {
3933
4449
  * @returns The normalized cwd-relative posix target.
3934
4450
  */
3935
4451
  function normalizeTarget(target, cwd) {
3936
- let value = path.isAbsolute(target) ? toPosix(path.relative(cwd, target)) : toPosix(target);
4452
+ let value = path.isAbsolute(target) ? toPosix(path.relative(cwd, target)) : path.posix.normalize(toPosix(target));
3937
4453
  if (value.startsWith("./")) value = value.slice(2);
3938
4454
  while (value.endsWith("/")) value = value.slice(0, -1);
3939
4455
  return value;
@@ -3960,8 +4476,15 @@ function isOutsideCwd(normalizedTarget) {
3960
4476
  */
3961
4477
  function matchTargets(normalized) {
3962
4478
  if (normalized.some((target) => target === "" || target === ".")) return () => true;
4479
+ const targets = new Set(normalized);
3963
4480
  return (relative) => {
3964
- return normalized.some((target) => relative === target || relative.startsWith(`${target}/`));
4481
+ let candidate = relative;
4482
+ for (;;) {
4483
+ if (targets.has(candidate)) return true;
4484
+ const slash = candidate.lastIndexOf("/");
4485
+ if (slash === -1) return false;
4486
+ candidate = candidate.slice(0, slash);
4487
+ }
3965
4488
  };
3966
4489
  }
3967
4490
  function isTypeAwareFile(filePath) {
@@ -3974,6 +4497,7 @@ function gitListFiles(cwd, pathSpecs) {
3974
4497
  try {
3975
4498
  return execFileSync("git", [
3976
4499
  "ls-files",
4500
+ "-z",
3977
4501
  "--cached",
3978
4502
  "--others",
3979
4503
  "--exclude-standard",
@@ -3983,7 +4507,7 @@ function gitListFiles(cwd, pathSpecs) {
3983
4507
  cwd,
3984
4508
  encoding: "utf8",
3985
4509
  maxBuffer: 64 * 1024 * 1024
3986
- }).split("\n").filter((line) => line.length > 0);
4510
+ }).split("\0").filter((line) => line.length > 0);
3987
4511
  } catch {
3988
4512
  return;
3989
4513
  }
@@ -3991,7 +4515,7 @@ function gitListFiles(cwd, pathSpecs) {
3991
4515
  function walkDirectory(root, current, accumulator) {
3992
4516
  let entries;
3993
4517
  try {
3994
- entries = fs.readdirSync(current, { withFileTypes: true });
4518
+ entries = fs$1.readdirSync(current, { withFileTypes: true });
3995
4519
  } catch {
3996
4520
  return;
3997
4521
  }
@@ -4010,7 +4534,7 @@ function walkFallback(cwd, targets) {
4010
4534
  const absolute = path.resolve(cwd, target);
4011
4535
  let stat;
4012
4536
  try {
4013
- stat = fs.statSync(absolute);
4537
+ stat = fs$1.statSync(absolute);
4014
4538
  } catch {
4015
4539
  continue;
4016
4540
  }
@@ -4049,11 +4573,12 @@ function gitIgnoredTargets(cwd, targets) {
4049
4573
  const output = execFileSync("git", [
4050
4574
  "check-ignore",
4051
4575
  "--no-index",
4052
- "--stdin"
4576
+ "--stdin",
4577
+ "-z"
4053
4578
  ], {
4054
4579
  cwd,
4055
4580
  encoding: "utf8",
4056
- input: targets.join("\n"),
4581
+ input: `${targets.join("\0")}\0`,
4057
4582
  maxBuffer: 64 * 1024 * 1024,
4058
4583
  stdio: [
4059
4584
  "pipe",
@@ -4061,7 +4586,7 @@ function gitIgnoredTargets(cwd, targets) {
4061
4586
  "ignore"
4062
4587
  ]
4063
4588
  });
4064
- return new Set(output.split("\n").map((line) => line.trim()).filter(Boolean));
4589
+ return new Set(output.split("\0").filter(Boolean));
4065
4590
  } catch {
4066
4591
  return /* @__PURE__ */ new Set();
4067
4592
  }
@@ -4334,7 +4859,7 @@ function queryIgnoredFiles(cwd, targets) {
4334
4859
  "ignore"
4335
4860
  ]
4336
4861
  });
4337
- const parsed = JSON.parse(fs.readFileSync(outFile, "utf8"));
4862
+ const parsed = JSON.parse(fs$1.readFileSync(outFile, "utf8"));
4338
4863
  if (!isRecord(parsed)) return;
4339
4864
  const { basePath, entries, ignored, mode } = parsed;
4340
4865
  if (mode === "answers" && isStringArray(ignored)) return {
@@ -4350,7 +4875,7 @@ function queryIgnoredFiles(cwd, targets) {
4350
4875
  } catch {
4351
4876
  return;
4352
4877
  } finally {
4353
- fs.rmSync(outFile, { force: true });
4878
+ fs$1.rmSync(outFile, { force: true });
4354
4879
  }
4355
4880
  }
4356
4881
  //#endregion
@@ -4393,7 +4918,7 @@ function readHybridStatus(cwd) {
4393
4918
  function writeHybridStatus(cwd, oxlint) {
4394
4919
  let installed;
4395
4920
  try {
4396
- installed = fs.existsSync(path.resolve(cwd, "node_modules"));
4921
+ installed = fs$1.existsSync(path.resolve(cwd, "node_modules"));
4397
4922
  } catch {
4398
4923
  return;
4399
4924
  }
@@ -4572,6 +5097,7 @@ function resolveWorkerLimits(environment, availableParallelism, ci) {
4572
5097
  const explicit = parsePositiveInteger(environment["LINT_MAX_WORKERS"]);
4573
5098
  const maxWorkers = explicit ?? (ci ? availableParallelism : Math.floor(availableParallelism / 4));
4574
5099
  return {
5100
+ explicitMaxWorkers: explicit !== void 0,
4575
5101
  filesPerWorker,
4576
5102
  maxWorkers,
4577
5103
  typedMaxWorkers: explicit ?? Math.min(maxWorkers, 6)
@@ -4664,6 +5190,17 @@ function selectPasses(options, ci) {
4664
5190
  return [FAST_PASS, TYPED_PASS];
4665
5191
  }
4666
5192
  /**
5193
+ * Whether a pass builds a TypeScript program in each of its workers. The pass
5194
+ * that runs no builder is exactly the one that builds no program, so
5195
+ * `invalidation` records both.
5196
+ *
5197
+ * @param descriptor - The pass.
5198
+ * @returns Whether its workers hold a program, and so need heap sizing.
5199
+ */
5200
+ function buildsProgram(descriptor) {
5201
+ return descriptor.invalidation !== "none";
5202
+ }
5203
+ /**
4667
5204
  * The worker cap for a pass. `invalidation` already records whether a pass
4668
5205
  * builds a TypeScript program — the fast pass runs no builder precisely because
4669
5206
  * it builds none — so it doubles as the cap selector rather than restating the
@@ -4674,7 +5211,7 @@ function selectPasses(options, ci) {
4674
5211
  * @returns The worker cap for this pass.
4675
5212
  */
4676
5213
  function maxWorkersFor(descriptor, limits) {
4677
- return descriptor.invalidation === "none" ? limits.maxWorkers : limits.typedMaxWorkers;
5214
+ return buildsProgram(descriptor) ? limits.typedMaxWorkers : limits.maxWorkers;
4678
5215
  }
4679
5216
  //#endregion
4680
5217
  //#region src/lint-cli/lib/plan/fix.ts
@@ -4739,13 +5276,18 @@ function collectFixTargets(passes, run, files) {
4739
5276
  * @returns The fix child, or `undefined` when nothing was reported.
4740
5277
  */
4741
5278
  function planFixChild(passes, run, inputs) {
4742
- const { files, limits, options } = inputs;
5279
+ const { files, heap, limits, options } = inputs;
4743
5280
  const covered = options.cache ? collectFixTargets(passes, run, files) : [];
4744
5281
  const uncovered = options.cache ? files.outsideCwdTargets : options.paths;
4745
5282
  const targets = [...covered, ...uncovered];
4746
5283
  if (targets.length === 0) return;
4747
5284
  const cacheFile = cacheFileFor(CACHE_FILE_DEFAULT, run.key);
4748
5285
  if (covered.length > 0) openCache(path.resolve(run.cwd, cacheFile), run.ci)?.removeEntries(covered);
5286
+ const concurrency = options.concurrency ?? computeWorkerCount({
5287
+ dirtyCount: targets.length,
5288
+ filesPerWorker: limits.filesPerWorker,
5289
+ maxWorkers: capWorkersForHeap(limits.typedMaxWorkers, limits.explicitMaxWorkers, heap)
5290
+ });
4749
5291
  return composeEslintCommand({
4750
5292
  ...options,
4751
5293
  cache: options.cache && uncovered.length === 0
@@ -4753,13 +5295,13 @@ function planFixChild(passes, run, inputs) {
4753
5295
  agentsFormatterPath: inputs.agentsFormatterPath,
4754
5296
  cacheLocation: cacheFile,
4755
5297
  ci: run.ci,
4756
- concurrency: options.concurrency ?? computeWorkerCount({
4757
- dirtyCount: targets.length,
4758
- filesPerWorker: limits.filesPerWorker,
4759
- maxWorkers: limits.typedMaxWorkers
4760
- }),
5298
+ concurrency,
4761
5299
  eslintLabel: FULL_PASS.label,
4762
5300
  fix: true,
5301
+ heap: planPassHeap(FULL_PASS.label, heap, {
5302
+ concurrency,
5303
+ dirtyCount: targets.length
5304
+ }),
4763
5305
  paths: targets,
4764
5306
  typeAwareEnv: FULL_PASS.typeAwareEnv
4765
5307
  });
@@ -5096,7 +5638,7 @@ function computeAffectedFiles({ key, cwd }, mode) {
5096
5638
  if (entryReadable) warnOnce(`no TypeScript files resolved from ${path.basename(configPath)}; skipping type-aware cache invalidation`);
5097
5639
  return;
5098
5640
  }
5099
- fs.mkdirSync(stateDirectory(cwd), { recursive: true });
5641
+ fs$1.mkdirSync(stateDirectory(cwd), { recursive: true });
5100
5642
  const resolutionGate = computeResolutionGate(cwd);
5101
5643
  const affected = /* @__PURE__ */ new Set();
5102
5644
  let warmProjects = 0;
@@ -5143,7 +5685,7 @@ function computeAffectedFiles({ key, cwd }, mode) {
5143
5685
  function hasBuilderState({ key, cwd }, mode) {
5144
5686
  const prefix = `${path.basename(statePath(cwd, BUILD_INFO_STATE, modeSuffix(mode), key))}-`;
5145
5687
  try {
5146
- return fs.readdirSync(stateDirectory(cwd)).some((name) => name.startsWith(prefix) && !name.endsWith(".tmp"));
5688
+ return fs$1.readdirSync(stateDirectory(cwd)).some((name) => name.startsWith(prefix) && !name.endsWith(".tmp"));
5147
5689
  } catch {
5148
5690
  return false;
5149
5691
  }
@@ -5358,10 +5900,10 @@ function persistBuilderState(builder, buildInfoPath) {
5358
5900
  if (path.normalize(fileName) !== normalizedBuildInfo) return;
5359
5901
  const temporary = `${fileName}.${process$1.pid}.tmp`;
5360
5902
  try {
5361
- fs.writeFileSync(temporary, data);
5362
- fs.renameSync(temporary, fileName);
5903
+ fs$1.writeFileSync(temporary, data);
5904
+ fs$1.renameSync(temporary, fileName);
5363
5905
  } catch (err) {
5364
- fs.rmSync(temporary, { force: true });
5906
+ fs$1.rmSync(temporary, { force: true });
5365
5907
  throw err;
5366
5908
  }
5367
5909
  });
@@ -5376,7 +5918,7 @@ function persistBuilderState(builder, buildInfoPath) {
5376
5918
  */
5377
5919
  function writeGateState(gatePath, gateValue) {
5378
5920
  if (gateValue === void 0) {
5379
- fs.rmSync(gatePath, { force: true });
5921
+ fs$1.rmSync(gatePath, { force: true });
5380
5922
  return;
5381
5923
  }
5382
5924
  writeState(gatePath, gateValue);
@@ -5397,7 +5939,7 @@ function writeGateState(gatePath, gateValue) {
5397
5939
  * @returns The affected result.
5398
5940
  */
5399
5941
  function runBuilder({ buildInfoPath, gatePath, project, resolutionGate, ts }) {
5400
- const firstRun = !fs.existsSync(buildInfoPath);
5942
+ const firstRun = !fs$1.existsSync(buildInfoPath);
5401
5943
  const options = {
5402
5944
  ...project.options,
5403
5945
  composite: false,
@@ -5480,7 +6022,7 @@ function applyTypeAwareInvalidation(run, { alreadyDirty, cache, cacheLocation, m
5480
6022
  }
5481
6023
  const threshold = resolveAffectedBustThreshold(run.environment);
5482
6024
  if (affectedTargets.length > threshold) {
5483
- fs.rmSync(cacheLocation, { force: true });
6025
+ fs$1.rmSync(cacheLocation, { force: true });
5484
6026
  return {
5485
6027
  busted: true,
5486
6028
  firstRun: false,
@@ -5539,12 +6081,12 @@ function sizePasses(descriptors, run, inputs) {
5539
6081
  * @returns The number of dirty files.
5540
6082
  */
5541
6083
  function mutatingDirtyCount(descriptor, cacheLocation, targetFiles, { clearedCaches, run }) {
5542
- const buildsProgram = descriptor.invalidation !== "none";
6084
+ const program = buildsProgram(descriptor);
5543
6085
  const mode = descriptor.invalidation === "only" ? "only" : void 0;
5544
- if (clearedCaches.has(cacheLocation) && (!buildsProgram || hasBuilderState(run, mode))) return targetFiles.length;
6086
+ if (clearedCaches.has(cacheLocation) && (!program || hasBuilderState(run, mode))) return targetFiles.length;
5545
6087
  const cache = openCache(cacheLocation, run.ci);
5546
6088
  const dirty = new Set((cache?.getUpdatedFiles(targetFiles) ?? targetFiles).map((file) => normalizePath(file)));
5547
- if (buildsProgram) {
6089
+ if (program) {
5548
6090
  const outcome = applyTypeAwareInvalidation(run, {
5549
6091
  alreadyDirty: dirty,
5550
6092
  cache,
@@ -5601,17 +6143,24 @@ function sizePass(descriptor, context) {
5601
6143
  const dirtyCount = passDirtyCount(descriptor, cacheFile, context);
5602
6144
  const conservative = context.files.outsideCwdTargets.length > 0;
5603
6145
  const filesPerWorker = descriptor.filesPerWorker(context.limits, context.run.environment);
5604
- const maxWorkers = maxWorkersFor(descriptor, context.limits);
6146
+ const program = buildsProgram(descriptor);
6147
+ const passMaxWorkers = maxWorkersFor(descriptor, context.limits);
6148
+ const maxWorkers = program ? capWorkersForHeap(passMaxWorkers, context.limits.explicitMaxWorkers, context.heap) : passMaxWorkers;
5605
6149
  const sizingDirtyCount = conservative ? maxWorkers * filesPerWorker : dirtyCount;
5606
6150
  const concurrency = context.options.concurrency ?? computeWorkerCount({
5607
6151
  dirtyCount: sizingDirtyCount,
5608
6152
  filesPerWorker,
5609
6153
  maxWorkers
5610
6154
  });
6155
+ const heap = program ? planPassHeap(descriptor.label, context.heap, {
6156
+ concurrency,
6157
+ dirtyCount: sizingDirtyCount
6158
+ }) : void 0;
5611
6159
  if (context.run.mutate && context.multiPass && descriptor === TYPED_PASS && !conservative && dirtyCount === 0) return {
5612
6160
  cacheFile,
5613
6161
  concurrency,
5614
6162
  descriptor,
6163
+ heap,
5615
6164
  shouldRun: false,
5616
6165
  skipReason: TYPED_SKIP_NOTICE
5617
6166
  };
@@ -5619,6 +6168,7 @@ function sizePass(descriptor, context) {
5619
6168
  cacheFile,
5620
6169
  concurrency,
5621
6170
  descriptor,
6171
+ heap,
5622
6172
  shouldRun: true,
5623
6173
  skipReason: void 0
5624
6174
  };
@@ -5639,9 +6189,10 @@ function sizePass(descriptor, context) {
5639
6189
  *
5640
6190
  * @param options - The parsed CLI options.
5641
6191
  * @param run - The run context (cwd, variant key, environment, mutate).
6192
+ * @param memory - The machine's memory facts (injected in tests).
5642
6193
  * @returns The staged run plan.
5643
6194
  */
5644
- function plan(options, run) {
6195
+ function plan(options, run, memory = probeMemory()) {
5645
6196
  const { ci, cwd, environment, mutate } = run;
5646
6197
  const runEslint = !options.oxlint;
5647
6198
  const oxlintTypeAware = resolveOxlintTypeAware(options);
@@ -5666,6 +6217,7 @@ function plan(options, run) {
5666
6217
  const files = collectRepoFiles(cwd, options.paths);
5667
6218
  const newestBustMtime = maxMtimeMs(files.bustFiles);
5668
6219
  const limits = resolveWorkerLimits(environment, availableParallelism(), ci);
6220
+ const heap = resolveHeapBudget(environment, memory);
5669
6221
  const oxlintDecision = resolveOxlintRun(run, {
5670
6222
  files,
5671
6223
  runEslint,
@@ -5685,6 +6237,7 @@ function plan(options, run) {
5685
6237
  const inputs = {
5686
6238
  clearedCaches,
5687
6239
  files: sizingFiles,
6240
+ heap,
5688
6241
  limits,
5689
6242
  multiPass: descriptors.length > 1,
5690
6243
  newestBustMtime,
@@ -5712,6 +6265,7 @@ function plan(options, run) {
5712
6265
  return planFixChild(passes, run, {
5713
6266
  agentsFormatterPath,
5714
6267
  files: sizingFiles,
6268
+ heap,
5715
6269
  limits,
5716
6270
  options
5717
6271
  });
@@ -5767,15 +6321,20 @@ function stageDescriptors(descriptors, run, staging) {
5767
6321
  * @param argv - The argument slice (without the node/bin prefix).
5768
6322
  * @param cwd - The working directory (defaults to `process.cwd()`; injected in tests).
5769
6323
  * @param environment - The process environment (defaults to `process.env`).
6324
+ * @param stdin - Where `--files0-from -` reads its list (defaults to
6325
+ * `process.stdin`).
5770
6326
  * @returns The process exit code.
5771
6327
  * @rejects {CliError} When the arguments are invalid or a tool is missing.
5772
6328
  */
5773
- async function runLint(argv, cwd = process$1.cwd(), environment = process$1.env) {
5774
- const options = parseArguments(argv, environment);
6329
+ async function runLint(argv, cwd = process$1.cwd(), environment = process$1.env, stdin = process$1.stdin) {
6330
+ resetCancellation();
6331
+ const options = await readFileList(parseArguments(argv, environment), cwd, stdin);
6332
+ if (options.paths.length === 0) return 0;
5775
6333
  const staged = plan(options, resolveRunContext(cwd, environment, !options.print));
5776
6334
  const { commands, notice } = compose(staged.eager, options);
5777
6335
  if (options.print) {
5778
6336
  for (const command of commands) process$1.stdout.write(`${formatCommandLine(command)}\n`);
6337
+ for (const pass of staged.eager.passes) if (pass.shouldRun && pass.heap !== void 0) process$1.stderr.write(pass.heap.note);
5779
6338
  return 0;
5780
6339
  }
5781
6340
  if (commands.some((command) => command.bin === "oxlint" && command.args.includes("--type-aware")) && !isPackageExists("oxlint-tsgolint", { paths: [cwd] })) throw new CliError("oxlint-tsgolint is not installed, so oxlint cannot run type-aware rules. Install oxlint-tsgolint, or pass --no-oxlint-type-aware to skip type-aware linting.");
@@ -5824,7 +6383,7 @@ async function runChecks(commands, cwd, staged, options) {
5824
6383
  }
5825
6384
  //#endregion
5826
6385
  //#region src/lint-cli/index.ts
5827
- const HELP = `isentinel-lint [flags] [paths...]
6386
+ const HELP = `isentinel-lint [flags] [paths... | --files0-from <file|->]
5828
6387
 
5829
6388
  Runs oxlint and ESLint together, sizing ESLint's --concurrency from how many
5830
6389
  files actually need re-linting and managing per-mode caches.
@@ -5844,6 +6403,9 @@ Flags:
5844
6403
  --concurrency <n|off> Override the concurrency heuristic.
5845
6404
  --eslint-args "<args>" Extra arguments for ESLint.
5846
6405
  --oxlint-args "<args>" Extra arguments for oxlint.
6406
+ --files0-from <file|-> Read the paths to lint from a NUL-delimited UTF-8
6407
+ list (- for stdin) instead of positional arguments.
6408
+ An empty list lints nothing and exits 0.
5847
6409
  --print Print the composed commands without running them.
5848
6410
  -- <args> Forward args to the single selected tool.
5849
6411
  -h, --help Show this help.