ttsc 0.20.1 → 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
@@ -4,7 +4,11 @@ import path from "node:path";
4
4
  import { TtscCompiler } from "../../TtscCompiler";
5
5
  import { readProjectConfig } from "../../compiler/internal/project/readProjectConfig";
6
6
  import { resolveProjectConfig } from "../../compiler/internal/project/resolveProjectConfig";
7
- import { runBuild } from "../../compiler/internal/runBuild";
7
+ import {
8
+ type ResidentCheckWatchChange,
9
+ ResidentCheckWatchSession,
10
+ runBuild,
11
+ } from "../../compiler/internal/runBuild";
8
12
  import { runSingleFileEmit } from "../../compiler/internal/runSingleFileEmit";
9
13
  import {
10
14
  getBoolean,
@@ -20,10 +24,13 @@ import {
20
24
  resolveCleanTargets,
21
25
  resolveSourceBuildCachePaths,
22
26
  } from "../../plugin/internal/buildSourcePlugin";
27
+ import type { ITtscProjectInputSnapshot } from "../../structures/internal/ITtscProjectInputSnapshot";
23
28
  import type { TtscBuildOptions } from "../../structures/internal/TtscBuildOptions";
29
+ import type { TtscSingleFileEmitOptions } from "../../structures/internal/TtscSingleFileEmitOptions";
24
30
  import { getCompilerVersionText } from "./getCompilerVersionText";
25
31
  import { resolveCacheDir } from "./resolveCacheDir";
26
- import { WatchTopology } from "./watchTopology";
32
+ import { resolveSingleFileOutput } from "./singleFileOutput";
33
+ import { type WatchInputChange, WatchTopology } from "./watchTopology";
27
34
 
28
35
  /**
29
36
  * CLI entry point for `ttsc`. Dispatches argv to the appropriate build lane
@@ -570,7 +577,10 @@ function printCacheHelp(): void {
570
577
  process.stdout.write("\n");
571
578
  }
572
579
 
573
- function runSingleFile(options: ReturnType<typeof parseBuildArgs>): number {
580
+ function runSingleFile(
581
+ options: ReturnType<typeof parseBuildArgs> &
582
+ Pick<TtscSingleFileEmitOptions, "onProjectInputs" | "onWatchInputs">,
583
+ ): number {
574
584
  if (options.files.length !== 1) {
575
585
  throw new Error(
576
586
  "ttsc: single-file mode currently accepts exactly one input file",
@@ -580,10 +590,11 @@ function runSingleFile(options: ReturnType<typeof parseBuildArgs>): number {
580
590
  const file = path.resolve(cwd, options.files[0]!);
581
591
  const emit = singleFileShouldEmit(options, cwd, file);
582
592
  const out = emit
583
- ? resolveSingleFileOut({
593
+ ? resolveSingleFileOutput({
584
594
  cliOutDir: options.outDir,
585
595
  cwd,
586
596
  file,
597
+ passthrough: options.passthrough,
587
598
  tsconfig: options.tsconfig,
588
599
  })
589
600
  : undefined;
@@ -592,6 +603,8 @@ function runSingleFile(options: ReturnType<typeof parseBuildArgs>): number {
592
603
  checkers: options.checkers,
593
604
  cwd,
594
605
  file,
606
+ onProjectInputs: options.onProjectInputs,
607
+ onWatchInputs: options.onWatchInputs,
595
608
  out,
596
609
  passthrough: options.passthrough,
597
610
  singleThreaded: options.singleThreaded,
@@ -628,101 +641,6 @@ function singleFileShouldEmit(
628
641
  return project.compilerOptions.noEmit !== true;
629
642
  }
630
643
 
631
- function resolveSingleFileOut(opts: {
632
- cliOutDir?: string;
633
- cwd: string;
634
- file: string;
635
- tsconfig?: string;
636
- }): string {
637
- const jsBasename =
638
- path.basename(opts.file).replace(/\.[cm]?tsx?$/i, "") +
639
- singleFileJsExtension(opts.file);
640
-
641
- // Explicit CLI --outDir wins. Mirrors the CWD-relative source layout under
642
- // the requested directory so existing single-file invocations don't shift.
643
- if (opts.cliOutDir) {
644
- const relative = path.relative(opts.cwd, opts.file);
645
- const jsRelative =
646
- relative.slice(0, relative.length - path.extname(relative).length) +
647
- singleFileJsExtension(opts.file);
648
- return path.resolve(opts.cwd, opts.cliOutDir, jsRelative);
649
- }
650
-
651
- // No CLI override: honor tsconfig's outDir so `ttsc src/foo.ts` lands the
652
- // emitted JS at `<outDir>/<relative-from-rootDir>.js` instead of dropping
653
- // it next to the source file. This matches how project mode emits and how
654
- // `tsc <file>` would behave with the same tsconfig.
655
- const projectOutDir = readProjectOutDir({
656
- cwd: opts.cwd,
657
- file: opts.file,
658
- tsconfig: opts.tsconfig,
659
- });
660
- if (projectOutDir !== null) {
661
- const fromRoot = path.relative(projectOutDir.rootDir, opts.file);
662
- if (fromRoot !== "" && !isOutsideSingleFileLayout(fromRoot)) {
663
- const jsRelative =
664
- fromRoot.slice(0, fromRoot.length - path.extname(fromRoot).length) +
665
- singleFileJsExtension(opts.file);
666
- return path.resolve(projectOutDir.outDir, jsRelative);
667
- }
668
- return path.resolve(projectOutDir.outDir, jsBasename);
669
- }
670
-
671
- // Last resort (no tsconfig outDir at all): emit next to the source. This
672
- // preserves the legacy `ttsc <file.ts>` → `<file.js>` behavior for projects
673
- // that intentionally don't configure outDir.
674
- return opts.file.replace(/\.[cm]?tsx?$/i, singleFileJsExtension(opts.file));
675
- }
676
-
677
- function readProjectOutDir(opts: {
678
- cwd: string;
679
- file: string;
680
- tsconfig?: string;
681
- }): { outDir: string; rootDir: string } | null {
682
- try {
683
- const project = readProjectConfig({
684
- cwd: opts.cwd,
685
- file: opts.file,
686
- tsconfig: opts.tsconfig,
687
- });
688
- const outDir = project.compilerOptions.outDir;
689
- if (typeof outDir !== "string" || outDir.length === 0) {
690
- return null;
691
- }
692
- const rawRoot = project.compilerOptions.rootDir;
693
- const rootDir =
694
- typeof rawRoot === "string" && rawRoot.length !== 0
695
- ? path.isAbsolute(rawRoot)
696
- ? rawRoot
697
- : path.resolve(project.root, rawRoot)
698
- : project.root;
699
- return { outDir, rootDir };
700
- } catch {
701
- // Missing or unreadable tsconfig: fall back to the legacy behavior so
702
- // `ttsc <file>` still works outside a configured project.
703
- return null;
704
- }
705
- }
706
-
707
- function isOutsideSingleFileLayout(relative: string): boolean {
708
- return (
709
- relative === ".." ||
710
- relative.startsWith(`..${path.sep}`) ||
711
- path.isAbsolute(relative)
712
- );
713
- }
714
-
715
- function singleFileJsExtension(file: string): string {
716
- switch (path.extname(file).toLowerCase()) {
717
- case ".mts":
718
- return ".mjs";
719
- case ".cts":
720
- return ".cjs";
721
- default:
722
- return ".js";
723
- }
724
- }
725
-
726
644
  function runWatch(
727
645
  options: ReturnType<typeof parseBuildArgs>,
728
646
  checkOnly: boolean,
@@ -735,6 +653,9 @@ function runWatch(
735
653
  onWatchInputs: (inputs: readonly string[]) => {
736
654
  topology?.setExtraInputs(inputs);
737
655
  },
656
+ onProjectInputs: (inputs: ITtscProjectInputSnapshot) => {
657
+ topology?.setProjectInputs(inputs);
658
+ },
738
659
  quiet: true,
739
660
  };
740
661
  const root = path.dirname(
@@ -746,12 +667,16 @@ function runWatch(
746
667
  let running = false;
747
668
  let rerun = false;
748
669
  let timer: NodeJS.Timeout | null = null;
670
+ const resident =
671
+ invocation.files.length === 0 ? new ResidentCheckWatchSession() : undefined;
672
+ const pendingChanges = new PendingResidentCheckWatchChanges();
749
673
  // Tracks the most recent build's exit code so the watch session can exit
750
674
  // non-zero when its latest rebuild failed, instead of always reporting 0.
751
675
  let lastStatus = 0;
752
676
 
753
- const runOnce = () => {
677
+ const runOnce = async () => {
754
678
  running = true;
679
+ const change = pendingChanges.take();
755
680
  let completed = false;
756
681
  try {
757
682
  if (!options.preserveWatchOutput) {
@@ -766,24 +691,38 @@ function runWatch(
766
691
  // to invalid JSON, single-file invariant). Catch here so a throwing
767
692
  // rebuild reports a clear failure and the watcher keeps running instead
768
693
  // of crashing the process with an uncaught exception.
769
- const status =
770
- invocation.files.length !== 0
771
- ? runSingleFile(invocation)
772
- : (() => {
773
- const result = runBuild(
774
- checkOnly ? { ...invocation, emit: false } : invocation,
775
- );
776
- if (result.stdout) process.stdout.write(result.stdout);
777
- if (result.stderr) process.stderr.write(result.stderr);
778
- return result.status;
779
- })();
694
+ const status = await (invocation.files.length !== 0
695
+ ? Promise.resolve(runSingleFile(invocation))
696
+ : (async () => {
697
+ const buildOptions = checkOnly
698
+ ? { ...invocation, emit: false }
699
+ : invocation;
700
+ const result =
701
+ resident === undefined
702
+ ? runBuild(buildOptions)
703
+ : await resident.run(buildOptions, change);
704
+ // Watch output is one ordered stream. The diagnostics a build
705
+ // produced and the "[ttsc] watch build …" marker that concludes
706
+ // them reach a single merged reader, and on two pipes the OS may
707
+ // hand it the marker first — a reader that counts the build on the
708
+ // marker then snapshots before the diagnostics land and sees an
709
+ // empty build. The rebuilding banner, the screen clear, and the
710
+ // marker are already on stdout, so the diagnostics join them there,
711
+ // the way tsc --watch prints everything to one place.
712
+ if (result.stdout) process.stdout.write(result.stdout);
713
+ if (result.stderr) process.stdout.write(result.stderr);
714
+ return result.status;
715
+ })());
780
716
  lastStatus = status;
781
717
  completed = true;
782
718
  process.stdout.write(
783
719
  `[ttsc] ${status === 0 ? "watch build complete" : "watch build failed"}\n`,
784
720
  );
785
721
  } catch (error) {
786
- process.stderr.write(`${formatError(error)}\n`);
722
+ // Same ordered-stream rule as the build path: the failure text goes on
723
+ // the stream the marker below uses, so a merged reader sees them in order
724
+ // rather than racing two pipes.
725
+ process.stdout.write(`${formatError(error)}\n`);
787
726
  lastStatus = lastStatus === 0 ? 2 : lastStatus;
788
727
  process.stdout.write(`[ttsc] watch build failed\n`);
789
728
  } finally {
@@ -807,13 +746,14 @@ function runWatch(
807
746
  trigger();
808
747
  }
809
748
  };
810
- const trigger = () => {
749
+ const trigger = (change?: WatchInputChange, reload = false) => {
750
+ pendingChanges.push(change, reload);
811
751
  if (running) {
812
752
  rerun = true;
813
753
  return;
814
754
  }
815
755
  if (timer) clearTimeout(timer);
816
- timer = setTimeout(runOnce, 60);
756
+ timer = setTimeout(() => void runOnce(), 60);
817
757
  };
818
758
 
819
759
  topology = new WatchTopology(invocation, {
@@ -822,14 +762,29 @@ function runWatch(
822
762
  `[ttsc] watch error on ${path.relative(cwd, location) || "."}: ${formatError(error)}\n`,
823
763
  );
824
764
  },
825
- onInputChange: trigger,
826
- onTopologyChange: trigger,
765
+ onInputChange: (change) => {
766
+ debugWatchInputs(
767
+ `change ${change.kind}${change.invalidate === true ? " invalidate" : ""} ${
768
+ change.path === undefined
769
+ ? "(unnamed)"
770
+ : path.relative(cwd, change.path)
771
+ }`,
772
+ );
773
+ trigger(change);
774
+ },
775
+ onProjectInputWatchRoots: (roots) => {
776
+ debugWatchInputs(
777
+ `roots ${JSON.stringify(roots.map((root) => path.relative(cwd, root) || "."))}`,
778
+ );
779
+ },
780
+ onTopologyChange: () => trigger(undefined, true),
827
781
  });
828
782
  topology.refresh(false);
829
783
 
830
784
  const close = () => {
831
785
  if (timer) clearTimeout(timer);
832
786
  topology?.close();
787
+ resident?.dispose();
833
788
  };
834
789
  process.on("SIGINT", () => {
835
790
  close();
@@ -842,7 +797,7 @@ function runWatch(
842
797
 
843
798
  process.stdout.write(`[ttsc] watching ${path.relative(cwd, root) || "."}\n`);
844
799
  try {
845
- runOnce();
800
+ void runOnce();
846
801
  } catch (error) {
847
802
  // runOnce already swallows build throws, but guard against any unforeseen
848
803
  // throw escaping the first pass: tear down the persistent watchers so the
@@ -855,12 +810,81 @@ function runWatch(
855
810
  return toExitCode(lastStatus);
856
811
  }
857
812
 
813
+ /**
814
+ * Coalesces filesystem events until the next resident check-watch cycle.
815
+ *
816
+ * A full reload dominates every narrower signal. Program invalidation remains
817
+ * distinct so a project-input module creation/deletion can cold-load the
818
+ * Program without discarding the selected execution or restarting the sidecar.
819
+ */
820
+ export class PendingResidentCheckWatchChanges {
821
+ private readonly changed = new Set<string>();
822
+ private readonly external = new Set<string>();
823
+ private invalidate = false;
824
+ private reload = false;
825
+
826
+ public push(change?: WatchInputChange, reload = false): void {
827
+ if (reload || change?.kind === "config" || change?.kind === "plugin") {
828
+ this.reload = true;
829
+ this.invalidate = false;
830
+ this.changed.clear();
831
+ this.external.clear();
832
+ return;
833
+ }
834
+ if (this.reload) return;
835
+ if (change?.invalidate === true) this.invalidate = true;
836
+ if (change?.path === undefined) {
837
+ if (change?.kind === "compiler") {
838
+ this.reload = true;
839
+ this.invalidate = false;
840
+ this.changed.clear();
841
+ this.external.clear();
842
+ }
843
+ return;
844
+ }
845
+ this.changed.add(change.path);
846
+ if (change.kind === "project") this.external.add(change.path);
847
+ }
848
+
849
+ public take(): ResidentCheckWatchChange {
850
+ const change: ResidentCheckWatchChange = {
851
+ ...(this.reload ? { reload: true } : {}),
852
+ ...(this.invalidate ? { invalidate: true } : {}),
853
+ ...(this.changed.size === 0 ? {} : { changed: [...this.changed].sort() }),
854
+ ...(this.external.size === 0
855
+ ? {}
856
+ : { external: [...this.external].sort() }),
857
+ };
858
+ this.reload = false;
859
+ this.invalidate = false;
860
+ this.changed.clear();
861
+ this.external.clear();
862
+ return change;
863
+ }
864
+ }
865
+
858
866
  // Coerces a build status into a valid process exit code: 0 stays 0, any
859
867
  // non-zero (or non-finite) status collapses to 1 so the session signals failure.
860
868
  function toExitCode(status: number): number {
861
869
  return Number.isInteger(status) && status >= 0 && status <= 255 ? status : 1;
862
870
  }
863
871
 
872
+ /**
873
+ * Opt-in report of what the project-input layer is watching and announcing.
874
+ *
875
+ * A watch that goes quiet is indistinguishable from a watch with nothing to
876
+ * say: the build simply never runs again, and the transcript that survives says
877
+ * only that. This separates the two — the roots line states what is covered,
878
+ * and a change line states what was announced — so a missing rebuild can be
879
+ * attributed to the watch that never saw the file or to the decision that
880
+ * declined to report it. Silent unless asked, and on the same ordered stream as
881
+ * the rest of the watch output so it interleaves with the builds it explains.
882
+ */
883
+ function debugWatchInputs(message: string): void {
884
+ if (!process.env.TTSC_WATCH_DEBUG_INPUTS) return;
885
+ process.stdout.write(`[ttsc:debug] ${message}\n`);
886
+ }
887
+
864
888
  function formatError(error: unknown): string {
865
889
  if (error instanceof Error) {
866
890
  return error.message;