ttsc 0.24.0 → 0.26.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.
Files changed (63) hide show
  1. package/README.md +70 -63
  2. package/cmd/ttscgraph/serve_answers_a_project_that_implements_its_own_ambient_declaration_test.go +69 -0
  3. package/internal/graph/assigned_implementations_of_declaration_file_symbols_leave_no_edge_source_test.go +84 -0
  4. package/internal/graph/assigned_implementations_of_node_modules_symbols_leave_no_edge_source_test.go +85 -0
  5. package/internal/graph/edges.go +15 -1
  6. package/internal/lspserver/lsp_native_plugin_source.go +5 -6
  7. package/internal/lspserver/lsp_native_resident.go +17 -24
  8. package/lib/TtscService.d.ts +1 -6
  9. package/lib/TtscService.js +2 -2
  10. package/lib/TtscService.js.map +1 -1
  11. package/lib/compiler/internal/captureProcessOutput.d.ts +26 -0
  12. package/lib/compiler/internal/captureProcessOutput.js +92 -0
  13. package/lib/compiler/internal/captureProcessOutput.js.map +1 -0
  14. package/lib/compiler/internal/compileProjectInMemory.js +4 -7
  15. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  16. package/lib/compiler/internal/residentCheckProcess.d.ts +0 -2
  17. package/lib/compiler/internal/residentCheckProcess.js +1 -15
  18. package/lib/compiler/internal/residentCheckProcess.js.map +1 -1
  19. package/lib/compiler/internal/residentTransformProcess.d.ts +0 -6
  20. package/lib/compiler/internal/residentTransformProcess.js +0 -25
  21. package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
  22. package/lib/compiler/internal/spawnNative.d.ts +14 -6
  23. package/lib/compiler/internal/spawnNative.js +32 -11
  24. package/lib/compiler/internal/spawnNative.js.map +1 -1
  25. package/lib/compiler/internal/startResidentTransform.d.ts +1 -5
  26. package/lib/compiler/internal/startResidentTransform.js +1 -3
  27. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  28. package/lib/launcher/internal/prepareExecution.d.ts +2 -1
  29. package/lib/launcher/internal/prepareExecution.js +269 -23
  30. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  31. package/lib/launcher/internal/registerRuntimeHooks.js +6 -1
  32. package/lib/launcher/internal/registerRuntimeHooks.js.map +1 -1
  33. package/lib/launcher/internal/runTtscserver.js +4 -9
  34. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  35. package/lib/launcher/internal/runTtsx.js +2 -55
  36. package/lib/launcher/internal/runTtsx.js.map +1 -1
  37. package/lib/launcher/internal/runtimeHooks.d.ts +25 -2
  38. package/lib/launcher/internal/runtimeHooks.js +344 -69
  39. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  40. package/lib/plugin/internal/buildSourcePlugin.js +31 -3
  41. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  42. package/lib/plugin/internal/descriptorProcessFailure.d.ts +23 -19
  43. package/lib/plugin/internal/descriptorProcessFailure.js +40 -51
  44. package/lib/plugin/internal/descriptorProcessFailure.js.map +1 -1
  45. package/lib/plugin/internal/loadProjectPlugins.d.ts +12 -0
  46. package/lib/plugin/internal/loadProjectPlugins.js +77 -28
  47. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  48. package/package.json +8 -8
  49. package/src/TtscService.ts +8 -20
  50. package/src/compiler/internal/captureProcessOutput.ts +96 -0
  51. package/src/compiler/internal/compileProjectInMemory.ts +7 -7
  52. package/src/compiler/internal/residentCheckProcess.ts +1 -21
  53. package/src/compiler/internal/residentTransformProcess.ts +0 -42
  54. package/src/compiler/internal/spawnNative.ts +39 -18
  55. package/src/compiler/internal/startResidentTransform.ts +1 -12
  56. package/src/launcher/internal/prepareExecution.ts +297 -26
  57. package/src/launcher/internal/registerRuntimeHooks.ts +11 -2
  58. package/src/launcher/internal/runTtscserver.ts +4 -9
  59. package/src/launcher/internal/runTtsx.ts +2 -78
  60. package/src/launcher/internal/runtimeHooks.ts +385 -82
  61. package/src/plugin/internal/buildSourcePlugin.ts +36 -3
  62. package/src/plugin/internal/descriptorProcessFailure.ts +34 -65
  63. package/src/plugin/internal/loadProjectPlugins.ts +78 -39
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type SpawnSyncOptionsWithStringEncoding,
3
3
  type SpawnSyncReturns,
