ttsc 0.19.2 → 0.20.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 (178) hide show
  1. package/README.md +7 -5
  2. package/cmd/graphdump/main.go +2 -4
  3. package/cmd/ttsc/filter.go +12 -4
  4. package/cmd/ttsc/filter_resolves_flag_names_case_insensitively_test.go +53 -0
  5. package/cmd/ttsc/flags_gen.go +3 -3
  6. package/cmd/ttscgraph/dump.go +5 -7
  7. package/cmd/ttscgraph/run_dump_emits_only_checker_verified_member_relations_test.go +118 -0
  8. package/cmd/ttscgraph/serve.go +58 -308
  9. package/cmd/ttscgraph/serve_session_keeps_base_url_fallback_unchanged_test.go +49 -0
  10. package/cmd/ttscgraph/serve_session_keeps_esm_directory_candidate_unchanged_test.go +61 -0
  11. package/cmd/ttscgraph/serve_session_keeps_exports_blocked_file_candidates_unchanged_test.go +64 -0
  12. package/cmd/ttscgraph/serve_session_keeps_inactive_conditional_export_unchanged_test.go +66 -0
  13. package/cmd/ttscgraph/serve_session_keeps_lower_priority_module_candidate_unchanged_test.go +67 -0
  14. package/cmd/ttscgraph/serve_session_keeps_lower_priority_paths_pattern_unchanged_test.go +58 -0
  15. package/cmd/ttscgraph/serve_session_keeps_nonlongest_root_dirs_candidate_unchanged_test.go +54 -0
  16. package/cmd/ttscgraph/serve_session_reloads_commonjs_package_entry_for_esm_test.go +50 -0
  17. package/cmd/ttscgraph/serve_session_reloads_module_suffix_candidate_test.go +41 -0
  18. package/cmd/ttscgraph/serve_session_reloads_superseding_module_candidates_test.go +216 -0
  19. package/cmd/ttscgraph/serve_session_retries_failed_dump_test.go +103 -0
  20. package/driver/graph.go +12 -6
  21. package/driver/lsp.go +30 -0
  22. package/driver/program.go +37 -1
  23. package/driver/resolution_candidates.go +819 -0
  24. package/driver/windowsjunction/windowsjunction.go +40 -0
  25. package/go.mod +2 -0
  26. package/internal/graph/build.go +45 -11
  27. package/internal/graph/dump.go +312 -108
  28. package/internal/graph/dump_checkout_paths_are_stable_test.go +210 -0
  29. package/internal/graph/dump_diagnostics_ride_the_generation_that_built_it_test.go +9 -3
  30. package/internal/graph/dump_paths.go +172 -0
  31. package/internal/graph/dump_paths_are_portable_and_injective_test.go +93 -0
  32. package/internal/graph/dump_schema_version_matches_the_typescript_client_test.go +1 -1
  33. package/internal/graph/dump_uses_ecma_line_terminators_test.go +87 -0
  34. package/internal/graph/duplicate_variable_object_members_keep_first_declaration_test.go +61 -0
  35. package/internal/graph/edges.go +220 -5
  36. package/internal/graph/enum_members_carry_their_names_on_the_enum_node_test.go +131 -0
  37. package/internal/graph/graph.go +177 -5
  38. package/internal/graph/line_model.go +122 -0
  39. package/internal/graph/literals.go +175 -0
  40. package/internal/graph/literals_are_absent_when_the_type_admits_more_than_it_can_name_test.go +74 -0
  41. package/internal/graph/literals_cover_enum_members_the_graph_has_no_nodes_for_test.go +79 -0
  42. package/internal/graph/literals_do_not_depend_on_declaration_line_wrapping_test.go +84 -0
  43. package/internal/graph/literals_render_every_value_in_typescript_source_form_test.go +77 -0
  44. package/internal/graph/literals_resolve_through_alias_indirection_test.go +64 -0
  45. package/internal/graph/member_relation_edges_follow_checker_validity_test.go +85 -0
  46. package/internal/graph/member_relation_edges_preserve_checker_boundaries_test.go +191 -0
  47. package/internal/graph/member_relation_edges_reject_checker_invalid_pairs_test.go +119 -0
  48. package/internal/graph/node_ids_preserve_hash_bearing_components_test.go +41 -0
  49. package/internal/graph/node_modifiers_emit_union_strings_test.go +4 -1
  50. package/internal/graph/object_literal_members_ride_on_the_snapshot_test.go +192 -0
  51. package/internal/graph/object_member_signatures_preserve_literal_whitespace_test.go +67 -0
  52. package/internal/graph/object_members.go +229 -0
  53. package/internal/graph/provenance.go +88 -30
  54. package/internal/graph/source_texts_cover_every_resident_program_source_test.go +73 -0
  55. package/internal/graphsymbols/position_helpers_follow_ecma_line_terminators_test.go +66 -0
  56. package/internal/graphsymbols/provider.go +26 -91
  57. package/internal/lspserver/lsp_clean_didopen_refreshes_compiler_state_test.go +144 -0
  58. package/internal/lspserver/lsp_completion.go +109 -0
  59. package/internal/lspserver/lsp_completion_gate_never_refuses_a_matching_line_test.go +50 -0
  60. package/internal/lspserver/lsp_completion_hints_longest_trigger_wins_test.go +149 -0
  61. package/internal/lspserver/lsp_completion_hints_nearest_trigger_wins_test.go +37 -0
  62. package/internal/lspserver/lsp_completion_merges_into_upstream_response_test.go +149 -0
  63. package/internal/lspserver/lsp_completion_nearest_hint_replaces_nearest_filter_test.go +43 -0
  64. package/internal/lspserver/lsp_completion_preserves_completion_list_extensions_test.go +99 -0
  65. package/internal/lspserver/lsp_completion_refuses_hints_inside_a_string_literal_test.go +69 -0
  66. package/internal/lspserver/lsp_completion_replaces_full_filter_in_rank_order_test.go +85 -0
  67. package/internal/lspserver/lsp_completion_request.go +455 -0
  68. package/internal/lspserver/lsp_completion_scope.go +530 -0
  69. package/internal/lspserver/lsp_completion_scope_bench_test.go +41 -0
  70. package/internal/lspserver/lsp_completion_scope_classifies_regex_statement_and_operator_boundaries_test.go +39 -0
  71. package/internal/lspserver/lsp_completion_scope_ignores_jsdoc_bytes_in_literals_test.go +108 -0
  72. package/internal/lspserver/lsp_completion_scope_refuses_regex_hints_test.go +52 -0
  73. package/internal/lspserver/lsp_completion_trigger_uses_last_rune_test.go +24 -0
  74. package/internal/lspserver/lsp_deleted_watched_file_withdraws_plugin_diagnostics_test.go +110 -0
  75. package/internal/lspserver/lsp_didsave_keeps_the_incremental_changed_uri_path_test.go +78 -0
  76. package/internal/lspserver/lsp_hints_absence_is_not_an_error_test.go +46 -0
  77. package/internal/lspserver/lsp_hints_refresh_coalesces_concurrent_requests_test.go +82 -0
  78. package/internal/lspserver/lsp_hints_refresh_ignores_a_superseded_generation_test.go +40 -0
  79. package/internal/lspserver/lsp_hints_refresh_keeps_each_producer_isolated_test.go +64 -0
  80. package/internal/lspserver/lsp_hints_refresh_replaces_the_session_snapshot_test.go +66 -0
  81. package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +90 -0
  82. package/internal/lspserver/lsp_hints_refresh_serves_completion_while_running_test.go +91 -0
  83. package/internal/lspserver/lsp_index_byte_from_bounds_its_search_start_test.go +38 -0
  84. package/internal/lspserver/lsp_initialize_constrains_position_encoding_to_utf16_test.go +205 -0
  85. package/internal/lspserver/lsp_late_completion_trigger_is_reported_once_test.go +105 -0
  86. package/internal/lspserver/lsp_native_completion_hints_decode_flat_lint_wire_test.go +66 -0
  87. package/internal/lspserver/lsp_native_completion_hints_grouped_compatibility_boundary_test.go +60 -0
  88. package/internal/lspserver/lsp_native_plugin_source.go +315 -2
  89. package/internal/lspserver/lsp_native_resident.go +304 -0
  90. package/internal/lspserver/lsp_plugin.go +85 -8
  91. package/internal/lspserver/lsp_plugin_completion_resolve_stays_local_test.go +82 -0
  92. package/internal/lspserver/lsp_position_conversions_span_astral_and_combining_runes_test.go +119 -0
  93. package/internal/lspserver/lsp_proxy.go +601 -9
  94. package/internal/lspserver/lsp_refresh.go +70 -0
  95. package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +43 -0
  96. package/internal/lspserver/lsp_resident_maps_verb_args_test.go +34 -0
  97. package/internal/lspserver/lsp_server.go +13 -1
  98. package/internal/lspserver/lsp_watched_file_changes_reach_compiler_caches_test.go +172 -0
  99. package/lib/TtscCompiler.d.ts +3 -1
  100. package/lib/TtscCompiler.js +5 -1
  101. package/lib/TtscCompiler.js.map +1 -1
  102. package/lib/TtscService.d.ts +13 -3
  103. package/lib/TtscService.js +6 -6
  104. package/lib/TtscService.js.map +1 -1
  105. package/lib/compiler/internal/project/readConfigJson.d.ts +30 -0
  106. package/lib/compiler/internal/project/readConfigJson.js +208 -0
  107. package/lib/compiler/internal/project/readConfigJson.js.map +1 -0
  108. package/lib/compiler/internal/project/readProjectConfig.js +53 -149
  109. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  110. package/lib/compiler/internal/residentTransformProcess.d.ts +15 -2
  111. package/lib/compiler/internal/residentTransformProcess.js +159 -39
  112. package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
  113. package/lib/compiler/internal/runBuild.d.ts +6 -0
  114. package/lib/compiler/internal/runBuild.js +143 -44
  115. package/lib/compiler/internal/runBuild.js.map +1 -1
  116. package/lib/compiler/internal/startResidentTransform.d.ts +5 -1
  117. package/lib/compiler/internal/startResidentTransform.js +3 -1
  118. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  119. package/lib/compiler/internal/transformProjectInMemory.js +12 -2
  120. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  121. package/lib/flags/parser.d.ts +19 -0
  122. package/lib/flags/parser.js +78 -36
  123. package/lib/flags/parser.js.map +1 -1
  124. package/lib/flags/schema.d.ts +49 -11
  125. package/lib/flags/schema.js +94 -22
  126. package/lib/flags/schema.js.map +1 -1
  127. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +5 -0
  128. package/lib/internal/assertSafeExplicitCacheDirectory.js +26 -0
  129. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +1 -0
  130. package/lib/launcher/internal/runTtsc.js +107 -69
  131. package/lib/launcher/internal/runTtsc.js.map +1 -1
  132. package/lib/launcher/internal/runTtsx.js +28 -41
  133. package/lib/launcher/internal/runTtsx.js.map +1 -1
  134. package/lib/launcher/internal/runtimeHooks.d.ts +15 -0
  135. package/lib/launcher/internal/runtimeHooks.js +18 -1
  136. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  137. package/lib/launcher/internal/watchTopology.d.ts +43 -0
  138. package/lib/launcher/internal/watchTopology.js +360 -0
  139. package/lib/launcher/internal/watchTopology.js.map +1 -0
  140. package/lib/plugin/internal/buildSourcePlugin.d.ts +14 -3
  141. package/lib/plugin/internal/buildSourcePlugin.js +247 -61
  142. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  143. package/lib/plugin/internal/loadProjectPlugins.js +63 -2
  144. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  145. package/lib/structures/ITtscCompilerTransformation.d.ts +16 -0
  146. package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +2 -0
  147. package/package.json +8 -8
  148. package/shim/ast/shim.go +48 -3
  149. package/shim/astnav/go.mod +14 -0
  150. package/shim/astnav/go.sum +20 -0
  151. package/shim/astnav/shim.go +15 -0
  152. package/shim/checker/shim.go +113 -0
  153. package/shim/core/shim.go +47 -0
  154. package/shim/diagnosticwriter/lint.go +50 -0
  155. package/src/TtscCompiler.ts +5 -1
  156. package/src/TtscService.ts +37 -10
  157. package/src/compiler/internal/project/readConfigJson.ts +209 -0
  158. package/src/compiler/internal/project/readProjectConfig.ts +62 -158
  159. package/src/compiler/internal/residentTransformProcess.ts +197 -39
  160. package/src/compiler/internal/runBuild.ts +169 -47
  161. package/src/compiler/internal/startResidentTransform.ts +12 -1
  162. package/src/compiler/internal/transformProjectInMemory.ts +12 -1
  163. package/src/flags/parser.ts +113 -42
  164. package/src/flags/schema.ts +113 -31
  165. package/src/internal/assertSafeExplicitCacheDirectory.ts +29 -0
  166. package/src/launcher/internal/runTtsc.ts +117 -68
  167. package/src/launcher/internal/runTtsx.ts +29 -44
  168. package/src/launcher/internal/runtimeHooks.ts +30 -2
  169. package/src/launcher/internal/watchTopology.ts +466 -0
  170. package/src/plugin/internal/buildSourcePlugin.ts +326 -64
  171. package/src/plugin/internal/loadProjectPlugins.ts +80 -2
  172. package/src/structures/ITtscCompilerTransformation.ts +17 -0
  173. package/src/structures/internal/ITtscParsedProjectConfig.ts +2 -0
  174. package/tools/shim_audit/baseline.json +0 -3
  175. package/tools/shim_audit/main.go +42 -14
  176. package/tools/shim_audit/shim_dir_coverage_test.go +25 -0
  177. package/utility/flags_gen.go +3 -3
  178. package/utility/host.go +9 -3
