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
@@ -7,12 +7,6 @@ const STDERR_TAIL_LIMIT = 64 * 1024;
7
7
  /** Cap on how much of an offending line an error message echoes back. */
8
8
  const REPLY_ECHO_LIMIT = 200;
9
9
 
10
- /** Default deadline for one request to the resident transform host. */
11
- const DEFAULT_REQUEST_TIMEOUT_MS = 300_000;
12
-
13
- /** Node clamps timers beyond this signed 32-bit millisecond duration. */
14
- const MAX_TIMER_MS = 2_147_483_647;
15
-
16
10
  /** Allow a cooperative host a short shutdown window before forcing it down. */
17
11
  const TERMINATION_GRACE_MS = 1_000;
18
12
 
@@ -32,8 +26,6 @@ export interface ResidentTransformProcessOptions {
32
26
  binary: string;
33
27
  cwd?: string;
34
28
  env?: NodeJS.ProcessEnv;
35
- /** Maximum time one request may wait for the resident host's reply. */
36
- requestTimeoutMs?: number;
37
29
  }
38
30
 
39
31
  /** Per-request lifecycle controls for a resident transform host. */
@@ -49,7 +41,6 @@ interface PendingRequest {
49
41
  resolve: (reply: Record<string, unknown>) => void;
50
42
  settled: boolean;
51
43
  signal?: AbortSignal;
52
- timer: NodeJS.Timeout;
53
44
  }
54
45
 