4
+ type StdioOptions,
4
5
  spawnSync,
5
6
  } from "node:child_process";
6
7
  import crypto from "node:crypto";
@@ -10,6 +11,7 @@ import os from "node:os";
10
11
  import path from "node:path";
11
12
  import { fileURLToPath } from "node:url";
12
13
 
14
+ import { captureProcessOutput } from "../../compiler/internal/captureProcessOutput";
13
15
  import { findNearestGoMod } from "../../compiler/internal/paths";
14
16
 
15
17
  const GO_MOD_SEARCH_MAX_DEPTH = 3;
@@ -1646,7 +1648,6 @@ function readGoModInfo(
1646
1648
  cwd: dir,
1647
1649
  encoding: "utf8",
1648
1650
  env: goBuildEnv(goBinary, undefined, env),
1649
- maxBuffer: 1024 * 1024 * 16,
1650
1651
  windowsHide: true,
1651
1652
  });
1652
1653
  if (result.error) {
@@ -1737,7 +1738,6 @@ function runGoBuild(
1737
1738
  cwd,
1738
1739
  encoding: "utf8",
1739
1740
  env: goBuildEnv(goBinary, goBuildCacheRoot, env),
1740
- maxBuffer: 1024 * 1024 * 64,
1741
1741
  windowsHide: true,
1742
1742
  });