@@ -7,6 +7,15 @@ 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
+ /** Allow a cooperative host a short shutdown window before forcing it down. */
17
+ const TERMINATION_GRACE_MS = 1_000;
18
+
10
19
  /**
11
20
  * The operation a request expects a reply for. The host answers a transform
12
21
  * request (`{"file":...}`) with `{"typescript":...,"found":...}` and an update
@@ -23,12 +32,24 @@ export interface ResidentTransformProcessOptions {
23
32
  binary: string;
24
33
  cwd?: string;
25
34
  env?: NodeJS.ProcessEnv;
35
+ /** Maximum time one request may wait for the resident host's reply. */
36
+ requestTimeoutMs?: number;
37
+ }
38
+
39
+ /** Per-request lifecycle controls for a resident transform host. */
40
+ export interface ResidentTransformRequestOptions {
41
+ /** Abort this request. An in-flight abort retires the FIFO host. */
42
+ signal?: AbortSignal;
26
43
  }
27
44
 
28
45
  interface PendingRequest {
46
+ abort?: () => void;
29
47
  kind: ResidentReplyKind;
30
48
  reject: (reason: Error) => void;
31
49
  resolve: (reply: Record<string, unknown>) => void;
50
+ settled: boolean;
51
+ signal?: AbortSignal;
52
+ timer: NodeJS.Timeout;
32
53
  }