55
46
  /**
@@ -70,12 +61,10 @@ export class ResidentTransformProcess {
70
61
  private readonly child: ChildProcess;
71
62
  private readonly reader: Interface;
72
63
  private readonly pending: PendingRequest[] = [];
73
- private readonly requestTimeoutMs: number;
74
64
  private stderr = "";
75
65
  private failure: Error | undefined;
76
66
 
77
67
  public constructor(options: ResidentTransformProcessOptions) {
78
- this.requestTimeoutMs = normalizeRequestTimeoutMs(options.requestTimeoutMs);
79
68
  // Default stdio is "pipe" for stdin/stdout/stderr, which is exactly what the
80
69
  // line protocol needs; spelling it out as a string[] would not narrow to
81
70
  // StdioOptions, so it is left implicit.
@@ -153,9 +142,7 @@ export class ResidentTransformProcess {
153
142
  resolve,
154
143
  settled: false,
155
144
  signal: options.signal,
156
- timer: setTimeout(() => this.timeout(pending), this.requestTimeoutMs),
157
145
  };
158
- pending.timer.unref();
159
146
  if (options.signal !== undefined) {
160
147
  pending.abort = () => this.cancel(pending, options.signal!);
161
148
  options.signal.addEventListener("abort", pending.abort, { once: true });
@@ -285,7 +272,6 @@ export class ResidentTransformProcess {
285
272
  pending.settled = true;
286
273
  const index = this.pending.indexOf(pending);
287
274
  if (index !== -1) this.pending.splice(index, 1);
288
- clearTimeout(pending.timer);
289
275
  if (pending.signal !== undefined && pending.abort !== undefined) {
290
276
  pending.signal.removeEventListener("abort", pending.abort);
291
277
  }
@@ -293,23 +279,6 @@ export class ResidentTransformProcess {
293
279
  else pending.resolve(result);
294
280
  }
295
281
 
296
- private timeout(pending: PendingRequest): void {
297
- if (pending.settled) return;
298
- const timeout = new Error(
299
- `ttsc: resident transform request timed out after ${String(
300
- this.requestTimeoutMs,
301
- )} ms${stderrSuffix(this.stderr)}`,
302
- );
303
- this.settlePending(pending, timeout);
304
- this.fail(
305
- new Error(
306
- `ttsc: resident transform host retired after another request timed out${stderrSuffix(
307
- this.stderr,
308
- )}`,
309
- ),
310
- );
311
- }
312
-
313
282
  private cancel(pending: PendingRequest, signal: AbortSignal): void {
314
283
  if (pending.settled) return;
315
284
  this.settlePending(pending, cancelledError(signal));
@@ -362,17 +331,6 @@ export class ResidentTransformProcess {
362
331
  }
363
332
  }
364
333
 
365
- /** Validate and normalize the caller-visible resident request deadline. */
366
- export function normalizeRequestTimeoutMs(requestTimeoutMs?: number): number {
367
- const value = requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
368
- if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_MS) {
369
- throw new TypeError(
370
- `ttsc: requestTimeoutMs must be an integer between 1 and ${String(MAX_TIMER_MS)}`,
371
- );
372
- }
373
- return value;
374
- }
375
-
376
334
  function cancelledError(signal: AbortSignal): Error {
377
335
  const error = new Error(
378
336
  `ttsc: resident transform request cancelled${abortDetail(signal)}`,
@@ -1,13 +1,22 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { type SpawnSyncReturns, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
 
4
+ import { captureProcessOutput } from "./captureProcessOutput";
5
+
4
6
  /**
5
7
  * Spawn a native binary (or a Node.js script when the path has a JS/TS
6
- * extension) with the given args and return the `spawnSync` result. Sets a
7
- * generous 256 MiB output buffer so large projects do not truncate.
8
+ * extension) and return its result with `stdout` and `stderr` as text.
9
+ *
10
+ * The child's streams are written to files rather than piped, and the files are
11
+ * read once it exits. `spawnSync` buffers a _piped_ stream in this process's
12
+ * memory and refuses to hold more than `maxBuffer` bytes, so a piped capture
13
+ * has to name a ceiling — and any ceiling is a number nobody chose for this
14
+ * machine, deciding that a large but legitimate compile said too much. A file
15
+ * has no such limit: the bytes never pass through this heap on their way out of
16
+ * the child, and how many there may be is the filesystem's business.
8
17
  *
9
- * When `options.encoding` is omitted it defaults to `"utf8"`. Pass an explicit
10
- * value when the caller needs a different encoding or raw `Buffer` output.
18
+ * When `options.encoding` is omitted it defaults to `"utf8"`. Pass `"buffer"`
19
+ * when the caller needs raw bytes.
11
20
  */
12
21
  export function spawnNative(
13
22
  binary: string,
@@ -15,24 +24,36 @@ export function spawnNative(
15
24
  options: {
16
25
  cwd?: string;
17
26
  env?: NodeJS.ProcessEnv;
18
- encoding?: BufferEncoding;
27
+ encoding?: BufferEncoding | "buffer";
19
28
  },
20
- ) {
29
+ ): SpawnSyncReturns<string | Buffer> {
21
30
  const viaNode = /\.(?:[cm]?js|ts)$/i.test(binary);
22
31
  if (!viaNode) {
23
32
  ensureExecutable(binary);
24
33
  }
25
- return spawnSync(
26
- viaNode ? process.execPath : binary,
27
- viaNode ? [binary, ...args] : [...args],
28
- {
29
- cwd: options.cwd,
30
- encoding: options.encoding ?? "utf8",
31
- env: options.env,
32
- maxBuffer: 1024 * 1024 * 256,
33
- windowsHide: true,
34
- },
35
- );
34
+ const capture = captureProcessOutput();
35
+ try {
36
+ const result = spawnSync(
37
+ viaNode ? process.execPath : binary,
38
+ viaNode ? [binary, ...args] : [...args],
39
+ {
40
+ cwd: options.cwd,
41
+ env: options.env,
42
+ stdio: ["ignore", capture.stdoutFd, capture.stderrFd],
43
+ windowsHide: true,
44
+ },
45
+ );
46
+ const stdout = capture.read("stdout", options.encoding);
47
+ const stderr = capture.read("stderr", options.encoding);
48
+ return {
49
+ ...result,
50
+ output: [null, stdout, stderr],
51
+ stderr,
52
+ stdout,
53
+ } as SpawnSyncReturns<string | Buffer>;
54
+ } finally {
55
+ capture.dispose();
56
+ }
36
57
  }
37
58
 
38
59
  /**
@@ -4,10 +4,7 @@ import { loadProjectPlugins } from "../../plugin/internal/loadProjectPlugins";
4
4
  import type { ITtscCompilerContext } from "../../structures/ITtscCompilerContext";
5
5
  import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoadedNativePlugin";
6
6
  import { readProjectConfig } from "./project/readProjectConfig";
7
- import {
8
- ResidentTransformProcess,
9
- normalizeRequestTimeoutMs,
10
- } from "./residentTransformProcess";
7
+ import { ResidentTransformProcess } from "./residentTransformProcess";
11
8
  import { resolveBinary } from "./resolveBinary";
12
9
  import { resolveTsgo } from "./resolveTsgo";
13
10
  import {
@@ -26,11 +23,6 @@ export interface StartedResidentTransform {
26
23
  projectRoot: string;
27
24
  }
28
25
 
29
- /** Construction controls owned by the resident request client. */
30
- export interface StartResidentTransformOptions {
31
- requestTimeoutMs?: number;
32
- }
33
-
34
26
  /**
35
27
  * Start a resident `serve` host for the configured project.
36
28
  *
@@ -48,9 +40,7 @@ export interface StartResidentTransformOptions {
48
40
  */
49
41
  export function startResidentTransform(
50
42
  context: ITtscCompilerContext,
51
- options: StartResidentTransformOptions = {},
52
43
  ): StartedResidentTransform {
53
- const requestTimeoutMs = normalizeRequestTimeoutMs(options.requestTimeoutMs);
54
44
  const cwd = path.resolve(context.cwd ?? process.cwd());
55
45
  const project = readProjectConfig({
56
46
  cwd,
@@ -90,7 +80,6 @@ export function startResidentTransform(
90
80
  binary: host.binary,
91
81
  cwd: project.root,
92
82
  env: residentEnv(context, tsgoBinary, loaded.nativePlugins),
93
- requestTimeoutMs,
94
83
  });
95
84
  return { process: resident, projectRoot: project.root };
96
85
  }
@@ -2,10 +2,13 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ import { isOutsideRelativePath } from "../../compiler/internal/paths";
5
6
  import { readProjectConfig } from "../../compiler/internal/project/readProjectConfig";
6
7
  import { resolveEmittedJavaScript } from "../../compiler/internal/resolveEmittedJavaScript";
7
8
  import { runBuild } from "../../compiler/internal/runBuild";
9
+ import { createFilesystemPathIdentityContext } from "../../internal/projectInputPathIdentity";
8
10
  import type { TtscCommonOptions } from "../../structures/internal/TtscCommonOptions";
11
+ import { type OwningModuleOptions, projectModuleOptions } from "./runtimeHooks";
9
12
 
10
13
  /** Subdirectory name that isolates concurrent ttsx processes by PID. */
11
14
  const PROCESS_CACHE_KEY = String(process.pid);
@@ -16,6 +19,12 @@ const PROCESS_CACHE_KEY = String(process.pid);
16
19
  * resolve correctly without reaching an unsafe boundary.
17
20
  */
18
21
  const MAX_VIRTUAL_PARENT_DEPTH = 3;
22
+ /**
23
+ * Emit directory of the entry-only fallback build, a sibling of the virtual
24
+ * layout's volume-label directories so it can never collide with a mirrored
25
+ * project path.
26
+ */
27
+ const ENTRY_PROJECT_EMIT_DIR = "entry-project";
19
28
 
20
29
  /** Build the owning project and locate the emitted JavaScript entry for `ttsx`. */
21
30
  export function prepareExecution(
@@ -29,23 +38,35 @@ export function prepareExecution(
29
38
  emitDir: string;
30
39
  emittedFiles?: readonly string[];
31
40
  entryFile: string;
32
- moduleOption?: string;
41
+ moduleOptions: OwningModuleOptions;
33
42
  projectRoot: string;
34
43
  rootDir: string;
35
44
  } {
45
+ // Two paths, because two different questions are being asked.
46
+ //
47
+ // *Which project compiles this?* is answered from the path the user named.
48
+ // Project discovery walks up from it, so resolving a symlinked entry first
49
+ // would start that walk in the target's tree — finding another project's
50
+ // tsconfig, or none at all.
51
+ //
52
+ // *Where does the compiler put the output, and what does the runtime load?*
53
+ // is answered from the physical path, because that is the spelling Node
54
+ // forces. See `resolveEntrySpelling`.
55
+ const cwd = path.resolve(options.cwd ?? process.cwd());
56
+ const entry = resolveEntrySpelling(cwd, entryFile);
36
57
  const context = createProjectContext(
37
- path.resolve(options.cwd ?? process.cwd()),
38
- entryFile,
58
+ cwd,
59
+ path.resolve(cwd, entryFile),
60
+ entry,
39
61
  options,
40
62
  );
41
63
  try {
42
64
  buildProject(context, options);
43
- const emittedEntry = resolveEmittedJavaScript({
44
- emittedFiles: context.emittedFiles ?? undefined,
45
- outDir: context.emitDir,
46
- projectRoot: context.root,
47
- sourceFile: entryFile,
48
- });
65
+ let emittedEntry = emittedEntryOf(context, entry);
66
+ if (emittedEntry === null) {
67
+ buildEntryProject(context, options, entry);
68
+ emittedEntry = emittedEntryOf(context, entry);
69
+ }
49
70
  if (emittedEntry === null) {
50
71
  throw new Error(`ttsx: emitted entry not found for ${entryFile}`);
51
72
  }
@@ -54,7 +75,7 @@ export function prepareExecution(
54
75
  emitDir: context.emitDir,
55
76
  emittedFiles: context.emittedFiles ?? undefined,
56
77
  entryFile: emittedEntry,
57
- moduleOption: context.moduleOption,
78
+ moduleOptions: context.moduleOptions,
58
79
  projectRoot: context.root,
59
80
  rootDir: context.runtimeRootDir,
60
81
  };
@@ -64,9 +85,79 @@ export function prepareExecution(
64
85
  }
65
86
  }
66
87
 
88
+ /**
89
+ * The JavaScript this build emitted for `entry`, or `null` when it emitted none
90
+ * — which is the signal that the entry sits outside the project's file set.
91
+ *
92
+ * The guard is what makes this an ownership answer rather than a guess. tsgo
93
+ * strips `runtimeRootDir` from every output path, so a file outside that root
94
+ * cannot have an output under `outDir` at all; without the guard the lookup
95
+ * falls through to `resolveEmittedJavaScript`'s trailing-stem matcher, and a
96
+ * `build/release.ts` would happily match the `release.js` emitted for an
97
+ * unrelated `src/release.ts` — running the wrong file instead of compiling the
98
+ * requested one.
99
+ *
100
+ * It is lexical on purpose, and it is the same test `resolveEmittedJavaScript`
101
+ * then applies — including its rejection of an entry that _is_ the root. That
102
+ * only holds because both sides are _produced_ physically: the entry by
103
+ * `resolveEntrySpelling`, the root by `resolveRuntimeSourceRoot`. Folding here
104
+ * instead would paper over a mixed pair and disagree with the mirror that runs
105
+ * immediately after; keeping the pair honest is what makes folding
106
+ * unnecessary.
107
+ */
108
+ function emittedEntryOf(
109
+ context: ReturnType<typeof createProjectContext>,
110
+ entry: string,
111
+ ): string | null {
112
+ const relative = path.relative(context.runtimeRootDir, entry);
113
+ if (relative === "" || isOutsideRelativePath(relative)) {
114
+ return null;
115
+ }
116
+ return resolveEmittedJavaScript({
117
+ emittedFiles: context.emittedFiles ?? undefined,
118
+ outDir: context.emitDir,
119
+ projectRoot: context.runtimeRootDir,
120
+ sourceFile: entry,
121
+ });
122
+ }
123
+
124
+ /**
125
+ * The entry in the filesystem's own spelling, symlinked file included.
126
+ *
127
+ * Node is what forces the choice. Without `--preserve-symlinks` it keys a
128
+ * module by its real path, so the runtime hooks identify a served file that way
129
+ * too — and the emit has to be findable under the same name. tsgo does not
130
+ * force anything: it takes `files` verbatim and never resolves them, which is
131
+ * exactly why it must be handed the spelling Node will use rather than a
132
+ * different one.
133
+ *
134
+ * An entry spelled any other way is not a nicer name for the same file. The
135
+ * gate would claim to own an emit the runtime then refuses to serve, and the
136
+ * entry would run through the orphan type-strip lane with the project's
137
+ * transform plugins, `target`, `paths`, and source map all silently dropped —
138
+ * from a run that still prints and still exits zero.
139
+ *
140
+ * Resolving the link widens `rootDir` to the ancestor the two trees share,
141
+ * which is not a cost but the requirement: the file genuinely lives outside the
142
+ * project, and no root that excludes it can compile it.
143
+ */
144
+ function resolveEntrySpelling(cwd: string, entryFile: string): string {
145
+ const identities = createFilesystemPathIdentityContext({
146
+ throwOnRealpathError: false,
147
+ });
148
+ return identities.resolve(path.resolve(cwd, entryFile)).path;
149
+ }
150
+
151
+ /**
152
+ * @param discoveryFile - The entry as the user named it. Project discovery
153
+ * walks up from here, so it must not be retargeted through a symlink.
154
+ * @param entry - The entry in its physical spelling, which is what the emit
155
+ * layout and the runtime hooks both speak.
156
+ */
67
157
  function createProjectContext(
68
158
  cwd: string,
69
- filename: string,
159
+ discoveryFile: string,
160
+ entry: string,
70
161
  options: NonNullable<Parameters<typeof prepareExecution>[1]>,
71
162
  ) {
72
163
  const project = readProjectConfig(
@@ -76,7 +167,7 @@ function createProjectContext(
76
167
  projectRoot: options.projectRoot,
77
168
  tsconfig: path.resolve(cwd, options.project),
78
169
  }
79
- : { cwd, file: filename, projectRoot: options.projectRoot },
170
+ : { cwd, file: discoveryFile, projectRoot: options.projectRoot },
80
171
  );
81
172
  const tsconfig = project.path;
82
173
  const root = project.root;
@@ -86,6 +177,9 @@ function createProjectContext(
86
177
  path.join(root, "node_modules", ".cache", "ttsc", "ttsx");
87
178
  const processDir = path.join(cacheDir, "project", PROCESS_CACHE_KEY);
88
179
  const virtualRoot = path.join(processDir, "fs");
180
+ // Resolved once: it now costs a realpath (and, for a missing directory on
181
+ // Windows, a case-sensitivity probe) rather than a string join.
182
+ const runtimeRootDir = resolveRuntimeSourceRoot(project, entry);
89
183
  return {
90
184
  project,
91
185
  tsconfig,
@@ -96,17 +190,17 @@ function createProjectContext(
96
190
  virtualRoot,
97
191
  emitDir: project.compilerOptions.outDir
98
192
  ? virtualPath(virtualRoot, project.compilerOptions.outDir)
99
- : virtualPath(virtualRoot, resolveRuntimeSourceRoot(project, filename)),
193
+ : virtualPath(virtualRoot, runtimeRootDir),
100
194
  // The source-tree root the emit mirrors (tsgo strips this prefix). Used to
101
195
  // map a source `.ts` back to its emitted `.js` when the runtime hooks serve
102
196
  // the built entry under its source URL.
103
- runtimeRootDir: resolveRuntimeSourceRoot(project, filename),
104
- // The tsconfig `module` option, so the runtime hooks classify each served
105
- // file's format the same way tsgo chose when emitting it.
106
- moduleOption:
107
- typeof project.compilerOptions.module === "string"
108
- ? project.compilerOptions.module
109
- : undefined,
197
+ runtimeRootDir,
198
+ // The tsconfig options that decide the emit format, so the runtime hooks
199
+ // classify each served file the same way tsgo chose when emitting it.
200
+ // `target` belongs here as much as `module` does: with `module` absent tsgo
201
+ // derives the module kind from `target`, so publishing only `module` makes
202
+ // the hooks guess.
203
+ moduleOptions: projectModuleOptions(project.compilerOptions),
110
204
  // Force a source map on the transient runtime emit only when the project
111
205
  // configures none — when it already emits `sourceMap` or `inlineSourceMap`,
112
206
  // the serve path inlines/absolutizes that map, so no override is needed
@@ -119,17 +213,37 @@ function createProjectContext(
119
213
  };
120
214
  }
121
215
 
216
+ /**
217
+ * The source-tree root the emit mirrors, in the same physical spelling as the
218
+ * entry it will be compared against.
219
+ *
220
+ * Resolving it is the other half of `resolveEntrySpelling`, and skipping it
221
+ * leaves the comparison mixed rather than merely imprecise. `project.root`
222
+ * arrives through plain `fs.realpathSync`, which resolves reparse points but
223
+ * leaves a Windows 8.3 component alone, while the entry arrives through
224
+ * `fs.realpathSync.native`, which expands it — and `path.relative` folds case
225
+ * but not 8.3. A declared `rootDir` is worse still: it is joined verbatim, so a
226
+ * `rootDir` that is itself a symlinked directory never resolves at all. Either
227
+ * way the gate reads an in-project entry as outside its own root, pays a second
228
+ * whole build for it, and publishes a wider root than the project has.
229
+ *
230
+ * `path.dirname(entry)` is already physical, so only the declared branch needs
231
+ * the pass.
232
+ */
122
233
  function resolveRuntimeSourceRoot(
123
234
  project: ReturnType<typeof readProjectConfig>,
124
- filename: string,
235
+ entry: string,
125
236
  ): string {
126
237
  const rootDir = project.compilerOptions.rootDir;
127
- if (typeof rootDir === "string") {
128
- return path.isAbsolute(rootDir)
129
- ? rootDir
130
- : path.resolve(project.root, rootDir);
238
+ if (typeof rootDir !== "string") {
239
+ return path.dirname(entry);
131
240
  }
132
- return path.dirname(filename);
241
+ const identities = createFilesystemPathIdentityContext({
242
+ throwOnRealpathError: false,
243
+ });
244
+ return identities.resolve(
245
+ path.isAbsolute(rootDir) ? rootDir : path.resolve(project.root, rootDir),
246
+ ).path;
133
247
  }
134
248
 
135
249
  function buildProject(
@@ -184,6 +298,163 @@ function buildProject(
184
298
  throw new Error(detail);
185
299
  }
186
300
 
301
+ /**
302
+ * Build an entry the owning project's file set does not contain.
303
+ *
304
+ * `ttsc` selects a _file set_: a project whose `include` is `src` must emit
305
+ * only `src` into `outDir`, and a `clear.ts`, a `build/release.ts`, or a
306
+ * `lint.config.ts` beside the tsconfig has no business in `lib`. `ttsx` selects
307
+ * an _entry_: it needs that same project's compiler options, not its file list.
308
+ * Those two requirements are not in conflict, but the whole-project build
309
+ * cannot satisfy the second one, so an entry it did not emit is compiled here
310
+ * through a project that inherits every option and declares only the entry.
311
+ *
312
+ * The synthesized tsconfig is written beside the real one on purpose. `extends`
313
+ * with an absolute path would resolve from anywhere, but `${configDir}` and
314
+ * `paths` are anchored to the directory of the config that consumes them, so
315
+ * any other location silently retargets them. It is removed as soon as the
316
+ * build returns.
317
+ *
318
+ * `rootDir` widens to the nearest directory holding both the project root and
319
+ * the entry — for the layout this exists for, the project root itself; for an
320
+ * entry that is a symlink out of the tree, the ancestor the two trees share. It
321
+ * has to widen at least that far, because the inherited `rootDir` (`src`) does
322
+ * not contain the entry and tsgo emits an input outside `rootDir` to its own
323
+ * source path.
324
+ *
325
+ * Widening costs precision, not safety. The manifest's `rootDir` bounds which
326
+ * files the runtime hooks will try to serve from this emit, so a wide one
327
+ * admits more sources to the lookup — but the lookup only ever answers with a
328
+ * file from this build's own emit directory, whether from `emittedFiles` or a
329
+ * scan of `outDir` (`resolveEmittedJavaScript`). What a wide root risks is the
330
+ * exact mirror missing and the trailing-stem matcher picking the wrong output
331
+ * _of this build_; it cannot reach a raw source on disk.
332
+ */
333
+ function buildEntryProject(
334
+ context: ReturnType<typeof createProjectContext>,
335
+ options: NonNullable<Parameters<typeof prepareExecution>[1]>,
336
+ entry: string,
337
+ ): void {
338
+ // `entry` already carries the one spelling `prepareExecution` decided on.
339
+ // tsgo compares it against `rootDir` textually — `GetCommonSourceDirectory`
340
+ // takes `rootDir` verbatim and `ContainsPath` is lexical — so a mismatch here
341
+ // is not a near miss: the entry counts as outside `rootDir`, and tsgo emits
342
+ // it to its own source path with the extension changed instead of under
343
+ // `outDir`, writing a `.js` and its map beside the user's `.ts` where nothing
344
+ // cleans them up.
345
+ const rootDir = commonAncestorDirectory(path.dirname(entry), context.root);
346
+ const tsconfig = path.join(
347
+ context.root,
348
+ `.ttsx-entry.${PROCESS_CACHE_KEY}.tsconfig.json`,
349
+ );
350
+ fs.writeFileSync(
351
+ tsconfig,
352
+ JSON.stringify(
353
+ {
354
+ extends: context.tsconfig.replace(/\\/g, "/"),
355
+ compilerOptions: { rootDir: rootDir.replace(/\\/g, "/") },
356
+ // `files` alone does not displace an inherited `include`, and an
357
+ // inherited `exclude` could drop the entry back out of the program, so
358
+ // both are overridden explicitly.
359
+ files: [entry.replace(/\\/g, "/")],
360
+ include: [],
361
+ exclude: [],
362
+ },
363
+ null,
364
+ 2,
365
+ ),
366
+ "utf8",
367
+ );
368
+ try {
369
+ const project = readProjectConfig({
370
+ cwd: context.root,
371
+ projectRoot: options.projectRoot,
372
+ tsconfig,
373
+ });
374
+ const emitDir = path.join(context.virtualRoot, ENTRY_PROJECT_EMIT_DIR);
375
+ fs.mkdirSync(emitDir, { recursive: true });
376
+ const result = runBuild({
377
+ binary: options.binary,
378
+ checkers: options.checkers,
379
+ cwd: context.root,
380
+ emit: true,
381
+ env: options.env,
382
+ forceListEmittedFiles: true,
383
+ cacheDir: context.pluginCacheDir,
384
+ outDir: emitDir,
385
+ passthrough: options.passthrough,
386
+ forceRuntimeSourceMap: context.forceRuntimeSourceMap,
387
+ pluginConfigDir: options.pluginConfigDir,
388
+ plugins: options.plugins,
389
+ quiet: true,
390
+ resolvedProject: project,
391
+ singleThreaded: options.singleThreaded,
392
+ tsconfig,
393
+ });
394
+ if (result.status !== 0) {
395
+ removeRuntimeOutput(context.processDir);
396
+ throw new Error(
397
+ [
398
+ `ttsx: entry check failed for ${entry}`,
399
+ result.stderr || result.stdout,
400
+ ]
401
+ .filter((line) => line.trim().length !== 0)
402
+ .join("\n"),
403
+ );
404
+ }
405
+ context.emitDir = emitDir;
406
+ context.runtimeRootDir = rootDir;
407
+ context.moduleOptions = projectModuleOptions(project.compilerOptions);
408
+ context.emittedFiles =
409
+ result.emittedFiles && result.emittedFiles.length !== 0
410
+ ? result.emittedFiles
411
+ : undefined;
412
+ } finally {
413
+ try {
414
+ fs.rmSync(tsconfig, { force: true });
415
+ } catch {
416
+ // Best effort: a leftover synthesized tsconfig must not mask a build
417
+ // failure, and it is PID-scoped so it can never be mistaken for a real
418
+ // project config.
419
+ }
420
+ }
421
+ }
422
+
423
+ /**
424
+ * The nearest directory containing both `left` and `right`, in the physical
425
+ * spelling both of them share.
426
+ *
427
+ * Containment is asked through the same filesystem-identity predicate the
428
+ * runtime hooks use, and the answer is resolved through it too. Its caller now
429
+ * passes an already-resolved directory, so the two spellings agree before the
430
+ * walk starts; the predicate stays because this returns a `rootDir` that tsgo
431
+ * takes verbatim, and answering in anything but the physical spelling would
432
+ * leave tsgo unable to place a sibling source under it.
433
+ *
434
+ * Falls back to the entry's directory when there genuinely is no shared
435
+ * ancestor, as on two different Windows volumes: the entry still has to
436
+ * compile, and a root that contains it is the closest thing to correct
437
+ * available.
438
+ */
439
+ function commonAncestorDirectory(left: string, right: string): string {
440
+ const identities = createFilesystemPathIdentityContext({
441
+ throwOnRealpathError: false,
442
+ });
443
+ const from = identities.resolve(path.resolve(left)).path;
444
+ const target = identities.resolve(path.resolve(right)).path;
445
+ let current = from;
446
+ for (;;) {
447
+ if (identities.isWithin(current, target)) {
448
+ return current;
449
+ }
450
+ const parent = path.dirname(current);
451
+ if (parent === current) {
452
+ return from;
453
+ }
454
+ current = parent;
455
+ }
456
+ }
457
+
187
458
  function removeRuntimeOutput(directory: string): void {
188
459
  try {
189
460
  fs.rmSync(directory, { recursive: true, force: true });
@@ -1,7 +1,11 @@
1
1
  import path from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
 
4
- import { entryModuleFormat, installRuntimeHooks } from "./runtimeHooks";
4
+ import {
5
+ entryModuleFormat,
6
+ installRuntimeHooks,
7
+ realPath,
8
+ } from "./runtimeHooks";
5
9
 
6
10
  /**
7
11
  * Bootstrap `ttsx` spawns as the child's main module: it installs the runtime
@@ -29,7 +33,12 @@ process.argv = [process.argv[0]!, entry, ...forwarded];
29
33
 
30
34
  installRuntimeHooks();
31
35
 
32
- const kind = entryModuleFormat(path.resolve(entry));
36
+ // The physical path, because the format question is a runtime question: the
37
+ // hooks serve the entry under its real path, and `moduleFormat` walks from the
38
+ // path it is handed to find the `package.json` that decides. Asking from a
39
+ // symlink's own directory reads the wrong package scope and hands an ESM emit
40
+ // to `require`.
41
+ const kind = entryModuleFormat(realPath(path.resolve(entry)));
33
42
 
34
43
  function fail(error: unknown): never {
35
44
  // Match Node's own uncaught-exception surfacing for a thrown entry error.