@ttsc/lint 0.24.0 → 0.25.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/src/index.ts CHANGED
@@ -8,9 +8,7 @@ import path from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
 
10
10
  import {
11
- CONFIG_EVALUATOR_PROCESS_OPTIONS,
12
- CONFIG_EVALUATOR_STATUS_FD,
13
- configEvaluatorBoundaryEnvironment,
11
+ configEvaluatorFailureReason,
14
12
  configEvaluatorProcessFailure,
15
13
  } from "./internal/configEvaluatorFailure";
16
14
  import type { ITtscLintPlugin, ITtscLintPluginConfig } from "./structures";
@@ -630,33 +628,68 @@ const hooks = registerHooks({
630
628
  },
631
629
  });
632
630
 
633
- try {
634
- const importedConfig = configLocation.toLowerCase().endsWith(".json")
635
- ? JSON.parse(fs.readFileSync(configLocation, "utf8").replace(/^\uFEFF/, ""))
636
- : await import(configUrl);
637
- const current = await resolveConfig(importedConfig, true);
638
- const pluginMaps = collectPluginObjects(current);
639
- const entries: Array<{ namespace: string; source: string }> = [];
640
- for (const map of pluginMaps) {
641
- for (const [namespace, value] of Object.entries(map)) {
642
- const source = extractPluginSource(value);
643
- if (source === undefined || source.length === 0) {
644
- throw new Error(
645
- \`contributor \${JSON.stringify(namespace)} must resolve to an object with a non-empty "source" string\`,
646
- );
631
+ // Wrapped and settled explicitly rather than written as a top-level await.
632
+ // The loader tsconfig's "module" now follows the config's own package, and
633
+ // TS1378 rejects top-level await under a CommonJS module option however this
634
+ // .mts file emits. The trailing catch is what a top-level await gave for
635
+ // free: without it a throw from the finally would leave the promise
636
+ // unsettled instead of failing the load.
637
+ (async () => {
638
+ try {
639
+ const importedConfig = configLocation.toLowerCase().endsWith(".json")
640
+ ? JSON.parse(fs.readFileSync(configLocation, "utf8").replace(/^\uFEFF/, ""))
641
+ : await import(configUrl);
642
+ const current = await resolveConfig(importedConfig, true);
643
+ const pluginMaps = collectPluginObjects(current);
644
+ const entries: Array<{ namespace: string; source: string }> = [];
645
+ for (const map of pluginMaps) {
646
+ for (const [namespace, value] of Object.entries(map)) {
647
+ const source = extractPluginSource(value);
648
+ if (source === undefined || source.length === 0) {
649
+ throw new Error(
650
+ \`contributor \${JSON.stringify(namespace)} must resolve to an object with a non-empty "source" string\`,
651
+ );
652
+ }
653
+ entries.push({ namespace, source });
647
654
  }
648
- entries.push({ namespace, source });
649
655
  }
656
+ fs.writeFileSync(outputPath, JSON.stringify({
657
+ dependencies: finalizeDependencies(),
658
+ entries,
659
+ }), "utf8");
660
+ } catch (error) {
661
+ reportLoaderFailure(error);
662
+ } finally {
663
+ hooks.deregister();
650
664
  }
651
- fs.writeFileSync(outputPath, JSON.stringify({
652
- dependencies: finalizeDependencies(),
653
- entries,
654
- }), "utf8");
655
- } catch (error) {
656
- process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
657
- process.exit(1);
658
- } finally {
659
- hooks.deregister();
665
+ })().catch((error) => {
666
+ // Reached only when the finally above throws: the catch already ends the
667
+ // process, so this is the deregistration's own failure, not the config's.
668
+ reportLoaderFailure(error);
669
+ });
670
+
671
+ // reportLoaderFailure ends this loader on an error it can name, on both of the
672
+ // channels the parent uses.
673
+ //
674
+ // The stack is for a reader and streams to stderr as it is written. The reason
675
+ // is a fact about the user's config that a caller has to act on, so it travels
676
+ // as data through the result file the parent already reads. Only a well-formed
677
+ // envelope is honoured there, so a partially written or unrelated file leaves
678
+ // the process status to speak for itself.
679
+ function reportLoaderFailure(error: unknown): never {
680
+ // The trailing newline ends the stack as a line of its own. Without it the
681
+ // parent's own message, written to this same stream, starts mid-line.
682
+ process.stderr.write((error instanceof Error && error.stack ? error.stack : String(error)) + "\\n");
683
+ try {
684
+ fs.writeFileSync(
685
+ outputPath,
686
+ JSON.stringify({ __ttscLoaderError: error instanceof Error ? error.message : String(error) }),
687
+ "utf8",
688
+ );
689
+ } catch {
690
+ // A reason that cannot be written leaves the exit status as the report.
691
+ }
692
+ return process.exit(1);
660
693
  }
661
694
 
662
695
  function isObject(value: unknown): value is Record<string, unknown> {
@@ -1702,7 +1735,13 @@ function evaluateTtsxConfigPlugins(
1702
1735
  allowImportingTsExtensions: true,
1703
1736
  allowJs: true,
1704
1737
  checkJs: false,
1705
- module: "ESNext",
1738
+ // The config is a Node module, so Node's rule decides its format:
1739
+ // the nearest `package.json` `type` above it. Hardcoding one answer
1740
+ // ran every ambiguous `.ts` config as ESM and broke `__dirname` in
1741
+ // an ordinary CommonJS package (#1068). `moduleResolution` stays
1742
+ // `bundler`, which tsgo accepts for both kinds, so extensionless
1743
+ // relative imports keep resolving either way.
1744
+ module: configModuleOption(configPath),
1706
1745
  moduleResolution: "bundler",
1707
1746
  noImplicitAny: false,
1708
1747
  outDir: path.join(tempDir, "out").replace(/\\/g, "/"),
@@ -1711,6 +1750,12 @@ function evaluateTtsxConfigPlugins(
1711
1750
  skipLibCheck: true,
1712
1751
  strict: false,
1713
1752
  target: "ES2022",
1753
+ // TypeScript 7 includes no ambient type package unless `types` asks
1754
+ // for it, and this Program extends nothing, so without the wildcard
1755
+ // a config could not name a single Node global (#1068). The loader
1756
+ // directory links the config's nearest `node_modules`, so the
1757
+ // default `typeRoots` walk finds exactly what the project installed.
1758
+ types: ["*"],
1714
1759
  },
1715
1760
  files: [
1716
1761
  loaderPath.replace(/\\/g, "/"),
@@ -1749,28 +1794,29 @@ function evaluateTtsxConfigPlugins(
1749
1794
  }
1750
1795
  const env = {
1751
1796
  ...nodeConfigLoaderEnv(configPath),
1752
- ...configEvaluatorBoundaryEnvironment(),
1753
1797
  };
1754
1798
  const command = ttsxThroughNodeIfNeeded(ttsxBinary);
1755
1799
  const result = spawnSync(command.binary, [...command.prefix, ...args], {
1756
1800
  cwd: tempDir,
1757
1801
  env,
1758
- encoding: "utf8",
1759
- ...CONFIG_EVALUATOR_PROCESS_OPTIONS,
1760
- stdio: [
1761
- "ignore",
1762
- "pipe",
1763
- "pipe",
1764
- ...Array.from(
1765
- { length: CONFIG_EVALUATOR_STATUS_FD - 2 },
1766
- () => "pipe" as const,
1767
- ),
1768
- ],
1802
+ // Both child streams are human output, and they go straight to this
1803
+ // process's stderr as they are written. Nothing is collected here: the
1804
+ // parent's stdout is reserved for compiler JSON and LSP frames, and
1805
+ // buffering the child only to replay it afterwards is what forced an
1806
+ // invented output ceiling and made a long evaluation print nothing at all.
1807
+ stdio: ["ignore", 2, 2],
1769
1808
  windowsHide: true,
1770
1809
  });
1771
- forwardConfigEvaluatorStreams(result.stdout, result.stderr);
1772
1810
  const processFailure = configEvaluatorProcessFailure(result, configPath);
1773
- if (processFailure) throw processFailure;
1811
+ if (processFailure) {
1812
+ // The evaluator's stack already reached the user's stderr as it ran. What
1813
+ // it could not put there is a reason a caller can act on, so that arrives
1814
+ // through the result file instead.
1815
+ const reason = configEvaluatorFailureReason(outputPath);
1816
+ throw reason === ""
1817
+ ? processFailure
1818
+ : new Error(`${processFailure.message}\n${reason}`);
1819
+ }
1774
1820
  let payload: {
1775
1821
  dependencies?: ConfigDependencyFingerprint[];
1776
1822
  entries?: ConfigPluginEntry[];
@@ -1841,20 +1887,10 @@ function evaluateTtsxConfigPlugins(
1841
1887
  }
1842
1888
  return { dependencies, entries };
1843
1889
  } finally {
1844
- fs.rmSync(tempDir, { recursive: true, force: true });
1890
+ removeEvaluationTempDir(tempDir);
1845
1891
  }
1846
1892
  }
1847
1893
 
1848
- function forwardConfigEvaluatorStreams(
1849
- stdout: string | null | undefined,
1850
- stderr: string | null | undefined,
1851
- ): void {
1852
- // Both child streams are human output. Parent stdout is reserved for compiler
1853
- // JSON or LSP frames, so even a user console.log is redirected.
1854
- if (stdout) process.stderr.write(stdout);
1855
- if (stderr) process.stderr.write(stderr);
1856
- }
1857
-
1858
1894
  // ────────────────────────────────────────────────────────────────────────────
1859
1895
  // Config cache (shared with the Go sidecar — packages/lint/linthost/config.go)
1860
1896
  // ────────────────────────────────────────────────────────────────────────────
@@ -2268,6 +2304,64 @@ function findNearestNodeModules(start: string): string | undefined {
2268
2304
  }
2269
2305
  }
2270
2306
 
2307
+ /**
2308
+ * The loader tsconfig's `module` for a given config file: the module kind Node
2309
+ * would give the file itself.
2310
+ *
2311
+ * An explicit `.cts`/`.cjs` or `.mts`/`.mjs` extension already decides the emit
2312
+ * format on its own, so those keep the ES-module setting and let the extension
2313
+ * win — the same precedence tsgo applies. Everything ambiguous walks up for the
2314
+ * nearest `package.json` `type`, exactly as Node does when it loads the file.
2315
+ */
2316
+ function configModuleOption(configPath: string): string {
2317
+ const extension = path.extname(configPath).toLowerCase();
2318
+ if (extension !== ".ts" && extension !== ".tsx" && extension !== ".js") {
2319
+ return "ESNext";
2320
+ }
2321
+ return nearestPackageType(configPath) === "commonjs" ? "CommonJS" : "ESNext";
2322
+ }
2323
+
2324
+ /**
2325
+ * Nearest `package.json` `"type"` at or above `configPath`, mirroring Node's
2326
+ * package-scope lookup: the walk stops at the **first** manifest it finds, and
2327
+ * a manifest that declares no `"type"` means CommonJS rather than a reason to
2328
+ * keep climbing. Reaching the filesystem root without any manifest also means
2329
+ * CommonJS.
2330
+ */
2331
+ function nearestPackageType(configPath: string): "commonjs" | "module" {
2332
+ let dir = path.dirname(path.resolve(configPath));
2333
+ for (;;) {
2334
+ // One read rather than a stat-then-read: an unreadable entry and a missing
2335
+ // one are the same answer here — keep walking — and the Go loaders resolve
2336
+ // it the same way, so the four implementations of this rule cannot drift.
2337
+ let raw: string | undefined;
2338
+ try {
2339
+ raw = fs.readFileSync(path.join(dir, "package.json"), "utf8");
2340
+ } catch {
2341
+ raw = undefined;
2342
+ }
2343
+ if (raw !== undefined) {
2344
+ try {
2345
+ const manifest: unknown = JSON.parse(raw);
2346
+ const type =
2347
+ typeof manifest === "object" && manifest !== null
2348
+ ? (manifest as { type?: unknown }).type
2349
+ : undefined;
2350
+ return type === "module" ? "module" : "commonjs";
2351
+ } catch {
2352
+ // A manifest that does not parse still bounds the package scope; Node
2353
+ // refuses to look past it, and CommonJS is the format it defaults to.
2354
+ return "commonjs";
2355
+ }
2356
+ }
2357
+ const parent = path.dirname(dir);
2358
+ if (parent === dir) {
2359
+ return "commonjs";
2360
+ }
2361
+ dir = parent;
2362
+ }
2363
+ }
2364
+
2271
2365
  function linkNearestNodeModules(tempDir: string, sourceDir: string): void {
2272
2366
  const nodeModules = findNearestNodeModules(sourceDir);
2273
2367
  if (!nodeModules) return;
@@ -2400,3 +2494,19 @@ function ttsxThroughNodeIfNeeded(binary: string): {
2400
2494
  }
2401
2495
  return { binary, prefix: [] };
2402
2496
  }
2497
+
2498
+ /**
2499
+ * Remove an evaluation temp directory without letting cleanup replace a result.
2500
+ *
2501
+ * This runs from a `finally`, so a throw here would surface instead of the
2502
+ * evaluation's own outcome — and on Windows a grandchild that inherited a
2503
+ * handle, or a scanner holding the file, can make removal fail. Leaving bytes
2504
+ * in the system temp directory is by far the lesser outcome.
2505
+ */
2506
+ function removeEvaluationTempDir(directory: string): void {
2507
+ try {
2508
+ fs.rmSync(directory, { force: true, recursive: true });
2509
+ } catch {
2510
+ // Best effort.
2511
+ }
2512
+ }
@@ -1,51 +1,31 @@
1
- export const CONFIG_EVALUATOR_MAX_BUFFER = 16 * 1024 * 1024;
2
- export const CONFIG_EVALUATOR_TIMEOUT_MS = 60_000;
3
- export const CONFIG_EVALUATOR_TEARDOWN_GRACE_MS = 5_000;
4
- export const CONFIG_EVALUATOR_STATUS_FD = 3;
5
- export const CONFIG_EVALUATOR_PROCESS_OPTIONS = Object.freeze({
6
- killSignal: "SIGKILL" as const,
7
- maxBuffer: CONFIG_EVALUATOR_MAX_BUFFER,
8
- timeout: CONFIG_EVALUATOR_TIMEOUT_MS + CONFIG_EVALUATOR_TEARDOWN_GRACE_MS,
9
- });
1
+ import fs from "node:fs";
10
2
 
11
3
  interface ConfigEvaluatorProcessResult {
12
4
  error?: Error;
13
- output?: readonly (string | null)[] | null;
14
5
  signal: NodeJS.Signals | null;
15
6
  status: number | null;
16
- stderr: string | null | undefined;
17
7
  }
18
8
 
19
9
  /**
20
10
  * Classify the ways the isolated lint-config evaluator can stop.
21
11
  *
22
- * Node reports both timeout and max-buffer termination with the configured
23
- * signal, so the process error code must take precedence over the signal. The
24
- * evaluator uses `SIGKILL`: Node's synchronous process API otherwise keeps
25
- * waiting when a POSIX child handles the default `SIGTERM` without exiting. A
26
- * bare signal is an external termination and a non-zero status is an evaluator
27
- * failure whose stderr tail contains the useful user-config diagnostic.
12
+ * The evaluator writes the child's own output straight to this process's
13
+ * stderr, so a diagnostic has already reached the user by the time anything
14
+ * here runs. What is left to say is only how the process ended: it never
15
+ * launched, something outside killed it, or it exited non-zero after printing
16
+ * its own reason.
17
+ *
18
+ * Nothing is bounded here — not time, not output. Both were the compiler
19
+ * deciding, on numbers nobody chose for this machine, that a user's own config
20
+ * had run too long or said too much. A slow config is a slow build the user can
21
+ * watch and interrupt; a loud one is output they asked for. Neither is this
22
+ * process's memory to spend either, because the child's streams are no longer
23
+ * collected into it.
28
24
  */
29
25
  export function configEvaluatorProcessFailure(
30
26
  result: ConfigEvaluatorProcessResult,
31
27
  configPath: string,
32
28
  ): Error | undefined {
33
- const code = (result.error as NodeJS.ErrnoException | undefined)?.code;
34
- const nestedCode = result.output?.[CONFIG_EVALUATOR_STATUS_FD]?.trim() ?? "";
35
- if (code === "ETIMEDOUT" || nestedCode === "ETIMEDOUT") {
36
- return new Error(
37
- `@ttsc/lint: ttsx evaluation of ${configPath} timed out after ` +
38
- `${CONFIG_EVALUATOR_TIMEOUT_MS / 1_000} seconds. ` +
39
- "Simplify the config or move heavy work out of top-level.",
40
- );
41
- }
42
- if (code === "ENOBUFS" || nestedCode === "ENOBUFS") {
43
- return new Error(
44
- `@ttsc/lint: ttsx evaluation of ${configPath} exceeded the ` +
45
- `${CONFIG_EVALUATOR_MAX_BUFFER / (1024 * 1024)} MiB output limit. ` +
46
- "Reduce console output from the config and its dependencies.",
47
- );
48
- }
49
29
  if (result.error) {
50
30
  return new Error(
51
31
  `@ttsc/lint: failed to spawn ttsx for ${configPath}: ${result.error.message}`,
@@ -57,43 +37,34 @@ export function configEvaluatorProcessFailure(
57
37
  );
58
38
  }
59
39
  if (result.status !== 0) {
60
- const reason = configEvaluatorFailureReason(result.stderr);
61
40
  return new Error(
62
- `@ttsc/lint: lint config ${configPath} evaluation failed with exit code ${String(result.status)}` +
63
- (reason === "" ? "" : "\n" + reason),
41
+ `@ttsc/lint: lint config ${configPath} evaluation failed with exit code ${String(result.status)}`,
64
42
  );
65
43
  }
66
44
  return undefined;
67
45
  }
68
46
 
69
47
  /**
70
- * Pass the semantic deadline and private status pipe through the `ttsx` wrapper
71
- * to the runtime child that actually executes the config.
72
- */
73
- export function configEvaluatorBoundaryEnvironment(
74
- now: number = Date.now(),
75
- ): NodeJS.ProcessEnv {
76
- return {
77
- TTSC_TTSX_EVALUATOR_DEADLINE_MS: String(now + CONFIG_EVALUATOR_TIMEOUT_MS),
78
- TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: String(CONFIG_EVALUATOR_MAX_BUFFER),
79
- TTSC_TTSX_EVALUATOR_STATUS_FD: String(CONFIG_EVALUATOR_STATUS_FD),
80
- };
81
- }
82
-
83
- /**
84
- * Return the useful tail of evaluator stderr without turning an exception into
85
- * an unbounded duplicate of the already-forwarded child stream.
48
+ * Read the failure envelope the evaluator writes to its result file when it
49
+ * stops on an error it can name.
50
+ *
51
+ * This is the other half of classifying how the evaluation ended, which is why
52
+ * it lives beside {@link configEvaluatorProcessFailure} rather than at the call
53
+ * site: the status says that it failed, and this says why.
54
+ *
55
+ * Only a well-formed envelope is honoured. A real evaluation payload never
56
+ * carries this key, and every other shape — an absent file, a build that failed
57
+ * before the loader ran, a half-written result, a payload written before a
58
+ * later non-zero exit — leaves the process status to speak for itself.
86
59
  */
87
- function configEvaluatorFailureReason(
88
- stderr: string | null | undefined,
89
- ): string {
90
- const bounded = (stderr ?? "").slice(-CONFIG_EVALUATOR_REASON_MAX_CHARS);
91
- const lines = bounded
92
- .split(/\r?\n/)
93
- .map((line) => line.trimEnd())
94
- .filter((line) => line.trim() !== "");
95
- return lines.slice(-CONFIG_EVALUATOR_REASON_LINES).join("\n");
60
+ export function configEvaluatorFailureReason(outputPath: string): string {
61
+ try {
62
+ const parsed: unknown = JSON.parse(fs.readFileSync(outputPath, "utf8"));
63
+ if (typeof parsed !== "object" || parsed === null) return "";
64
+ const message = (parsed as { __ttscLoaderError?: unknown })
65
+ .__ttscLoaderError;
66
+ return typeof message === "string" ? message.trim() : "";
67
+ } catch {
68
+ return "";
69
+ }
96
70
  }
97
-
98
- const CONFIG_EVALUATOR_REASON_LINES = 5;
99
- const CONFIG_EVALUATOR_REASON_MAX_CHARS = 8 * 1024;