33
54
 
34
55
  /**
@@ -49,10 +70,12 @@ export class ResidentTransformProcess {
49
70
  private readonly child: ChildProcess;
50
71
  private readonly reader: Interface;
51
72
  private readonly pending: PendingRequest[] = [];
73
+ private readonly requestTimeoutMs: number;
52
74
  private stderr = "";
53
75
  private failure: Error | undefined;
54
76
 
55
77
  public constructor(options: ResidentTransformProcessOptions) {
78
+ this.requestTimeoutMs = normalizeRequestTimeoutMs(options.requestTimeoutMs);
56
79
  // Default stdio is "pipe" for stdin/stdout/stderr, which is exactly what the
57
80
  // line protocol needs; spelling it out as a string[] would not narrow to
58
81
  // StdioOptions, so it is left implicit.
@@ -102,25 +125,68 @@ export class ResidentTransformProcess {
102
125
  public request(
103
126
  payload: Record<string, unknown>,
104
127
  kind: ResidentReplyKind,
128
+ options: ResidentTransformRequestOptions = {},
105
129
  ): Promise<Record<string, unknown>> {
106
130
  if (this.failure !== undefined) {
107
131
  return Promise.reject(this.failure);
108
132
  }
133
+ if (options.signal?.aborted) {
134
+ return Promise.reject(cancelledError(options.signal));
135
+ }
109
136
  const stdin = this.child.stdin;
110
137
  if (stdin === null || stdin.destroyed) {
111
138
  return Promise.reject(
112
139
  new Error("ttsc: resident transform host stdin is closed"),
113
140
  );
114
141
  }
142
+ let line: string;
143
+ try {
144
+ line = `${JSON.stringify(payload)}\n`;
145
+ } catch (error) {
146
+ return Promise.reject(asError(error));
147
+ }
115
148
  return new Promise<Record<string, unknown>>((resolve, reject) => {
116
- const pending: PendingRequest = { kind, reject, resolve };
149
+ let pending!: PendingRequest;
150
+ pending = {
151
+ kind,
152
+ reject,
153
+ resolve,
154
+ settled: false,
155
+ signal: options.signal,
156
+ timer: setTimeout(() => this.timeout(pending), this.requestTimeoutMs),
157
+ };
158
+ pending.timer.unref();
159
+ if (options.signal !== undefined) {
160
+ pending.abort = () => this.cancel(pending, options.signal!);
161
+ options.signal.addEventListener("abort", pending.abort, { once: true });
162
+ }
117
163
  this.pending.push(pending);
118
- stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
119
- if (error) {
120
- this.removePending(pending);
121
- reject(error);
122
- }
123
- });
164
+ if (options.signal?.aborted) {
165
+ pending.abort!();
166
+ return;
167
+ }
168
+ try {
169
+ stdin.write(line, (error) => {
170
+ if (error === null || error === undefined || pending.settled) {
171
+ return;
172
+ }
173
+ this.settlePending(pending, error);
174
+ this.fail(
175
+ new Error(
176
+ `ttsc: resident transform host could not write a request: ${error.message}`,
177
+ ),
178
+ );
179
+ });
180
+ } catch (error) {
181
+ if (pending.settled) return;
182
+ const reason = asError(error);
183
+ this.settlePending(pending, reason);
184
+ this.fail(
185
+ new Error(
186
+ `ttsc: resident transform host could not write a request: ${reason.message}`,
187
+ ),
188
+ );
189
+ }
124
190
  });
125
191
  }
126
192
 
@@ -129,31 +195,23 @@ export class ResidentTransformProcess {
129
195
  * call more than once.
130
196
  */