1743
1743
  if (result.error) {
@@ -1767,6 +1767,40 @@ export function spawnGoTool(
1767
1767
  goBinary: string,
1768
1768
  args: readonly string[],
1769
1769
  options: SpawnSyncOptionsWithStringEncoding,
1770
+ ): SpawnSyncReturns<string> {
1771
+ // The child's streams go to files rather than pipes, so no output ceiling
1772
+ // applies: `spawnSync` only bounds what it must hold in this process's
1773
+ // memory, and a `go build` that says a great deal is not a failure to invent
1774
+ // a limit for. See `captureProcessOutput`.
1775
+ const capture = captureProcessOutput();
1776
+ const spawnOptions = {
1777
+ ...options,
1778
+ stdio: ["ignore", capture.stdoutFd, capture.stderrFd] as StdioOptions,
1779
+ };
1780
+ try {
1781
+ const result = spawnGoToolProcess(goBinary, args, spawnOptions);
1782
+ const stdout = capture.read("stdout", "utf8") as string;
1783
+ const stderr = capture.read("stderr", "utf8") as string;
1784
+ return {
1785
+ ...result,
1786
+ output: [null, stdout, stderr],
1787
+ stderr,
1788
+ stdout,
1789
+ };
1790
+ } finally {
1791
+ capture.dispose();
1792
+ }
1793
+ }
1794
+
1795
+ /**
1796
+ * Launch the Go tool, routing through cmd.exe only where Windows needs a
1797
+ * wrapper. Output routing is the caller's concern; this owns process
1798
+ * selection.
1799
+ */
1800
+ function spawnGoToolProcess(
1801
+ goBinary: string,
1802
+ args: readonly string[],
1803
+ options: SpawnSyncOptionsWithStringEncoding,
1770
1804
  ): SpawnSyncReturns<string> {
1771
1805
  if (process.platform !== "win32") {
1772
1806
  return spawnSync(goBinary, [...args], options);
@@ -2734,7 +2768,6 @@ function resolveGoBuildEnvironment(
2734
2768
  cwd,
2735
2769
  encoding: "utf8",
2736
2770
  env: goBuildEnv(goBinary, undefined, env),
2737
- maxBuffer: 1024 * 1024,
2738
2771
  windowsHide: true,
2739
2772
  },
2740
2773
  );
@@ -1,50 +1,30 @@
1
- export const PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
2
- export const PLUGIN_DESCRIPTOR_TIMEOUT_MS = 60_000;
3
- export const PLUGIN_DESCRIPTOR_TEARDOWN_GRACE_MS = 5_000;
4
- export const PLUGIN_DESCRIPTOR_STATUS_FD = 3;
5
- export const PLUGIN_DESCRIPTOR_PROCESS_OPTIONS = Object.freeze({
6
- killSignal: "SIGKILL" as const,
7
- maxBuffer: PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES,
8
- timeout: PLUGIN_DESCRIPTOR_TIMEOUT_MS + PLUGIN_DESCRIPTOR_TEARDOWN_GRACE_MS,
9
- });
1
+ import fs from "node:fs";
10
2
 
11
3
  interface DescriptorProcessResult {
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
- stdout: string | null | undefined;
18
7
  }
19
8
 
20
9
  /**
21
10
  * Classify the ways the isolated TypeScript descriptor evaluator can stop.
22
11
  *
23
- * Node reports both timeout and max-buffer termination with the configured
24
- * signal, so the process error code must take precedence over the signal. The
25
- * evaluator uses `SIGKILL`: Node's synchronous process API otherwise keeps
26
- * waiting when a POSIX child handles the default `SIGTERM` without exiting.
12
+ * The loader writes the child's own output straight to this process's stderr,
13
+ * so a diagnostic has already reached the user by the time anything here runs.
14
+ * What is left to say is only how the process ended: it never launched,
15
+ * something outside killed it, or it exited non-zero after printing its own
16
+ * 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
20
+ * descriptor had run too long or said too much. Neither is this process's
21
+ * memory to spend either, because the child's streams are no longer collected
22
+ * into it.
27
23
  */
28
24
  export function pluginDescriptorProcessFailure(
29
25
  result: DescriptorProcessResult,
30
26
  request: string,
31
27
  ): Error | undefined {
32
- const code = (result.error as NodeJS.ErrnoException | undefined)?.code;
33
- const nestedCode = result.output?.[PLUGIN_DESCRIPTOR_STATUS_FD]?.trim() ?? "";
34
- if (code === "ETIMEDOUT" || nestedCode === "ETIMEDOUT") {
35
- return new Error(
36
- `ttsc: plugin descriptor "${request}" evaluation through ttsx timed out after ` +
37
- `${PLUGIN_DESCRIPTOR_TIMEOUT_MS / 1_000} seconds. ` +
38
- "Descriptor modules and factories must finish their setup within that window.",
39
- );
40
- }
41
- if (code === "ENOBUFS" || nestedCode === "ENOBUFS") {
42
- return new Error(
43
- `ttsc: plugin descriptor "${request}" evaluation through ttsx exceeded the ` +
44
- `${PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES / (1024 * 1024)} MiB process output limit. ` +
45
- "Reduce stdout and stderr from the descriptor and its dependencies.",
46
- );
47
- }
48
28
  if (result.error) {
49
29
  return new Error(
50
30
  `ttsc: failed to launch ttsx for plugin descriptor "${request}": ${result.error.message}`,
@@ -56,45 +36,34 @@ export function pluginDescriptorProcessFailure(
56
36
  );
57
37
  }
58
38
  if (result.status !== 0) {
59
- const reason = descriptorFailureReason(
60
- hasText(result.stderr) ? result.stderr : result.stdout,
61
- );
62
39
  return new Error(
63
- `ttsc: plugin descriptor "${request}" evaluation through ttsx failed with exit code ${String(result.status)}` +
64
- (reason === "" ? "" : "\n" + reason),
40
+ `ttsc: plugin descriptor "${request}" evaluation through ttsx failed with exit code ${String(result.status)}`,
65
41
  );
66
42
  }
67
43
  return undefined;
68
44
  }
69
45
 
70
46
  /**
71
- * Pass the semantic deadline and private status pipe through the `ttsx` wrapper
72
- * to the runtime child that actually executes the descriptor.
47
+ * Read the failure envelope the descriptor shim writes to its result file when
48
+ * it stops on an error it can name.
49
+ *
50
+ * This is the other half of classifying how the evaluation ended, which is why
51
+ * it lives beside {@link pluginDescriptorProcessFailure} rather than at the call
52
+ * site: the status says that it failed, and this says why.
53
+ *
54
+ * Only a well-formed envelope is honoured. A real descriptor never carries this
55
+ * key, and every other shape — an absent file, a shim that died before writing,
56
+ * a half-written result, a descriptor written before a later non-zero exit —
57
+ * leaves the process status to speak for itself.
73
58
  */
74
- export function pluginDescriptorBoundaryEnvironment(
75
- now: number = Date.now(),
76
- ): NodeJS.ProcessEnv {
77
- return {
78
- TTSC_TTSX_EVALUATOR_DEADLINE_MS: String(now + PLUGIN_DESCRIPTOR_TIMEOUT_MS),
79
- TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: String(
80
- PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES,
81
- ),
82
- TTSC_TTSX_EVALUATOR_STATUS_FD: String(PLUGIN_DESCRIPTOR_STATUS_FD),
83
- };
84
- }
85
-
86
- function hasText(value: string | null | undefined): value is string {
87
- return value !== null && value !== undefined && value.trim() !== "";
88
- }
89
-
90
- function descriptorFailureReason(stderr: string | null | undefined): string {
91
- const bounded = (stderr ?? "").slice(-PLUGIN_DESCRIPTOR_REASON_MAX_CHARS);
92
- const lines = bounded
93
- .split(/\r?\n/)
94
- .map((line) => line.trimEnd())
95
- .filter((line) => line.trim() !== "");
96
- return lines.slice(-PLUGIN_DESCRIPTOR_REASON_LINES).join("\n");
59
+ export function pluginDescriptorFailureReason(outputPath: string): string {
60
+ try {
61
+ const parsed: unknown = JSON.parse(fs.readFileSync(outputPath, "utf8"));
62
+ if (typeof parsed !== "object" || parsed === null) return "";
63
+ const message = (parsed as { __ttscLoaderError?: unknown })
64
+ .__ttscLoaderError;
65
+ return typeof message === "string" ? message.trim() : "";
66
+ } catch {
67
+ return "";
68
+ }
97
69
  }
98
-
99
- const PLUGIN_DESCRIPTOR_REASON_LINES = 5;
100
- const PLUGIN_DESCRIPTOR_REASON_MAX_CHARS = 8 * 1024;
@@ -16,10 +16,7 @@ import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoa
16
16
  import type { ITtscParsedProjectConfig } from "../../structures/internal/ITtscParsedProjectConfig";
17
17
  import { buildSourcePlugin } from "./buildSourcePlugin";
18
18
  import {
19
- PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES,
20
- PLUGIN_DESCRIPTOR_PROCESS_OPTIONS,
21
- PLUGIN_DESCRIPTOR_STATUS_FD,
22
- pluginDescriptorBoundaryEnvironment,
19
+ pluginDescriptorFailureReason,
23
20
  pluginDescriptorProcessFailure,
24
21
  } from "./descriptorProcessFailure";
25
22
 
@@ -696,6 +693,50 @@ function requirePluginEntry(
696
693
 
697
694
  const TS_SOURCE_PATTERN = /\.(?:[cm]?ts|tsx)$/i;
698
695
 
696
+ /**
697
+ * The descriptor shim's emitted source.
698
+ *
699
+ * Exported so a regression can inspect the same bytes ttsx executes. This
700
+ * template consumes its own escapes, so reading this file's text instead would
701
+ * check characters no consumer ever sees — and a dropped backslash turns an
702
+ * escape into the character it was escaping: a raw line terminator inside a
703
+ * string literal, which stops the shim parsing and takes every descriptor load
704
+ * with it. Its `@ttsc/lint` twin has carried that guard since the same defect
705
+ * shipped there.
706
+ */
707
+ export const PLUGIN_DESCRIPTOR_SHIM_SOURCE = [
708
+ `// @ts-nocheck`,
709
+ `import { writeFileSync } from "node:fs";`,
710
+ `import { pathToFileURL } from "node:url";`,
711
+ // The import is inside the try, not above it. A descriptor that cannot be
712
+ // found, or whose module body throws, fails exactly where a descriptor
713
+ // whose factory throws does, and a caller deserves the same reason for
714
+ // both — "Cannot find module ./missing" is as actionable as anything the
715
+ // factory could have said.
716
+ `try {`,
717
+ ` const mod = await import(pathToFileURL(process.env.TTSC_PLUGIN_ENTRY).href);`,
718
+ ` const context = JSON.parse(process.env.TTSC_PLUGIN_CONTEXT);`,
719
+ ` const candidate = mod.createTtscPlugin ?? mod.default ?? mod.plugin ?? mod;`,
720
+ ` const descriptor =`,
721
+ ` typeof candidate === "function" ? candidate(context) : candidate;`,
722
+ ` writeFileSync(process.env.TTSC_PLUGIN_DESCRIPTOR_OUT, JSON.stringify(descriptor));`,
723
+ `} catch (error) {`,
724
+ // The stack streams to the user's stderr on its own. This puts the reason
725
+ // a caller can act on into the channel the parent already reads, so the
726
+ // failure is not reduced to a bare exit status.
727
+ // The escape is doubled on purpose: this template consumes one level, so
728
+ // `\\n` here is what puts the two-character escape into the emitted shim.
729
+ // A single `\n` would put a raw line terminator inside a string literal,
730
+ // and the shim would stop parsing — taking every descriptor load with it.
731
+ ` process.stderr.write((error instanceof Error && error.stack ? error.stack : String(error)) + "\\n");`,
732
+ ` try {`,
733
+ ` writeFileSync(process.env.TTSC_PLUGIN_DESCRIPTOR_OUT, JSON.stringify({ __ttscLoaderError: error instanceof Error ? error.message : String(error) }));`,
734
+ ` } catch {}`,
735
+ ` process.exit(1);`,
736
+ `}`,
737
+ ``,
738
+ ].join("\n");
739
+
699
740
  /**
700
741
  * Evaluate a `.ts` plugin descriptor entry in a child `ttsx` process and return
701
742
  * the descriptor it produces. A generated shim imports the entry, invokes its
@@ -735,21 +776,7 @@ function loadDescriptorViaTtsx(
735
776
  },
736
777
  }),
737
778
  );
738
- fs.writeFileSync(
739
- shim,
740
- [
741
- `// @ts-nocheck`,
742
- `import { writeFileSync } from "node:fs";`,
743
- `import { pathToFileURL } from "node:url";`,
744
- `const mod = await import(pathToFileURL(process.env.TTSC_PLUGIN_ENTRY).href);`,
745
- `const context = JSON.parse(process.env.TTSC_PLUGIN_CONTEXT);`,
746
- `const candidate = mod.createTtscPlugin ?? mod.default ?? mod.plugin ?? mod;`,
747
- `const descriptor =`,
748
- ` typeof candidate === "function" ? candidate(context) : candidate;`,
749
- `writeFileSync(process.env.TTSC_PLUGIN_DESCRIPTOR_OUT, JSON.stringify(descriptor));`,
750
- ``,
751
- ].join("\n"),
752
- );
779
+ fs.writeFileSync(shim, PLUGIN_DESCRIPTOR_SHIM_SOURCE);
753
780
  try {
754
781
  const result = childProcess.spawnSync(node, [ttsx, "--no-plugins", shim], {
755
782
  cwd: context.projectRoot,
@@ -773,34 +800,30 @@ function loadDescriptorViaTtsx(
773
800
  TTSC_PLUGIN_DESCRIPTOR_LOAD: "1",
774
801
  TTSC_PLUGIN_DESCRIPTOR_OUT: out,
775
802
  TTSC_PLUGIN_ENTRY: request,
776
- ...pluginDescriptorBoundaryEnvironment(),
777
803
  },
778
- ...PLUGIN_DESCRIPTOR_PROCESS_OPTIONS,
779
- stdio: [
780
- "ignore",
781
- "pipe",
782
- "pipe",
783
- ...Array.from(
784
- { length: PLUGIN_DESCRIPTOR_STATUS_FD - 2 },
785
- () => "pipe" as const,
786
- ),
787
- ],
804
+ // Both child streams are human output, and they go straight to this
805
+ // process's stderr as they are written. The descriptor itself travels
806
+ // through a file, so nothing here needs collecting — and collecting it
807
+ // only to replay it afterwards is what forced an invented output ceiling.
808
+ stdio: ["ignore", 2, 2],
788
809
  windowsHide: true,
789
810
  });
790
811
  const processFailure = pluginDescriptorProcessFailure(result, request);
791
- if (processFailure) throw processFailure;
812
+ if (processFailure) {
813
+ // The descriptor's stack already reached the user's stderr as it ran.
814
+ // What it could not put there is a reason a caller can act on, so that
815
+ // arrives through the result file instead.
816
+ const reason = pluginDescriptorFailureReason(out);
817
+ throw reason === ""
818
+ ? processFailure
819
+ : new Error(`${processFailure.message}
820
+ ${reason}`);
821
+ }
792
822
  if (!fs.existsSync(out)) {
793
823
  throw new Error(
794
824
  `ttsc: plugin descriptor "${request}" evaluation through ttsx produced no descriptor output.`,
795
825
  );
796
826
  }
797
- const outputSize = fs.statSync(out).size;
798
- if (outputSize > PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES) {
799
- throw new Error(
800
- `ttsc: plugin descriptor "${request}" produced ${outputSize} bytes of JSON, ` +
801
- `exceeding the ${PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES / (1024 * 1024)} MiB descriptor output limit.`,
802
- );
803
- }
804
827
  const text = fs.readFileSync(out, "utf8");
805
828
  try {
806
829
  return JSON.parse(text);
@@ -810,7 +833,7 @@ function loadDescriptorViaTtsx(
810
833
  );
811
834
  }
812
835
  } finally {
813
- fs.rmSync(dir, { force: true, recursive: true });
836
+ removeEvaluationTempDir(dir);
814
837
  }
815
838
  }
816
839
 
@@ -1389,3 +1412,19 @@ function readTsgoVersion(projectRoot: string): string {
1389
1412
  return "unknown";
1390
1413
  }
1391
1414
  }
1415
+
1416
+ /**
1417
+ * Remove an evaluation temp directory without letting cleanup replace a result.
1418
+ *
1419
+ * This runs from a `finally`, so a throw here would surface instead of the
1420
+ * evaluation's own outcome — and on Windows a grandchild that inherited a
1421
+ * handle, or a scanner holding the file, can make removal fail. Leaving bytes
1422
+ * in the system temp directory is by far the lesser outcome.
1423
+ */
1424
+ function removeEvaluationTempDir(directory: string): void {
1425
+ try {
1426
+ fs.rmSync(directory, { force: true, recursive: true });
1427
+ } catch {
1428
+ // Best effort.
1429
+ }
1430
+ }