ttsc 0.21.0 → 0.22.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 (95) hide show
  1. package/cmd/ttscserver/main.go +79 -3
  2. package/cmd/ttscserver/run_lsp_uses_native_plugin_manifest_source_test.go +96 -16
  3. package/driver/lsp.go +4 -0
  4. package/internal/lspserver/lsp_document_diagnostics_omits_cached_project_when_current_result_has_none_test.go +48 -0
  5. package/internal/lspserver/lsp_executable_project_input_change_requests_launcher_restart_test.go +98 -0
  6. package/internal/lspserver/lsp_external_watched_change_retains_program_test.go +53 -0
  7. package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +9 -0
  8. package/internal/lspserver/lsp_native_plugin_source.go +171 -55
  9. package/internal/lspserver/lsp_native_resident.go +110 -9
  10. package/internal/lspserver/lsp_plugin.go +6 -0
  11. package/internal/lspserver/lsp_project_context_transport_identity_test.go +43 -0
  12. package/internal/lspserver/lsp_project_diagnostics.go +235 -0
  13. package/internal/lspserver/lsp_project_diagnostics_capability_is_independent_test.go +67 -0
  14. package/internal/lspserver/lsp_project_diagnostics_program_input_overlap_refreshes_all_producers_test.go +78 -0
  15. package/internal/lspserver/lsp_project_diagnostics_refresh_accumulates_owner_freshness_test.go +65 -0
  16. package/internal/lspserver/lsp_project_diagnostics_refresh_keeps_each_producer_last_good_test.go +90 -0
  17. package/internal/lspserver/lsp_project_diagnostics_refreshes_only_input_owners_test.go +62 -0
  18. package/internal/lspserver/lsp_project_diagnostics_resident_unsupported_falls_back_test.go +123 -0
  19. package/internal/lspserver/lsp_project_diagnostics_resume_preserves_generation_test.go +52 -0
  20. package/internal/lspserver/lsp_project_diagnostics_translates_relative_client_project_test.go +90 -0
  21. package/internal/lspserver/lsp_project_input_ownership_resolves_declared_spellings_test.go +95 -0
  22. package/internal/lspserver/lsp_project_input_registration.go +504 -0
  23. package/internal/lspserver/lsp_project_input_registration_rechecks_selection_baseline_test.go +71 -0
  24. package/internal/lspserver/lsp_project_input_registration_reconciles_rejected_pending_request_test.go +268 -0
  25. package/internal/lspserver/lsp_project_input_registration_shapes_patterns_test.go +94 -0
  26. package/internal/lspserver/lsp_project_input_watchers_anchor_missing_paths_test.go +48 -0
  27. package/internal/lspserver/lsp_project_inputs.go +944 -0
  28. package/internal/lspserver/lsp_project_inputs_match_missing_exact_and_glob_test.go +129 -0
  29. package/internal/lspserver/lsp_project_inputs_reject_invalid_and_foreign_snapshots_test.go +110 -0
  30. package/internal/lspserver/lsp_proxy.go +591 -28
  31. package/internal/lspserver/lsp_reload_directories_compare_immediate_topology_test.go +188 -0
  32. package/internal/lspserver/lsp_reload_file_fingerprint_preserves_raw_symlink_target_test.go +47 -0
  33. package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +1 -1
  34. package/internal/lspserver/lsp_resident_invalidation_broadcasts_program_input_overlap_test.go +60 -0
  35. package/internal/lspserver/lsp_resident_invalidation_targets_project_input_owners_test.go +51 -0
  36. package/internal/lspserver/lsp_same_transport_entries_are_deduplicated_test.go +66 -0
  37. package/lib/compiler/internal/project/readProjectConfig.d.ts +5 -3
  38. package/lib/compiler/internal/project/readProjectConfig.js +46 -14
  39. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  40. package/lib/compiler/internal/residentCheckProcess.d.ts +46 -0
  41. package/lib/compiler/internal/residentCheckProcess.js +227 -0
  42. package/lib/compiler/internal/residentCheckProcess.js.map +1 -0
  43. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -1
  44. package/lib/compiler/internal/resolveEmittedJavaScript.js +35 -18
  45. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  46. package/lib/compiler/internal/runBuild.d.ts +64 -1
  47. package/lib/compiler/internal/runBuild.js +484 -7
  48. package/lib/compiler/internal/runBuild.js.map +1 -1
  49. package/lib/compiler/internal/runSingleFileEmit.js +1 -0
  50. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  51. package/lib/flags/parser.js +13 -17
  52. package/lib/flags/parser.js.map +1 -1
  53. package/lib/flags/schema.d.ts +10 -3
  54. package/lib/flags/schema.js +99 -0
  55. package/lib/flags/schema.js.map +1 -1
  56. package/lib/internal/projectInputPathIdentity.d.ts +23 -0
  57. package/lib/internal/projectInputPathIdentity.js +222 -0
  58. package/lib/internal/projectInputPathIdentity.js.map +1 -0
  59. package/lib/launcher/internal/runTtsc.d.ts +17 -0
  60. package/lib/launcher/internal/runTtsc.js +121 -88
  61. package/lib/launcher/internal/runTtsc.js.map +1 -1
  62. package/lib/launcher/internal/runTtscserver.d.ts +13 -1
  63. package/lib/launcher/internal/runTtscserver.js +415 -45
  64. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  65. package/lib/launcher/internal/singleFileOutput.d.ts +16 -0
  66. package/lib/launcher/internal/singleFileOutput.js +103 -0
  67. package/lib/launcher/internal/singleFileOutput.js.map +1 -0
  68. package/lib/launcher/internal/watchTopology.d.ts +214 -2
  69. package/lib/launcher/internal/watchTopology.js +1560 -41
  70. package/lib/launcher/internal/watchTopology.js.map +1 -1
  71. package/lib/plugin/internal/loadProjectPlugins.d.ts +1 -0
  72. package/lib/plugin/internal/loadProjectPlugins.js +5 -0
  73. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  74. package/lib/structures/ITtscPluginCapabilities.d.ts +31 -0
  75. package/lib/structures/internal/ITtscProjectInputSnapshot.d.ts +29 -0
  76. package/lib/structures/internal/ITtscProjectInputSnapshot.js +3 -0
  77. package/lib/structures/internal/ITtscProjectInputSnapshot.js.map +1 -0
  78. package/lib/structures/internal/TtscSingleFileEmitOptions.d.ts +5 -0
  79. package/package.json +8 -8
  80. package/src/compiler/internal/project/readProjectConfig.ts +62 -8
  81. package/src/compiler/internal/residentCheckProcess.ts +279 -0
  82. package/src/compiler/internal/resolveEmittedJavaScript.ts +43 -21
  83. package/src/compiler/internal/runBuild.ts +728 -12
  84. package/src/compiler/internal/runSingleFileEmit.ts +1 -0
  85. package/src/flags/parser.ts +17 -17
  86. package/src/flags/schema.ts +111 -3
  87. package/src/internal/projectInputPathIdentity.ts +273 -0
  88. package/src/launcher/internal/runTtsc.ts +141 -117
  89. package/src/launcher/internal/runTtscserver.ts +561 -54
  90. package/src/launcher/internal/singleFileOutput.ts +133 -0
  91. package/src/launcher/internal/watchTopology.ts +2008 -59
  92. package/src/plugin/internal/loadProjectPlugins.ts +8 -0
  93. package/src/structures/ITtscPluginCapabilities.ts +34 -0
  94. package/src/structures/internal/ITtscProjectInputSnapshot.ts +29 -0
  95. package/src/structures/internal/TtscSingleFileEmitOptions.ts +5 -0