131
197
  public dispose(): void {
132
- if (this.failure === undefined) {
133
- // If the host already died, reject with its real exit error (stderr +
134
- // exit code) rather than a bland "disposed" message.
135
- this.failure =
136
- this.child.exitCode !== null || this.child.signalCode !== null
137
- ? this.exitError()
138
- : new Error("ttsc: resident transform host disposed");
139
- }
140
- const stdin = this.child.stdin;
141
- if (stdin !== null && !stdin.destroyed) {
142
- stdin.end();
143
- }
144
- this.reader.close();
145
- this.rejectAll(this.failure);
146
- if (this.child.exitCode === null && this.child.signalCode === null) {
147
- this.child.kill();
148
- }
198
+ if (this.failure !== undefined) return;
199
+ // If the host already died, reject with its real exit error (stderr + exit
200
+ // code) rather than a bland "disposed" message.
201
+ this.fail(
202
+ this.child.exitCode !== null || this.child.signalCode !== null
203
+ ? this.exitError()
204
+ : new Error("ttsc: resident transform host disposed"),
205
+ );
149
206
  }
150
207
 
151
208
  private onLine(line: string): void {
209
+ if (this.failure !== undefined) return;
152
210
  const trimmed = line.trim();
153
211
  if (trimmed.length === 0) {
154
212
  return;
155
213
  }
156
- const request = this.pending.shift();
214
+ const request = this.pending[0];
157
215
  if (request === undefined) {
158
216
  // The host must emit exactly one reply per request, so an unmatched line
159
217
  // is a protocol violation that would desync every later reply into the
@@ -179,7 +237,7 @@ export class ResidentTransformProcess {
179
237
  trimmed,
180
238
  )}`,
181
239
  );
182
- request.reject(error);
240
+ this.settlePending(request, error);
183
241
  this.fail(error);
184
242
  return;
185
243
  }
@@ -189,7 +247,8 @@ export class ResidentTransformProcess {
189
247
  // line consumed exactly one slot — so only this request is corrupt; later
190
248
  // replies still pair correctly. Reject just this request instead of
191
249
  // failing the whole process.
192
- request.reject(
250
+ this.settlePending(
251
+ request,
193
252
  new Error(
194
253
  `ttsc: resident transform host sent an invalid ${request.kind} reply: ${echoLine(
195
254
  trimmed,
@@ -198,32 +257,93 @@ export class ResidentTransformProcess {
198
257
  );
199
258
  return;
200
259
  }
201
- request.resolve(reply);
260
+ this.settlePending(request, reply);
202
261
  }
203
262
 
204
263
  private onReaderClose(): void {
205
- if (this.pending.length === 0) {
206
- return;
207
- }
208
- this.rejectAll(this.failure ?? this.exitError());
264
+ if (this.failure === undefined) this.fail(this.exitError());
209
265
  }
210
266
 
211
267
  private fail(error: Error): void {
212
- this.failure ??= error;
213
- this.rejectAll(this.failure);
268
+ if (this.failure !== undefined) return;
269
+ this.failure = error;
270
+ this.rejectAll(error);
271
+ this.terminate();
214
272
  }
215
273
 
216
274
  private rejectAll(error: Error): void {
217
275
  while (this.pending.length !== 0) {
218
- this.pending.shift()!.reject(error);
276
+ this.settlePending(this.pending[0]!, error);
219
277
  }
220
278
  }
221
279
 
222
- private removePending(target: PendingRequest): void {
223
- const index = this.pending.indexOf(target);
224
- if (index !== -1) {
225
- this.pending.splice(index, 1);
280
+ private settlePending(
281
+ pending: PendingRequest,
282
+ result: Error | Record<string, unknown>,
283
+ ): void {
284
+ if (pending.settled) return;
285
+ pending.settled = true;
286
+ const index = this.pending.indexOf(pending);
287
+ if (index !== -1) this.pending.splice(index, 1);
288
+ clearTimeout(pending.timer);
289
+ if (pending.signal !== undefined && pending.abort !== undefined) {
290
+ pending.signal.removeEventListener("abort", pending.abort);
226
291
  }
292
+ if (result instanceof Error) pending.reject(result);
293
+ else pending.resolve(result);
294
+ }
295
+
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
+ private cancel(pending: PendingRequest, signal: AbortSignal): void {
314
+ if (pending.settled) return;
315
+ this.settlePending(pending, cancelledError(signal));
316
+ this.fail(
317
+ new Error(
318
+ `ttsc: resident transform host retired after another request was cancelled${abortDetail(
319
+ signal,
320
+ )}${stderrSuffix(this.stderr)}`,
321
+ ),
322
+ );
323
+ }
324
+
325
+ private terminate(): void {
326
+ const stdin = this.child.stdin;
327
+ if (stdin !== null && !stdin.destroyed) stdin.destroy();
328
+ if (this.child.exitCode !== null || this.child.signalCode !== null) return;
329
+ try {
330
+ this.child.kill();
331
+ } catch {
332
+ // The host exited between the liveness check and termination.
333
+ return;
334
+ }
335
+ const force = setTimeout(() => {
336
+ if (this.child.exitCode !== null || this.child.signalCode !== null) {
337
+ return;
338
+ }
339
+ try {
340
+ this.child.kill("SIGKILL");
341
+ } catch {
342
+ // The host exited between the liveness check and the forced signal.
343
+ }
344
+ }, TERMINATION_GRACE_MS);
345
+ force.unref();
346
+ this.child.once("exit", () => clearTimeout(force));
227
347
  }
228
348
 
229
349
  private exitError(): Error {
@@ -242,6 +362,44 @@ export class ResidentTransformProcess {
242
362
  }
243
363
  }
244
364
 
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
+ function cancelledError(signal: AbortSignal): Error {
377
+ const error = new Error(
378
+ `ttsc: resident transform request cancelled${abortDetail(signal)}`,
379
+ );
380
+ error.name = "AbortError";
381
+ return error;
382
+ }
383
+
384
+ function abortDetail(signal: AbortSignal): string {
385
+ const reason = signal.reason;
386
+ if (reason === undefined) return "";
387
+ try {
388
+ return `: ${reason instanceof Error ? reason.message : String(reason)}`;
389
+ } catch {
390
+ return "";
391
+ }
392
+ }
393
+
394
+ function stderrSuffix(stderr: string): string {
395
+ const detail = stderr.trim();
396
+ return detail.length === 0 ? "" : `: ${detail}`;
397
+ }
398
+
399
+ function asError(error: unknown): Error {
400
+ return error instanceof Error ? error : new Error(String(error));
401
+ }
402
+
245
403
  /**
246
404
  * Parse one reply line into a plain object, or `undefined` when the line is not
247
405
  * a JSON object. Invalid JSON, arrays, primitives, and `null` all yield
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
- import { INTERNAL_SHADOW_FLAGS, TERMINAL_FLAGS } from "../../flags/schema";
3
+ import { resolveFlagSpec } from "../../flags/schema";
4
4
  import {
5
5
  hasProjectPluginEntries,
6
6
  loadProjectPlugins,
@@ -26,6 +26,12 @@ import { outputText, spawnNative } from "./spawnNative";
26
26
  type RunBuildOptions = TtscBuildOptions & {
27
27
  skipDiagnosticsCheck?: boolean;
28
28
  forceListEmittedFiles?: boolean;
29
+ /**
30
+ * Receives selected native-plugin source roots after the project resolves.
31
+ * The watch launcher uses these roots to invalidate a sidecar when its Go
32
+ * implementation changes between rebuilds.
33
+ */
34
+ onWatchInputs?: (inputs: readonly string[]) => void;
29
35
  /**
30
36
  * Emit an external source map from the direct tsgo build lane even when the
31
37
  * project configures none. Set by the ttsx runtime builds so a served emit
@@ -80,15 +86,17 @@ function hasEnabledPassthroughFlag(
80
86
  const passthrough = options.passthrough ?? [];
81
87
  for (let i = 0; i < passthrough.length; i++) {
82
88
  const token = passthrough[i]!;
83
- if (token.startsWith(`${flag}=`)) {
84
- return token.slice(flag.length + 1).toLowerCase() !== "false";
89
+ // Identity, not spelling: the user forwards their own casing and ttsc must
90
+ // read `--DIAGNOSTICS` the way tsgo does.
91
+ if (resolveFlagSpec(token)?.name !== flag) continue;
92
+ const equalsIndex = token.indexOf("=");
93
+ if (equalsIndex !== -1) {
94
+ return token.slice(equalsIndex + 1).toLowerCase() !== "false";
85
95
  }
86
- if (token === flag) {
87
- if (i + 1 < passthrough.length && isBooleanLiteral(passthrough[i + 1]!)) {
88
- return passthrough[i + 1]!.toLowerCase() !== "false";
89
- }
90
- return true;
96
+ if (i + 1 < passthrough.length && isBooleanLiteral(passthrough[i + 1]!)) {
97
+ return passthrough[i + 1]!.toLowerCase() !== "false";
91
98
  }
99
+ return true;
92
100
  }
93
101
  return false;
94
102
  }
@@ -188,8 +196,16 @@ function runBuildTimed(
188
196
  options: RunBuildOptions,
189
197
  timing: BuildTiming,
190
198
  ): TtscBuildResult {
199
+ const projectFree = runProjectFreeTerminalFlag(options);
200
+ if (projectFree !== null) return projectFree;
191
201
  const setupStartedAt = process.hrtime.bigint();
192
202
  const execution = resolveExecutionContext(options);
203
+ options.onWatchInputs?.(
204
+ execution.nativePlugins.flatMap((plugin) => [
205
+ plugin.source,
206
+ ...(plugin.contributors?.map((contributor) => contributor.source) ?? []),
207
+ ]),
208
+ );
193
209
  if (
194
210
  execution.nativePlugins.length > 0 ||
195
211
  execution.pluginSetupFailure !== undefined
@@ -313,9 +329,14 @@ function runBuildTimed(
313
329
  }
314
330
 
315
331
  const args = createTsgoBuildArgs(execution, buildOptions, {
332
+ // `--verbose` promises the emitted-file list on every lane, and the list
333
+ // only exists if tsgo is asked for it. ttsc adds the flag for itself here
334
+ // exactly as it does for `forceListEmittedFiles`, and strips the raw
335
+ // `TSFILE:` lines back out below.
316
336
  listEmittedFiles:
317
337
  buildOptions.emit !== false &&
318
- buildOptions.forceListEmittedFiles === true,
338
+ (buildOptions.forceListEmittedFiles === true ||
339
+ buildOptions.quiet === false),
319
340
  noEmitOnError:
320
341
  buildOptions.emit !== false &&
321
342
  buildOptions.skipDiagnosticsCheck !== true &&
@@ -324,6 +345,62 @@ function runBuildTimed(
324
345
  return runTsgoBuild(execution, buildOptions, args);
325
346
  }
326
347
 
348
+ /**
349
+ * Answer a forwarded terminal flag whose meaning precedes a project, when no
350
+ * project can be resolved.
351
+ *
352
+ * `ttsc --init` exists to write the starter `tsconfig.json`, and `ttsc --all` /
353
+ * `ttsc -?` only print tsgo's help — none of them needs a project, yet all
354
+ * three died in project resolution because that layer ran first and
355
+ * unconditionally. The classification is `FLAG_SCHEMA`'s (`terminal` +
356
+ * `projectFree`), so marking a further flag project-free needs no edit here.
357
+ *
358
+ * A resolvable project keeps the established lane untouched: the build path
359
+ * still forwards the flag with `-p <tsconfig>` from the project root, so `ttsc
360
+ * --init` inside an existing project still reports tsgo's TS5054 instead of
361
+ * writing a second config into the current directory. Returns `null` when this
362
+ * lane does not apply.
363
+ */
364
+ function runProjectFreeTerminalFlag(
365
+ options: RunBuildOptions,
366
+ ): TtscBuildResult | null {
367
+ if (!forwardsProjectFreeTerminalTsgoFlag(options)) return null;
368
+ if (options.resolvedProject !== undefined) return null;
369
+ const cwd = path.resolve(options.cwd ?? process.cwd());
370
+ try {
371
+ readProjectConfig({
372
+ cwd,
373
+ projectRoot: options.projectRoot,
374
+ tsconfig: options.tsconfig,
375
+ });
376
+ return null;
377
+ } catch {
378
+ // Any resolution failure takes this branch, not only "not found": a
379
+ // malformed config and an explicitly named missing `-p` path are equally
380
+ // beside the point for a flag whose meaning does not presuppose a project.
381
+ // Forward it to tsgo from the invocation directory instead of failing.
382
+ }
383
+ const tsgo = resolveTsgo({ ...options, cwd });
384
+ const res = spawnNative(tsgo.binary, [...(options.passthrough ?? [])], {
385
+ cwd,
386
+ env: mergeEnv(options.env),
387
+ encoding: "utf8",
388
+ });
389
+ if (res.error) {
390
+ throw new Error(
391
+ `ttsc: failed to spawn ${tsgo.binary}: ${res.error.message}`,
392
+ );
393
+ }
394
+ return normalizeBuildOutput(
395
+ {
396
+ status: res.status ?? 1,
397
+ stdout: outputText(res.stdout),
398
+ stderr: outputText(res.stderr),
399
+ },
400
+ cwd,
401
+ );
402
+ }
403
+
327
404
  /**
328
405
  * A tsconfig-level `noEmit: true` is an analysis-only build unless the user
329
406
  * explicitly asks `ttsc --emit` to override it. Treat it like CLI `--noEmit`
@@ -412,11 +489,17 @@ function createPluginFailureTypecheckOptions(
412
489
  const passthrough: string[] = [];
413
490
  for (let i = 0; i < (options.passthrough?.length ?? 0); i++) {
414
491
  const token = options.passthrough![i]!;
415
- if (token === "--pretty") {
416
- if (isBooleanLiteral(options.passthrough![i + 1] ?? "")) i++;
492
+ if (resolveFlagSpec(token)?.name === "--pretty") {
493
+ // `--pretty` is boolean: it owns a following token only when that token
494
+ // is the literal `true`/`false`, and the inline form carries its own.
495
+ if (
496
+ !token.includes("=") &&
497
+ isBooleanLiteral(options.passthrough![i + 1] ?? "")
498
+ ) {
499
+ i++;
500
+ }
417
501
  continue;
418
502
  }
419
- if (token.startsWith("--pretty=")) continue;
420
503
  passthrough.push(token);
421
504
  }
422
505
  return {
@@ -509,31 +592,42 @@ function diagnosticHeadline(message: string): string {
509
592
  }
510
593
 
511
594
  /**
512
- * Tsgo CLI flags that make `tsgo` print something and exit instead of building
513
- * (`--showConfig`, `--listFilesOnly`, `--help`, …). ttsc must not add
514
- * build-only guard flags around these because the forwarded flag asks tsgo to
515
- * print something and exit instead of compiling the project.
595
+ * Report whether the caller forwarded a print-and-exit tsgo flag
596
+ * (`--showConfig`, `--listFilesOnly`, `--all`, `--init`, `-?`), so ttsc can
597
+ * avoid adding compile-only flags to a command that is not going to compile.
516
598
  *
517
- * Schema-derived: the set is computed from `FLAG_SCHEMA[*].terminal === true`,
518
- * not hand-maintained next to runBuild. Adding a new terminal flag now means
519
- * editing `schema.ts` and re-running `pnpm format` — every layer learns about
520
- * it automatically.
599
+ * Schema-derived, and resolved by flag identity rather than by exact spelling:
600
+ * `resolveFlagSpec` applies the one normalization the parsing engine and the
601
+ * generated Go allow-lists use, so `--showconfig` classifies exactly like
602
+ * `--showConfig`. Adding a new terminal flag means editing `schema.ts` and
603
+ * re-running `pnpm run gen:flags`; this predicate needs no edit, and it grows
604
+ * no normalization of its own for the next consumer to forget.
521
605
  */
522
- const TERMINAL_TSGO_FLAGS: ReadonlySet<string> = (() => {
523
- // Mirror the legacy hand-list: schema's terminal flags ∪ the `-?` alias
524
- // tsgo accepts (kept here because it is a tsgo synonym, not a ttsc flag).
525
- const out = new Set<string>(TERMINAL_FLAGS);
526
- out.add("-?");
527
- return out;
528
- })();
606
+ function forwardsTerminalTsgoFlag(options: TtscCommonOptions): boolean {
607
+ return (
608
+ options.passthrough?.some(
609
+ (token) => resolveFlagSpec(token)?.terminal === true,
610
+ ) ?? false
611
+ );
612
+ }
529
613
 
530
614
  /**
531
- * Report whether the caller forwarded a print-and-exit tsgo flag, so ttsc can
532
- * avoid adding compile-only flags to a command that is not going to compile.
615
+ * Report whether the caller forwarded a terminal flag whose meaning does not
616
+ * presuppose a resolved project (`--init`, `--all`, `-?`).
617
+ *
618
+ * Derived from `FLAG_SCHEMA[*].projectFree`, through the same identity
619
+ * resolution as every other classification — never a literal list of flag names
620
+ * beside this branch, which is the shape that let terminal-flag awareness exist
621
+ * in one layer and be missing from the layer above it.
533
622
  */
534
- function forwardsTerminalTsgoFlag(options: TtscCommonOptions): boolean {
623
+ function forwardsProjectFreeTerminalTsgoFlag(
624
+ options: TtscCommonOptions,
625
+ ): boolean {
535
626
  return (
536
- options.passthrough?.some((flag) => TERMINAL_TSGO_FLAGS.has(flag)) ?? false
627
+ options.passthrough?.some((token) => {
628
+ const flag = resolveFlagSpec(token);
629
+ return flag?.terminal === true && flag.projectFree === true;
630
+ }) ?? false
537
631
  );
538
632
  }
539
633
 
@@ -553,18 +647,16 @@ function forwardsInternalShadowFlag(
553
647
  options: TtscCommonOptions,
554
648
  flag: string,
555
649
  ): boolean {
556
- if (!INTERNAL_SHADOW_FLAGS.has(flag)) return false;
557
650
  const passthrough = options.passthrough;
558
651
  if (passthrough === undefined) return false;
559
- // Match both the bare form (`--pretty`) and the inline-value form
560
- // (`--pretty=true`). The launcher's parser preserves these shapes
561
- // verbatim, so a tolerant prefix compare is what we want — `passthrough`
562
- // never holds a stray substring that happens to start with the flag
563
- // because parser positional/passthrough sinks split on whitespace.
564
- const inlinePrefix = `${flag}=`;
565
- return passthrough.some(
566
- (token) => token === flag || token.startsWith(inlinePrefix),
567
- );
652
+ // Resolution covers the bare form (`--pretty`), the inline-value form
653
+ // (`--pretty=true`), and every casing tsgo accepts (`--PRETTY`) — the
654
+ // launcher forwards the user's own spelling verbatim, so comparing raw
655
+ // strings would miss a spelling tsgo honours.
656
+ return passthrough.some((token) => {
657
+ const spec = resolveFlagSpec(token);
658
+ return spec?.internalShadow === true && spec.name === flag;
659
+ });
568
660
  }
569
661
 
570
662
  /**
@@ -676,12 +768,46 @@ function runTsgoBuild(
676
768
  if (emittedFiles.length !== 0 && !userListedEmitted) {
677
769
  result.stdout = stripEmittedFileLines(result.stdout);
678
770
  }
771
+ if (options.quiet === false) {
772
+ result.stdout += verboseBuildSummary(execution, options, emittedFiles);
773
+ }
679
774
  return normalizeBuildOutput(
680
775
  { ...result, emittedFiles },
681
776
  execution.projectRoot,
682
777
  );
683
778
  }
684
779
 
780
+ /**
781
+ * The `--verbose` summary for the direct-tsgo lane, in the shape
782
+ * `cmd/ttsc/build.go` already prints on the native-host lane.
783
+ *
784
+ * Verbosity is a launcher-owned presentation concern: which lane `runBuild`
785
+ * selects is an implementation detail the user cannot see, so a documented flag
786
+ * must not change meaning with it. This lane never consumed `quiet` at all,
787
+ * which is why the flag was silent on every project without a ttsc plugin.
788
+ *
789
+ * `sites=0` is a fact about this lane rather than a placeholder: it runs
790
+ * precisely when the project declares no native plugin. A build that emitted
791
+ * nothing prints the header and `emitted=0 files`, so the summary never claims
792
+ * files that were not written.
793
+ */
794
+ function verboseBuildSummary(
795
+ execution: ReturnType<typeof resolveExecutionContext>,
796
+ options: RunBuildOptions,
797
+ emittedFiles: readonly string[],
798
+ ): string {
799
+ const lines = [
800
+ `// ttsc: tsconfig=${execution.tsconfig} cwd=${execution.cwd} sites=0 emit=${options.emit !== false}`,
801
+ ];
802
+ if (options.emit !== false) {
803
+ lines.push(`// ttsc: emitted=${emittedFiles.length} files`);
804
+ for (const file of emittedFiles) {
805
+ lines.push(` + ${path.relative(execution.cwd, file) || file}`);
806
+ }
807
+ }
808
+ return `${lines.join("\n")}\n`;
809
+ }
810
+
685
811
  /** Build the argument list for a direct `tsgo` build invocation. */
686
812
  function createTsgoBuildArgs(
687
813
  execution: ReturnType<typeof resolveExecutionContext>,
@@ -953,12 +1079,8 @@ function nativeTsgoPassthroughArgs(
953
1079
  }
954
1080
 
955
1081
  function isDiagnosticsPassthroughFlag(token: string): boolean {
956
- return (
957
- token === "--diagnostics" ||
958
- token.startsWith("--diagnostics=") ||
959
- token === "--extendedDiagnostics" ||
960
- token.startsWith("--extendedDiagnostics=")
961
- );
1082
+ const name = resolveFlagSpec(token)?.name;
1083
+ return name === "--diagnostics" || name === "--extendedDiagnostics";
962
1084
  }
963
1085
 
964
1086
  function isBooleanLiteral(token: string): boolean {