@@ -1,6 +1,11 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
 
3
4
  import { resolveFlagSpec } from "../../flags/schema";
5
+ import {
6
+ type ProjectInputPathIdentityContext,
7
+ createProjectInputPathIdentityContext,
8
+ } from "../../internal/projectInputPathIdentity";
4
9
  import {
5
10
  hasProjectPluginEntries,
6
11
  loadProjectPlugins,
@@ -8,11 +13,16 @@ import {
8
13
  import type { ITtscCompilerDiagnostic } from "../../structures/ITtscCompilerDiagnostic";
9
14
  import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoadedNativePlugin";
10
15
  import type { ITtscParsedProjectConfig } from "../../structures/internal/ITtscParsedProjectConfig";
16
+ import type { ITtscProjectInputSnapshot } from "../../structures/internal/ITtscProjectInputSnapshot";
11
17
  import type { TtscBuildOptions } from "../../structures/internal/TtscBuildOptions";
12
18
  import type { TtscBuildResult } from "../../structures/internal/TtscBuildResult";
13
19
  import type { TtscCommonOptions } from "../../structures/internal/TtscCommonOptions";
14
20
  import { createNativeProjectContextArgs } from "./project/createNativeProjectContextArgs";
15
21
  import { readProjectConfig } from "./project/readProjectConfig";
22
+ import {
23
+ ResidentCheckProcess,
24
+ type ResidentCheckRequest,
25
+ } from "./residentCheckProcess";
16
26
  import { resolveBinary } from "./resolveBinary";
17
27
  import { resolveTsgo } from "./resolveTsgo";
18
28
  import {
@@ -23,15 +33,23 @@ import {
23
33
  } from "./sharedHostHelpers";
24
34
  import { outputText, spawnNative } from "./spawnNative";
25
35
 
26
- type RunBuildOptions = TtscBuildOptions & {
36
+ export type RunBuildOptions = TtscBuildOptions & {
27
37
  skipDiagnosticsCheck?: boolean;
28
38
  forceListEmittedFiles?: boolean;
39
+ /** Keep every compiler-owned side product inside this private directory. */
40
+ isolateOutputsTo?: string;
29
41
  /**
30
42
  * Receives selected native-plugin source roots after the project resolves.
31
43
  * The watch launcher uses these roots to invalidate a sidecar when its Go
32
44
  * implementation changes between rebuilds.
33
45
  */
34
46
  onWatchInputs?: (inputs: readonly string[]) => void;
47
+ /**
48
+ * Receives the reconciled project-rule filesystem dependency snapshot. Called
49
+ * only by watch launchers; ordinary builds do not probe the optional sidecar
50
+ * command.
51
+ */
52
+ onProjectInputs?: (inputs: ITtscProjectInputSnapshot) => void;
35
53
  /**
36
54
  * Emit an external source map from the direct tsgo build lane even when the
37
55
  * project configures none. Set by the ttsx runtime builds so a served emit
@@ -192,6 +210,373 @@ export function runBuild(options: RunBuildOptions = {}): TtscBuildResult {
192
210
  return appendTimingOutput(result, timing);
193
211
  }
194
212
 
213
+ export type ResidentCheckWatchChange = {
214
+ /** Re-resolve project, plugin, contributor, and Program topology. */
215
+ reload?: boolean;
216
+ /** Retain the sidecar and execution selection but cold-load its Program. */
217
+ invalidate?: boolean;
218
+ /** Local compiler or data paths changed since the prior cycle. */
219
+ changed?: readonly string[];
220
+ /** Subset of changed paths declared by ProjectRules as external inputs. */
221
+ external?: readonly string[];
222
+ };
223
+
224
+ /**
225
+ * Analysis-only watch coordinator.
226
+ *
227
+ * The selected project and compatible check-stage processes stay resident
228
+ * across ordinary source/data edits. A config, root-set, contributor, or plugin
229
+ * topology transition calls for a full reset before the next cycle. Emit and
230
+ * transform lanes can pass through the coordinator, but compatibility checks
231
+ * keep them on the established one-shot path without starting sidecars.
232
+ */
233
+ export class ResidentCheckWatchSession {
234
+ private execution: ReturnType<typeof resolveExecutionContext> | undefined;
235
+ private readonly pendingChanges = new Map<number, ResidentCheckRequest>();
236
+ private projectInputs: ITtscProjectInputSnapshot | undefined;
237
+ private readonly processes = new Map<string, ResidentCheckProcess>();
238
+
239
+ public async run(
240
+ options: RunBuildOptions,
241
+ change: ResidentCheckWatchChange = {},
242
+ ): Promise<TtscBuildResult> {
243
+ if (change.reload === true) this.reset();
244
+
245
+ const timing = createBuildTiming(options);
246
+ const projectFree = runProjectFreeTerminalFlag(options);
247
+ if (projectFree !== null) {
248
+ this.reset();
249
+ return appendTimingOutput(projectFree, timing);
250
+ }
251
+
252
+ let execution = this.execution;
253
+ const reusedExecution = execution !== undefined;
254
+ let buildOptions: RunBuildOptions;
255
+ if (execution === undefined) {
256
+ const setupStartedAt = process.hrtime.bigint();
257
+ execution = resolveExecutionContext(options);
258
+ const discoveryOptions = this.captureProjectInputs(options);
259
+ const prepared = prepareBuildExecution(
260
+ discoveryOptions,
261
+ timing,
262
+ execution,
263
+ setupStartedAt,
264
+ );
265
+ buildOptions = prepared.buildOptions;
266
+ if (prepared.result !== undefined) {
267
+ this.reset();
268
+ return appendTimingOutput(prepared.result, timing);
269
+ }
270
+ if (!residentCheckExecutionIsCompatible(buildOptions, execution)) {
271
+ this.reset();
272
+ return appendTimingOutput(
273
+ runPreparedBuild(options, timing, execution, buildOptions),
274
+ timing,
275
+ );
276
+ }
277
+ this.execution = execution;
278
+ } else {
279
+ buildOptions = applyProjectNoEmit(options, execution);
280
+ }
281
+ if (
282
+ reusedExecution &&
283
+ this.refreshProjectInputTopology(options, execution)
284
+ ) {
285
+ this.reset();
286
+ return this.run(options);
287
+ }
288
+
289
+ const checked = await this.runCheckPlugins(
290
+ buildOptions,
291
+ execution,
292
+ timing,
293
+ change,
294
+ );
295
+ let result: TtscBuildResult;
296
+ if (checked.status !== 0) {
297
+ result = appendTypeScriptDiagnosticsAfterPluginFailure(
298
+ checked,
299
+ buildOptions,
300
+ execution,
301
+ );
302
+ } else if (
303
+ checkPluginsReportTypeScriptDiagnostics(execution.nativePlugins)
304
+ ) {
305
+ result = checked;
306
+ } else {
307
+ result = appendBuildOutput(
308
+ checked,
309
+ runTsgo(execution, ["--noEmit"], buildOptions),
310
+ );
311
+ }
312
+ return appendTimingOutput(result, timing);
313
+ }
314
+
315
+ /** Terminate every sidecar and discard the cached selection context. */
316
+ public dispose(): void {
317
+ this.reset();
318
+ }
319
+
320
+ private reset(): void {
321
+ for (const process of this.processes.values()) process.dispose();
322
+ this.processes.clear();
323
+ this.pendingChanges.clear();
324
+ this.execution = undefined;
325
+ this.projectInputs = undefined;
326
+ }
327
+
328
+ private captureProjectInputs(options: RunBuildOptions): RunBuildOptions {
329
+ const onProjectInputs = options.onProjectInputs;
330
+ if (onProjectInputs === undefined) return options;
331
+ return {
332
+ ...options,
333
+ onProjectInputs: (snapshot) => {
334
+ this.projectInputs = snapshot;
335
+ onProjectInputs(snapshot);
336
+ },
337
+ };
338
+ }
339
+
340
+ private refreshProjectInputTopology(
341
+ options: RunBuildOptions,
342
+ execution: ReturnType<typeof resolveExecutionContext>,
343
+ ): boolean {
344
+ if (options.onProjectInputs === undefined) return false;
345
+ const next = discoverNativeProjectInputs(options, execution);
346
+ const changed =
347
+ this.projectInputs !== undefined &&
348
+ !projectInputSnapshotsEqual(this.projectInputs, next);
349
+ this.projectInputs = next;
350
+ options.onProjectInputs(next);
351
+ return changed;
352
+ }
353
+
354
+ private async runCheckPlugins(
355
+ options: RunBuildOptions,
356
+ execution: ReturnType<typeof resolveExecutionContext>,
357
+ timing: BuildTiming,
358
+ change: ResidentCheckWatchChange,
359
+ ): Promise<TtscBuildResult> {
360
+ let out: TtscBuildResult = {
361
+ diagnostics: [],
362
+ status: 0,
363
+ stderr: "",
364
+ stdout: "",
365
+ };
366
+ const checks = planResidentCheckEntries(execution.nativePlugins, (plugin) =>
367
+ createNativeCheckArgs(execution, options, plugin),
368
+ );
369
+ const request = residentCheckRequest(change, execution.projectRoot);
370
+ // Buffer the cycle for every resident plugin before running any of them.
371
+ // An earlier plugin may fail and short-circuit diagnostics, but a later
372
+ // sidecar must still receive every filesystem transition when it resumes.
373
+ bufferResidentCheckEntryRequests(this.pendingChanges, checks, request);
374
+
375
+ for (const { args, entryIndex, key, plugin } of checks) {
376
+ let result: TtscBuildResult;
377
+ if (key === undefined) {
378
+ result = runNativePluginCommand(
379
+ plugin,
380
+ args,
381
+ options,
382
+ execution,
383
+ "ttsc.check",
384
+ timing,
385
+ `ttsc check plugin ${plugin.name} time`,
386
+ );
387
+ } else {
388
+ const startedAt = process.hrtime.bigint();
389
+ let resident = this.processes.get(key);
390
+ if (resident === undefined) {
391
+ resident = new ResidentCheckProcess({
392
+ args: ["check-serve", ...args.slice(1)],
393
+ binary: plugin.binary,
394
+ cwd: execution.projectRoot,
395
+ env: nativePluginEnv(options.env, execution, plugin),
396
+ });
397
+ this.processes.set(key, resident);
398
+ }
399
+ try {
400
+ const reply = await resident.request(
401
+ takeResidentCheckEntryRequest(this.pendingChanges, entryIndex),
402
+ );
403
+ result = normalizeBuildOutput(
404
+ {
405
+ status: reply.status,
406
+ stderr: reply.stderr,
407
+ stdout: reply.stdout,
408
+ },
409
+ execution.projectRoot,
410
+ );
411
+ } catch {
412
+ resident.dispose();
413
+ this.processes.delete(key);
414
+ // The one-shot fallback observes the complete current filesystem,
415
+ // and a later sidecar starts cold, so neither needs old deltas.
416
+ // A capability-aware host may still disappear or violate framing.
417
+ // Preserve correctness by running the established one-shot command
418
+ // for this cycle; the next cycle gets one clean respawn attempt.
419
+ result = runNativePluginCommand(
420
+ plugin,
421
+ args,
422
+ options,
423
+ execution,
424
+ "ttsc.check",
425
+ { ...timing, enabled: false },
426
+ "",
427
+ );
428
+ }
429
+ recordTiming(
430
+ timing,
431
+ `ttsc check plugin ${plugin.name} time`,
432
+ startedAt,
433
+ );
434
+ }
435
+ out = appendBuildOutput(out, result);
436
+ if (result.status !== 0) return out;
437
+ }
438
+ return out;
439
+ }
440
+ }
441
+
442
+ function projectInputSnapshotsEqual(
443
+ left: ITtscProjectInputSnapshot,
444
+ right: ITtscProjectInputSnapshot,
445
+ ): boolean {
446
+ const leftReloadFiles = left.reloadFiles ?? [];
447
+ const rightReloadFiles = right.reloadFiles ?? [];
448
+ const leftReloadDirectories = left.reloadDirectories ?? [];
449
+ const rightReloadDirectories = right.reloadDirectories ?? [];
450
+ return (
451
+ left.root === right.root &&
452
+ left.files.length === right.files.length &&
453
+ left.globs.length === right.globs.length &&
454
+ leftReloadDirectories.length === rightReloadDirectories.length &&
455
+ leftReloadFiles.length === rightReloadFiles.length &&
456
+ left.files.every((value, index) => value === right.files[index]) &&
457
+ left.globs.every((value, index) => value === right.globs[index]) &&
458
+ leftReloadDirectories.every(
459
+ (value, index) => value === rightReloadDirectories[index],
460
+ ) &&
461
+ leftReloadFiles.every((value, index) => value === rightReloadFiles[index])
462
+ );
463
+ }
464
+
465
+ function residentCheckExecutionIsCompatible(
466
+ options: RunBuildOptions,
467
+ execution: ReturnType<typeof resolveExecutionContext>,
468
+ ): boolean {
469
+ return (
470
+ options.emit === false &&
471
+ options.fix !== true &&
472
+ options.format !== true &&
473
+ forwardsTerminalTsgoFlag(options) === false &&
474
+ execution.nativePlugins.every((plugin) => plugin.stage === "check")
475
+ );
476
+ }
477
+
478
+ function residentCheckProcessKey(
479
+ plugin: ITtscLoadedNativePlugin,
480
+ args: readonly string[],
481
+ ): string {
482
+ return `${plugin.binary}\0${plugin.name}\0${JSON.stringify(args)}`;
483
+ }
484
+
485
+ export type ResidentCheckEntryPlan = {
486
+ args: string[];
487
+ entryIndex: number;
488
+ key: string | undefined;
489
+ plugin: ITtscLoadedNativePlugin;
490
+ };
491
+
492
+ /**
493
+ * Plan every configured check entry while sharing resident processes only by
494
+ * binary/name/argument identity.
495
+ */
496
+ export function planResidentCheckEntries(
497
+ plugins: readonly ITtscLoadedNativePlugin[],
498
+ createArgs: (plugin: ITtscLoadedNativePlugin) => string[],
499
+ ): ResidentCheckEntryPlan[] {
500
+ return plugins
501
+ .filter((candidate) => candidate.stage === "check")
502
+ .map((plugin, entryIndex) => {
503
+ const args = createArgs(plugin);
504
+ return {
505
+ args,
506
+ entryIndex,
507
+ key:
508
+ plugin.capabilities?.residentCheck === true
509
+ ? residentCheckProcessKey(plugin, args)
510
+ : undefined,
511
+ plugin,
512
+ };
513
+ });
514
+ }
515
+
516
+ /** Retain one complete change stream per configured resident check entry. */
517
+ export function bufferResidentCheckEntryRequests(
518
+ pending: Map<number, ResidentCheckRequest>,
519
+ checks: readonly ResidentCheckEntryPlan[],
520
+ request: ResidentCheckRequest,
521
+ ): void {
522
+ for (const check of checks) {
523
+ if (check.key === undefined) continue;
524
+ pending.set(
525
+ check.entryIndex,
526
+ mergeResidentCheckRequests(pending.get(check.entryIndex), request),
527
+ );
528
+ }
529
+ }
530
+
531
+ /** Consume exactly one configured entry's buffered request. */
532
+ export function takeResidentCheckEntryRequest(
533
+ pending: Map<number, ResidentCheckRequest>,
534
+ entryIndex: number,
535
+ ): ResidentCheckRequest {
536
+ const request = pending.get(entryIndex);
537
+ if (request === undefined) {
538
+ throw new Error(
539
+ `ttsc: resident check entry ${String(entryIndex)} has no buffered request`,
540
+ );
541
+ }
542
+ pending.delete(entryIndex);
543
+ return request;
544
+ }
545
+
546
+ export function residentCheckRequest(
547
+ change: ResidentCheckWatchChange,
548
+ cwd: string,
549
+ ): ResidentCheckRequest {
550
+ const normalize = (values: readonly string[] | undefined): string[] =>
551
+ [...new Set(values?.map((value) => path.resolve(cwd, value)) ?? [])].sort();
552
+ const changed = normalize(change.changed);
553
+ const external = normalize(change.external);
554
+ return {
555
+ ...(change.invalidate === true ? { invalidate: true } : {}),
556
+ ...(changed.length === 0 ? {} : { changed }),
557
+ ...(external.length === 0 ? {} : { external }),
558
+ };
559
+ }
560
+
561
+ function mergeResidentCheckRequests(
562
+ previous: ResidentCheckRequest | undefined,
563
+ current: ResidentCheckRequest,
564
+ ): ResidentCheckRequest {
565
+ const merge = (
566
+ left: readonly string[] | undefined,
567
+ right: readonly string[] | undefined,
568
+ ): string[] => [...new Set([...(left ?? []), ...(right ?? [])])].sort();
569
+ const changed = merge(previous?.changed, current.changed);
570
+ const external = merge(previous?.external, current.external);
571
+ return {
572
+ ...(changed.length === 0 ? {} : { changed }),
573
+ ...(external.length === 0 ? {} : { external }),
574
+ ...(previous?.invalidate === true || current.invalidate === true
575
+ ? { invalidate: true }
576
+ : {}),
577
+ };
578
+ }
579
+
195
580
  function runBuildTimed(
196
581
  options: RunBuildOptions,
197
582
  timing: BuildTiming,
@@ -200,12 +585,31 @@ function runBuildTimed(
200
585
  if (projectFree !== null) return projectFree;
201
586
  const setupStartedAt = process.hrtime.bigint();
202
587
  const execution = resolveExecutionContext(options);
203
- options.onWatchInputs?.(
204
- execution.nativePlugins.flatMap((plugin) => [
205
- plugin.source,
206
- ...(plugin.contributors?.map((contributor) => contributor.source) ?? []),
207
- ]),
588
+ return runBuildWithExecution(options, timing, execution, setupStartedAt);
589
+ }
590
+
591
+ function runBuildWithExecution(
592
+ options: RunBuildOptions,
593
+ timing: BuildTiming,
594
+ execution: ReturnType<typeof resolveExecutionContext>,
595
+ setupStartedAt: bigint,
596
+ ): TtscBuildResult {
597
+ const prepared = prepareBuildExecution(
598
+ options,
599
+ timing,
600
+ execution,
601
+ setupStartedAt,
208
602
  );
603
+ if (prepared.result !== undefined) return prepared.result;
604
+ return runPreparedBuild(options, timing, execution, prepared.buildOptions);
605
+ }
606
+
607
+ function prepareBuildExecution(
608
+ options: RunBuildOptions,
609
+ timing: BuildTiming,
610
+ execution: ReturnType<typeof resolveExecutionContext>,
611
+ setupStartedAt: bigint,
612
+ ): { buildOptions: RunBuildOptions; result?: TtscBuildResult } {
209
613
  if (
210
614
  execution.nativePlugins.length > 0 ||
211
615
  execution.pluginSetupFailure !== undefined
@@ -214,12 +618,27 @@ function runBuildTimed(
214
618
  }
215
619
  const buildOptions = applyProjectNoEmit(options, execution);
216
620
  if (execution.pluginSetupFailure !== undefined) {
217
- return appendTypeScriptDiagnosticsAfterPluginFailure(
218
- execution.pluginSetupFailure,
621
+ return {
219
622
  buildOptions,
220
- execution,
221
- );
623
+ result: appendTypeScriptDiagnosticsAfterPluginFailure(
624
+ execution.pluginSetupFailure,
625
+ buildOptions,
626
+ execution,
627
+ ),
628
+ };
629
+ }
630
+ if (options.onProjectInputs !== undefined) {
631
+ options.onProjectInputs(discoverNativeProjectInputs(options, execution));
222
632
  }
633
+ return { buildOptions };
634
+ }
635
+
636
+ function runPreparedBuild(
637
+ options: RunBuildOptions,
638
+ timing: BuildTiming,
639
+ execution: ReturnType<typeof resolveExecutionContext>,
640
+ buildOptions: RunBuildOptions,
641
+ ): TtscBuildResult {
223
642
  if (execution.nativePlugins.length > 0) {
224
643
  const compilers = execution.nativePlugins.filter(
225
644
  (plugin) => plugin.stage === "transform",
@@ -838,6 +1257,7 @@ function createTsgoBuildArgs(
838
1257
  args.push(...createTsgoDiagnosticArgs(options));
839
1258
  args.push(...createTsgoThreadingArgs(options));
840
1259
  args.push(...(options.passthrough ?? []));
1260
+ args.push(...isolatedTsgoOutputArgs(options));
841
1261
  if (flags.noEmitOnError === true) {
842
1262
  args.push("--noEmitOnError");
843
1263
  }
@@ -957,6 +1377,27 @@ function createNativeCheckArgs(
957
1377
  return args;
958
1378
  }
959
1379
 
1380
+ function createNativeProjectInputsArgs(
1381
+ execution: ReturnType<typeof resolveExecutionContext>,
1382
+ plugin: ITtscLoadedNativePlugin,
1383
+ ): string[] {
1384
+ const args = [
1385
+ "project-inputs",
1386
+ "--tsconfig=" + execution.tsconfig,
1387
+ "--plugins-json=" + serializeNativePlugins(execution.nativePlugins),
1388
+ "--cwd=" + execution.projectRoot,
1389
+ ];
1390
+ if (plugin.capabilities?.projectContextArgs === true) {
1391
+ args.push(
1392
+ ...createNativeProjectContextArgs(
1393
+ execution.project,
1394
+ execution.pluginConfigDir,
1395
+ ),
1396
+ );
1397
+ }
1398
+ return args;
1399
+ }
1400
+
960
1401
  // `--singleThreaded` / `--checkers` are forwarded to native check-stage hosts
961
1402
  // only when the host is one ttsc itself owns (currently `@ttsc/lint`). #113
962
1403
  // forwarded both flags as bare CLI tokens to every native sidecar, then
@@ -1048,13 +1489,35 @@ function transformHostTimingLabel(
1048
1489
  * single token so the sidecars' unknown-flag filters keep it intact.
1049
1490
  */
1050
1491
  function createNativeTsgoArgs(options: TtscCommonOptions): string[] {
1051
- const passthrough = nativeTsgoPassthroughArgs(options);
1052
- if (passthrough === undefined || passthrough.length === 0) {
1492
+ const passthrough = [
1493
+ ...(nativeTsgoPassthroughArgs(options) ?? []),
1494
+ ...isolatedTsgoOutputArgs(options),
1495
+ ];
1496
+ if (passthrough.length === 0) {
1053
1497
  return [];
1054
1498
  }
1055
1499
  return ["--tsgo-args=" + JSON.stringify(passthrough)];
1056
1500
  }
1057
1501
 
1502
+ function isolatedTsgoOutputArgs(options: TtscCommonOptions): string[] {
1503
+ const target =
1504
+ "isolateOutputsTo" in options &&
1505
+ typeof options.isolateOutputsTo === "string"
1506
+ ? path.resolve(options.isolateOutputsTo)
1507
+ : undefined;
1508
+ if (target === undefined) return [];
1509
+ return [
1510
+ "--outFile",
1511
+ "null",
1512
+ "--declarationDir",
1513
+ "null",
1514
+ "--tsBuildInfoFile",
1515
+ "null",
1516
+ "--outDir",
1517
+ target,
1518
+ ];
1519
+ }
1520
+
1058
1521
  function nativeTsgoPassthroughArgs(
1059
1522
  options: TtscCommonOptions,
1060
1523
  ): readonly string[] | undefined {
@@ -1151,6 +1614,255 @@ function runNativeCheckPlugins(
1151
1614
  return out;
1152
1615
  }
1153
1616
 
1617
+ function discoverNativeProjectInputs(
1618
+ options: TtscBuildOptions,
1619
+ execution: ReturnType<typeof resolveExecutionContext>,
1620
+ ): ITtscProjectInputSnapshot {
1621
+ const snapshots: ITtscProjectInputSnapshot[] = [];
1622
+ for (const plugin of execution.nativePlugins.filter(
1623
+ (candidate) =>
1624
+ candidate.stage === "check" &&
1625
+ candidate.capabilities?.projectInputs === true,
1626
+ )) {
1627
+ const res = spawnNative(
1628
+ plugin.binary,
1629
+ createNativeProjectInputsArgs(execution, plugin),
1630
+ {
1631
+ cwd: execution.projectRoot,
1632
+ env: nativePluginEnv(options.env, execution, plugin),
1633
+ encoding: "utf8",
1634
+ },
1635
+ );
1636
+ if (res.error) {
1637
+ throw new Error(
1638
+ `ttsc.project-inputs: failed to spawn ${plugin.binary}: ${res.error.message}`,
1639
+ );
1640
+ }
1641
+ const stdout = outputText(res.stdout).trim();
1642
+ if (res.status !== 0) {
1643
+ const detail = outputText(res.stderr).trim() || stdout;
1644
+ throw new Error(
1645
+ `ttsc.project-inputs: ${plugin.name ?? plugin.binary} failed${detail ? `: ${detail}` : ""}`,
1646
+ );
1647
+ }
1648
+ const snapshot = parseProjectInputSnapshot(stdout, plugin);
1649
+ snapshots.push(snapshot);
1650
+ }
1651
+ return mergeProjectInputSnapshots(execution.projectRoot, snapshots);
1652
+ }
1653
+
1654
+ function arraysEqual(
1655
+ left: readonly string[],
1656
+ right: readonly string[],
1657
+ ): boolean {
1658
+ return (
1659
+ left.length === right.length && left.every((item, i) => item === right[i])
1660
+ );
1661
+ }
1662
+
1663
+ /**
1664
+ * Keep every declared spelling of one identity, ordered without regard to which
1665
+ * producer ran first.
1666
+ *
1667
+ * Two contributors can name the same file through different aliases, and each
1668
+ * alias is a path a watcher has to observe: keeping only one of them would
1669
+ * discard the link whose retarget the other spelling exists to catch. Insertion
1670
+ * order is not canonical, so the caller sorts the flattened result.
1671
+ */
1672
+ function retainDeclaredSpelling(
1673
+ target: Map<string, string[]>,
1674
+ key: string,
1675
+ declared: string,
1676
+ ): void {
1677
+ const previous = target.get(key);
1678
+ if (previous === undefined) {
1679
+ target.set(key, [declared]);
1680
+ return;
1681
+ }
1682
+ if (previous.includes(declared)) return;
1683
+ previous.push(declared);
1684
+ }
1685
+
1686
+ export function mergeProjectInputSnapshots(
1687
+ fallbackRoot: string,
1688
+ snapshots: readonly ITtscProjectInputSnapshot[],
1689
+ identities: ProjectInputPathIdentityContext = createProjectInputPathIdentityContext(),
1690
+ ): ITtscProjectInputSnapshot {
1691
+ const files = new Map<string, string>();
1692
+ const globs = new Map<string, string>();
1693
+ const reloadDirectories = new Map<string, string>();
1694
+ const reloadFiles = new Map<string, string>();
1695
+ // Deduplicated on the same identity keys, but holding what the contributors
1696
+ // wrote. Normalization resolves a declaration through its symlinks, which is
1697
+ // right for every comparison and wrong for the watcher that has to observe
1698
+ // the link itself being retargeted.
1699
+ const declaredFiles = new Map<string, string[]>();
1700
+ const declaredGlobs = new Map<string, string[]>();
1701
+ const declaredReloadDirectories = new Map<string, string[]>();
1702
+ const declaredReloadFiles = new Map<string, string[]>();
1703
+ const rootIdentity = identities.resolve(fallbackRoot);
1704
+ for (const snapshot of snapshots) {
1705
+ const candidateRoot = identities.resolve(snapshot.root);
1706
+ if (rootIdentity.key !== candidateRoot.key) {
1707
+ throw new Error(
1708
+ `ttsc.project-inputs: plugin root ${candidateRoot.path} differs from the selected project root ${rootIdentity.path}`,
1709
+ );
1710
+ }
1711
+ for (const file of snapshot.files) {
1712
+ const identity = identities.resolve(file);
1713
+ files.set(identity.key, identity.path);
1714
+ retainDeclaredSpelling(declaredFiles, identity.key, path.resolve(file));
1715
+ }
1716
+ for (const glob of snapshot.globs) {
1717
+ const identity = identities.resolve(glob);
1718
+ globs.set(identity.key, identity.path.split(path.sep).join("/"));
1719
+ retainDeclaredSpelling(
1720
+ declaredGlobs,
1721
+ identity.key,
1722
+ path.resolve(glob).split(path.sep).join("/"),
1723
+ );
1724
+ }
1725
+ for (const reloadFile of snapshot.reloadFiles ?? []) {
1726
+ const identity = identities.resolve(reloadFile);
1727
+ reloadFiles.set(identity.key, identity.path);
1728
+ retainDeclaredSpelling(
1729
+ declaredReloadFiles,
1730
+ identity.key,
1731
+ path.resolve(reloadFile),
1732
+ );
1733
+ }
1734
+ for (const reloadDirectory of snapshot.reloadDirectories ?? []) {
1735
+ const identity = identities.resolve(reloadDirectory);
1736
+ reloadDirectories.set(identity.key, identity.path);
1737
+ retainDeclaredSpelling(
1738
+ declaredReloadDirectories,
1739
+ identity.key,
1740
+ path.resolve(reloadDirectory),
1741
+ );
1742
+ }
1743
+ }
1744
+ const normalized = {
1745
+ root: rootIdentity.path,
1746
+ files: [...files.values()].sort(),
1747
+ globs: [...globs.values()].sort(),
1748
+ reloadDirectories: [...reloadDirectories.values()].sort(),
1749
+ reloadFiles: [...reloadFiles.values()].sort(),
1750
+ };
1751
+ const declared = {
1752
+ files: [...declaredFiles.values()].flat().sort(),
1753
+ globs: [...declaredGlobs.values()].flat().sort(),
1754
+ reloadDirectories: [...declaredReloadDirectories.values()].flat().sort(),
1755
+ reloadFiles: [...declaredReloadFiles.values()].flat().sort(),
1756
+ };
1757
+ // Carried only when it says something the normalized arrays do not. A tree
1758
+ // with no alias on any declaration produces the same four lists, and a
1759
+ // consumer that has to scan both would pay twice for one population.
1760
+ return arraysEqual(declared.files, normalized.files) &&
1761
+ arraysEqual(declared.globs, normalized.globs) &&
1762
+ arraysEqual(declared.reloadDirectories, normalized.reloadDirectories) &&
1763
+ arraysEqual(declared.reloadFiles, normalized.reloadFiles)
1764
+ ? normalized
1765
+ : { ...normalized, declared };
1766
+ }
1767
+
1768
+ export function parseProjectInputSnapshot(
1769
+ text: string,
1770
+ plugin: ITtscLoadedNativePlugin,
1771
+ ): ITtscProjectInputSnapshot {
1772
+ let value: unknown;
1773
+ try {
1774
+ value = JSON.parse(text);
1775
+ } catch (error) {
1776
+ throw new Error(
1777
+ `ttsc.project-inputs: ${plugin.name ?? plugin.binary} returned invalid JSON: ${
1778
+ error instanceof Error ? error.message : String(error)
1779
+ }`,
1780
+ );
1781
+ }
1782
+ if (
1783
+ typeof value !== "object" ||
1784
+ value === null ||
1785
+ typeof (value as { root?: unknown }).root !== "string" ||
1786
+ !isStringArray((value as { files?: unknown }).files) ||
1787
+ !isStringArray((value as { globs?: unknown }).globs) ||
1788
+ ((value as { reloadDirectories?: unknown }).reloadDirectories !==
1789
+ undefined &&
1790
+ !isStringArray(
1791
+ (value as { reloadDirectories?: unknown }).reloadDirectories,
1792
+ )) ||
1793
+ ((value as { reloadFiles?: unknown }).reloadFiles !== undefined &&
1794
+ !isStringArray((value as { reloadFiles?: unknown }).reloadFiles))
1795
+ ) {
1796
+ throw new Error(
1797
+ `ttsc.project-inputs: ${plugin.name ?? plugin.binary} returned an invalid snapshot`,
1798
+ );
1799
+ }
1800
+ const snapshot = value as unknown as ITtscProjectInputSnapshot;
1801
+ const invalid = [
1802
+ ["root", snapshot.root],
1803
+ ...snapshot.files.map((file) => ["file", file] as const),
1804
+ ...snapshot.globs.map((glob) => ["glob", glob] as const),
1805
+ ...(snapshot.reloadDirectories ?? []).map(
1806
+ (directory) => ["reload directory", directory] as const,
1807
+ ),
1808
+ ...(snapshot.reloadFiles ?? []).map(
1809
+ (file) => ["reload file", file] as const,
1810
+ ),
1811
+ ].find(
1812
+ ([, location]) =>
1813
+ location.length === 0 || !isAbsoluteLocalProjectInputPath(location),
1814
+ );
1815
+ if (invalid !== undefined) {
1816
+ throw new Error(
1817
+ `ttsc.project-inputs: ${plugin.name ?? plugin.binary} returned an invalid snapshot: ${invalid[0]} ${JSON.stringify(invalid[1])} is not an absolute local path`,
1818
+ );
1819
+ }
1820
+ return {
1821
+ ...snapshot,
1822
+ reloadDirectories: snapshot.reloadDirectories ?? [],
1823
+ reloadFiles: snapshot.reloadFiles ?? [],
1824
+ };
1825
+ }
1826
+
1827
+ export function isAbsoluteLocalProjectInputPath(
1828
+ location: string,
1829
+ platform: NodeJS.Platform = process.platform,
1830
+ ): boolean {
1831
+ if (location.includes("\0")) return false;
1832
+ if (platform !== "win32") return path.posix.isAbsolute(location);
1833
+ const normalized = location.replaceAll("/", "\\");
1834
+ if (/^[A-Za-z]:\\/.test(normalized)) return true;
1835
+ if (normalized.startsWith("\\\\?\\")) {
1836
+ const extended = normalized.slice(4);
1837
+ if (/^[A-Za-z]:\\/.test(extended)) return true;
1838
+ if (extended.toLowerCase().startsWith("unc\\")) {
1839
+ return isWindowsUncProjectInputPath(`\\\\${extended.slice(4)}`);
1840
+ }
1841
+ return false;
1842
+ }
1843
+ if (normalized.startsWith("\\\\.\\")) return false;
1844
+ return isWindowsUncProjectInputPath(normalized);
1845
+ }
1846
+
1847
+ function isWindowsUncProjectInputPath(location: string): boolean {
1848
+ const matched = /^\\\\([^\\]+)\\([^\\]+)(?:\\|$)/.exec(location);
1849
+ return (
1850
+ matched !== null &&
1851
+ isWindowsUncVolumeSegment(matched[1]!) &&
1852
+ isWindowsUncVolumeSegment(matched[2]!)
1853
+ );
1854
+ }
1855
+
1856
+ function isWindowsUncVolumeSegment(segment: string): boolean {
1857
+ return segment !== "." && segment !== ".." && !/[\0<>:"/\\|?*]/.test(segment);
1858
+ }
1859
+
1860
+ function isStringArray(value: unknown): value is string[] {
1861
+ return (
1862
+ Array.isArray(value) && value.every((item) => typeof item === "string")
1863
+ );
1864
+ }
1865
+
1154
1866
  /**
1155
1867
  * Spawn a single native plugin binary with the given args and return the
1156
1868
  * normalized build result. `label` is used in the thrown error message so the
@@ -1237,6 +1949,7 @@ export function createProcessDiagnostic(
1237
1949
  function resolveExecutionContext(
1238
1950
  options: TtscCommonOptions & {
1239
1951
  emit?: boolean;
1952
+ onWatchInputs?: (inputs: readonly string[]) => void;
1240
1953
  resolvedProject?: ITtscParsedProjectConfig;
1241
1954
  tsconfig?: string;
1242
1955
  },
@@ -1262,10 +1975,13 @@ function resolveExecutionContext(
1262
1975
  cwd,
1263
1976
  entries: options.plugins,
1264
1977
  env: { ...process.env, ...options.env },
1978
+ onWatchInputs: options.onWatchInputs,
1265
1979
  pluginConfigDir: options.pluginConfigDir,
1266
1980
  projectRoot,
1267
1981
  tsconfig,
1268
1982
  }).nativePlugins;
1983
+ } else {
1984
+ options.onWatchInputs?.([]);
1269
1985
  }
1270
1986
  } catch (error) {
1271
1987
  pluginSetupFailure = {