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
@@ -0,0 +1,235 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ )
9
+
10
+ // projectDiagnosticRecord is one producer's last successful project
11
+ // publication. A failed refresh never updates the record, so another producer's
12
+ // success cannot erase diagnostics that were still valid before the failure.
13
+ type projectDiagnosticRecord struct {
14
+ generation uint64
15
+ publication LSPProjectDiagnostics
16
+ }
17
+
18
+ type projectDiagnosticsRefreshResult struct {
19
+ publication *LSPProjectDiagnostics
20
+ refreshed map[string]struct{}
21
+ complete bool
22
+ selected int
23
+ }
24
+
25
+ // ProjectDiagnostics evaluates project rules without requiring an open
26
+ // document and returns the latest successful publication from every capable
27
+ // sidecar.
28
+ func (s *NativePluginSource) ProjectDiagnostics() *LSPProjectDiagnostics {
29
+ return s.ProjectDiagnosticsForOwners(nil).publication
30
+ }
31
+
32
+ // ProjectDiagnosticsForOwners refreshes only the diagnostics-capable producers
33
+ // named by owners. A nil owner list preserves the legacy all-producer request;
34
+ // an empty non-nil list is a successful no-op.
35
+ func (s *NativePluginSource) ProjectDiagnosticsForOwners(
36
+ owners []string,
37
+ ) projectDiagnosticsRefreshResult {
38
+ if s == nil {
39
+ return projectDiagnosticsRefreshResult{}
40
+ }
41
+ selectedOwners := map[string]struct{}{}
42
+ if owners != nil {
43
+ for _, owner := range owners {
44
+ selectedOwners[owner] = struct{}{}
45
+ }
46
+ }
47
+ generation := s.projectDiagnosticsSequence.Add(1)
48
+ result := projectDiagnosticsRefreshResult{
49
+ complete: true,
50
+ refreshed: map[string]struct{}{},
51
+ }
52
+ for _, plugin := range selectPluginTransports(
53
+ s.plugins,
54
+ func(plugin NativeLSPPluginEntry) bool {
55
+ return plugin.ProjectDiagnostics
56
+ },
57
+ s.projectContextJSON,
58
+ ) {
59
+ key := pluginKey(plugin, s.projectContextJSON)
60
+ if owners != nil {
61
+ if _, selected := selectedOwners[key]; !selected {
62
+ continue
63
+ }
64
+ }
65
+ result.selected++
66
+ body, err := s.run(plugin, serveVerbProjectDiagnostics)
67
+ if err != nil {
68
+ s.log("%v", err)
69
+ result.complete = false
70
+ continue
71
+ }
72
+ var publication *LSPProjectDiagnostics
73
+ if err := json.Unmarshal(body, &publication); err != nil {
74
+ s.log(
75
+ "ttscserver: %s lsp-project-diagnostics returned invalid JSON: %v",
76
+ pluginLabel(plugin),
77
+ err,
78
+ )
79
+ result.complete = false
80
+ continue
81
+ }
82
+ if publication == nil || publication.URI == "" {
83
+ result.complete = false
84
+ continue
85
+ }
86
+ s.storeProjectDiagnostics(plugin, generation, publication)
87
+ result.refreshed[key] = struct{}{}
88
+ }
89
+ result.publication = s.projectDiagnosticsSnapshot()
90
+ return result
91
+ }
92
+
93
+ // storeProjectDiagnostics replaces one producer's last-good publication. A
94
+ // successful empty diagnostics array clears that producer while leaving every
95
+ // other producer unchanged.
96
+ func (s *NativePluginSource) storeProjectDiagnostics(
97
+ plugin NativeLSPPluginEntry,
98
+ generation uint64,
99
+ publication *LSPProjectDiagnostics,
100
+ ) {
101
+ if publication == nil || publication.URI == "" {
102
+ return
103
+ }
104
+ key := pluginKey(plugin, s.projectContextJSON)
105
+ copied := copyProjectDiagnostics(publication)
106
+ s.projectDiagnosticsMu.Lock()
107
+ defer s.projectDiagnosticsMu.Unlock()
108
+ if existing, ok := s.pluginProjectDiagnostics[key]; ok &&
109
+ generation < existing.generation {
110
+ return
111
+ }
112
+ if s.pluginProjectDiagnostics == nil {
113
+ s.pluginProjectDiagnostics = map[string]projectDiagnosticRecord{}
114
+ }
115
+ s.pluginProjectDiagnostics[key] = projectDiagnosticRecord{
116
+ generation: generation,
117
+ publication: *copied,
118
+ }
119
+ }
120
+
121
+ // clientProjectURI restates a producer's project URI in the spelling the client
122
+ // opened the project under.
123
+ //
124
+ // A sidecar is deliberately given the project's physical paths, because that is
125
+ // what makes its compiler, its caches, and its own resolution agree with every
126
+ // other consumer. The editor never saw those paths. It opened the project under
127
+ // whatever spelling its workspace uses — through a symlink, a mapped drive, a
128
+ // case that differs — and a publication addressed to any other spelling is
129
+ // invisible to it no matter how correct the diagnostics inside are. So the one
130
+ // place the two worlds meet translates back.
131
+ func (s *NativePluginSource) clientProjectURI(uri string) string {
132
+ if s == nil || uri == "" {
133
+ return uri
134
+ }
135
+ client, key := s.clientProjectIdentity()
136
+ if client == "" {
137
+ return uri
138
+ }
139
+ location, ok := filePathFromURI(uri)
140
+ if !ok {
141
+ return uri
142
+ }
143
+ if projectInputPathKey(realProjectInputPath(location)) != key {
144
+ return uri
145
+ }
146
+ return projectInputFileURI(client)
147
+ }
148
+
149
+ // clientProjectIdentity is the project as the client named it, absolute, with
150
+ // the key a producer's URI is compared against. Empty when the client named no
151
+ // project at all, which is the one case where there is nothing to translate to.
152
+ func (s *NativePluginSource) clientProjectIdentity() (string, string) {
153
+ s.clientProjectOnce.Do(func() {
154
+ client := projectInputFilesystemPath(strings.TrimSpace(s.clientTsconfig))
155
+ if client == "" {
156
+ return
157
+ }
158
+ if !filepath.IsAbs(client) {
159
+ // The client names its project relative to the directory it asked this
160
+ // host to work in. When it named none, the host was started from that
161
+ // directory and inherited it, so the process directory is not a guess
162
+ // here — it is the same answer by another route.
163
+ base := projectInputFilesystemPath(strings.TrimSpace(s.clientCwd))
164
+ if base == "" {
165
+ working, err := os.Getwd()
166
+ if err != nil {
167
+ return
168
+ }
169
+ base = working
170
+ }
171
+ absolute, err := filepath.Abs(base)
172
+ if err != nil {
173
+ return
174
+ }
175
+ client = filepath.Join(absolute, client)
176
+ }
177
+ if !filepath.IsAbs(client) {
178
+ return
179
+ }
180
+ s.clientProject = client
181
+ s.clientProjectKey = projectInputPathKey(realProjectInputPath(client))
182
+ })
183
+ return s.clientProject, s.clientProjectKey
184
+ }
185
+
186
+ // projectDiagnosticsSnapshot concatenates producer publications in manifest
187
+ // order. A project has one config URI, so a producer that reports a different
188
+ // URI is excluded and logged rather than replacing the other producers.
189
+ func (s *NativePluginSource) projectDiagnosticsSnapshot() *LSPProjectDiagnostics {
190
+ s.projectDiagnosticsMu.RLock()
191
+ defer s.projectDiagnosticsMu.RUnlock()
192
+ var out *LSPProjectDiagnostics
193
+ for _, plugin := range selectPluginTransports(
194
+ s.plugins,
195
+ nil,
196
+ s.projectContextJSON,
197
+ ) {
198
+ key := pluginKey(plugin, s.projectContextJSON)
199
+ record, ok := s.pluginProjectDiagnostics[key]
200
+ if !ok {
201
+ continue
202
+ }
203
+ publication := record.publication
204
+ publication.URI = s.clientProjectURI(publication.URI)
205
+ if out == nil {
206
+ out = copyProjectDiagnostics(&publication)
207
+ continue
208
+ }
209
+ if out.URI != publication.URI {
210
+ s.log(
211
+ "ttscserver: %s lsp-project-diagnostics returned URI %q, want %q",
212
+ pluginLabel(plugin),
213
+ publication.URI,
214
+ out.URI,
215
+ )
216
+ continue
217
+ }
218
+ out.Diagnostics = append(out.Diagnostics, publication.Diagnostics...)
219
+ }
220
+ return out
221
+ }
222
+
223
+ func copyProjectDiagnostics(
224
+ publication *LSPProjectDiagnostics,
225
+ ) *LSPProjectDiagnostics {
226
+ if publication == nil {
227
+ return nil
228
+ }
229
+ copied := *publication
230
+ copied.Diagnostics = append(
231
+ []LSPDiagnostic(nil),
232
+ publication.Diagnostics...,
233
+ )
234
+ return &copied
235
+ }
@@ -0,0 +1,67 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ // TestLSPProjectDiagnosticsCapabilityIsIndependent verifies the standalone
10
+ // diagnostic command is not inferred from project-input topology support.
11
+ //
12
+ // A third-party sidecar may implement `project-inputs` without implementing
13
+ // `lsp-project-diagnostics`, while a staged diagnostic sidecar may expose the
14
+ // inverse combination. Probing either command under the other capability makes
15
+ // strict sidecars fail an undocumented command.
16
+ //
17
+ // 1. Configure a topology-only plugin and a diagnostics-only plugin.
18
+ // 2. Seed the diagnostics producer, then make its next refresh fail to launch.
19
+ // 3. Assert only the explicitly capable producer was invoked.
20
+ // 4. Assert its last-good publication remains available after the failure.
21
+ func TestLSPProjectDiagnosticsCapabilityIsIndependent(t *testing.T) {
22
+ topologyOnly := NativeLSPPluginEntry{
23
+ Binary: "ttsc-no-such-topology-only-sidecar",
24
+ Name: "@ttsc/topology-only",
25
+ ProjectInputs: true,
26
+ }
27
+ diagnosticsOnly := NativeLSPPluginEntry{
28
+ Binary: "ttsc-no-such-diagnostics-only-sidecar",
29
+ Name: "@ttsc/diagnostics-only",
30
+ ProjectDiagnostics: true,
31
+ }
32
+ var log bytes.Buffer
33
+ source := &NativePluginSource{
34
+ err: &log,
35
+ plugins: []NativeLSPPluginEntry{topologyOnly, diagnosticsOnly},
36
+ }
37
+ source.storeProjectDiagnostics(
38
+ diagnosticsOnly,
39
+ 1,
40
+ &LSPProjectDiagnostics{
41
+ URI: "file:///project/tsconfig.json",
42
+ Diagnostics: []LSPDiagnostic{{
43
+ Code: "last-good",
44
+ Message: "last-good",
45
+ }},
46
+ },
47
+ )
48
+
49
+ got := source.ProjectDiagnostics()
50
+
51
+ if strings.Contains(log.String(), topologyOnly.Name) {
52
+ t.Fatalf(
53
+ "projectInputs incorrectly enabled lsp-project-diagnostics:\n%s",
54
+ log.String(),
55
+ )
56
+ }
57
+ if !strings.Contains(log.String(), diagnosticsOnly.Name) {
58
+ t.Fatalf(
59
+ "projectDiagnostics did not enable its direct command:\n%s",
60
+ log.String(),
61
+ )
62
+ }
63
+ if got == nil || len(got.Diagnostics) != 1 ||
64
+ got.Diagnostics[0].Code != "last-good" {
65
+ t.Fatalf("failed capable producer lost its last-good publication: %#v", got)
66
+ }
67
+ }
@@ -0,0 +1,78 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ // TestLSPProjectDiagnosticsProgramInputOverlapRefreshesAllProducers verifies a
11
+ // declared input that can also belong to the Program widens diagnostic scope.
12
+ //
13
+ // Only the first producer declares the path, but both project-rule producers
14
+ // observe the shared Program. TypeScript and resolveJsonModule JSON edits must
15
+ // therefore refresh both, while a path that can only be data keeps the
16
+ // owner-scoped behavior pinned by
17
+ // lsp_project_diagnostics_refreshes_only_input_owners_test.go.
18
+ //
19
+ // 1. Declare one shared-Program path for the first producer only.
20
+ // 2. Resolve its owner scope and widen it as a watched Program input.
21
+ // 3. Assert the scope became all-producer.
22
+ // 4. Refresh and assert both producers were invoked.
23
+ func TestLSPProjectDiagnosticsProgramInputOverlapRefreshesAllProducers(
24
+ t *testing.T,
25
+ ) {
26
+ for _, name := range []string{"shared.ts", "shared.json"} {
27
+ t.Run(name, func(t *testing.T) {
28
+ root := t.TempDir()
29
+ first := NativeLSPPluginEntry{
30
+ Binary: "ttsc-no-such-first-program-sidecar",
31
+ Name: "@ttsc/first-program",
32
+ ProjectDiagnostics: true,
33
+ ProjectInputs: true,
34
+ }
35
+ second := NativeLSPPluginEntry{
36
+ Binary: "ttsc-no-such-second-program-sidecar",
37
+ Name: "@ttsc/second-program",
38
+ ProjectDiagnostics: true,
39
+ ProjectInputs: true,
40
+ }
41
+ var log bytes.Buffer
42
+ source := &NativePluginSource{
43
+ err: &log,
44
+ plugins: []NativeLSPPluginEntry{first, second},
45
+ }
46
+ input := filepath.Join(root, "src", name)
47
+ source.storeProjectInputs(first, 1, LSPProjectInputSnapshot{
48
+ Root: filepath.ToSlash(root),
49
+ Files: []string{filepath.ToSlash(input)},
50
+ })
51
+ source.storeProjectInputs(second, 1, LSPProjectInputSnapshot{
52
+ Root: filepath.ToSlash(root),
53
+ Files: []string{
54
+ filepath.ToSlash(filepath.Join(root, "docs", "second.md")),
55
+ },
56
+ })
57
+ uri := testFileURI(input)
58
+ scope, matched := (&Proxy{source: source}).projectInputOwnerScope(uri)
59
+ if !matched || scope.all || len(scope.owners) != 1 {
60
+ t.Fatalf("declared owner scope = %#v, matched %v", scope, matched)
61
+ }
62
+ scope = projectDiagnosticScopeForWatchedInput(uri, scope)
63
+ if !scope.all {
64
+ t.Fatalf("Program input scope remained owner-only: %#v", scope)
65
+ }
66
+
67
+ result := source.ProjectDiagnosticsForOwners(nil)
68
+
69
+ if result.selected != 2 || result.complete {
70
+ t.Fatalf("all-producer failed refresh = %#v", result)
71
+ }
72
+ if !strings.Contains(log.String(), first.Name) ||
73
+ !strings.Contains(log.String(), second.Name) {
74
+ t.Fatalf("Program input did not invoke both producers:\n%s", log.String())
75
+ }
76
+ })
77
+ }
78
+ }
@@ -0,0 +1,65 @@
1
+ package lspserver
2
+
3
+ import "testing"
4
+
5
+ // TestProjectDiagnosticsRefreshAccumulatesOwnerFreshness verifies separate
6
+ // successful refreshes can jointly satisfy one affected-producer generation.
7
+ func TestProjectDiagnosticsRefreshAccumulatesOwnerFreshness(t *testing.T) {
8
+ proxy := &Proxy{}
9
+ proxy.projectDiagnosticRefreshPending = true
10
+ proxy.pendingProjectDiagnosticGeneration = 2
11
+ proxy.pendingProjectDiagnosticOwners = map[string]struct{}{
12
+ "alpha": {},
13
+ "beta": {},
14
+ }
15
+
16
+ if proxy.recordPendingProjectDiagnosticOwnersRefreshed(
17
+ 1,
18
+ map[string]struct{}{"beta": {}},
19
+ ) {
20
+ t.Fatal("stale producer refresh completed the newer generation")
21
+ }
22
+ if _, pending := proxy.pendingProjectDiagnosticOwners["beta"]; !pending {
23
+ t.Fatal("stale producer refresh removed a newer pending owner")
24
+ }
25
+ if proxy.recordPendingProjectDiagnosticOwnersRefreshed(
26
+ 2,
27
+ map[string]struct{}{"alpha": {}},
28
+ ) {
29
+ t.Fatal("one producer completed a two-producer refresh")
30
+ }
31
+ if _, pending := proxy.pendingProjectDiagnosticOwners["alpha"]; pending {
32
+ t.Fatal("successful producer remained pending")
33
+ }
34
+ if _, pending := proxy.pendingProjectDiagnosticOwners["beta"]; !pending {
35
+ t.Fatal("failed producer was removed from pending scope")
36
+ }
37
+ if !proxy.recordPendingProjectDiagnosticOwnersRefreshed(
38
+ 2,
39
+ map[string]struct{}{"beta": {}},
40
+ ) {
41
+ t.Fatal("separate producer successes did not complete the generation")
42
+ }
43
+
44
+ // A newer watched event can arrive after record reports completion but
45
+ // before its caller clears the old generation. The newer scope must survive
46
+ // that rejected completion unchanged.
47
+ proxy.projectDiagnosticGeneration = 3
48
+ proxy.pendingProjectDiagnosticGeneration = 3
49
+ proxy.projectDiagnosticRefreshPending = true
50
+ proxy.pendingProjectDiagnosticOwners = map[string]struct{}{"alpha": {}}
51
+ proxy.completePendingProjectDiagnosticRefresh(2)
52
+ proxy.projectRefreshMu.Lock()
53
+ defer proxy.projectRefreshMu.Unlock()
54
+ if !proxy.projectDiagnosticRefreshPending ||
55
+ proxy.pendingProjectDiagnosticGeneration != 3 {
56
+ t.Fatalf(
57
+ "newer pending generation was cleared: pending %v, generation %d",
58
+ proxy.projectDiagnosticRefreshPending,
59
+ proxy.pendingProjectDiagnosticGeneration,
60
+ )
61
+ }
62
+ if _, pending := proxy.pendingProjectDiagnosticOwners["alpha"]; !pending {
63
+ t.Fatal("newer pending owner was cleared by the prior generation")
64
+ }
65
+ }
@@ -0,0 +1,90 @@
1
+ package lspserver
2
+
3
+ import "testing"
4
+
5
+ // TestLSPProjectDiagnosticsRefreshKeepsEachProducerLastGood verifies partial
6
+ // refreshes update only the producers that answered successfully.
7
+ //
8
+ // Project diagnostics are one merged config-URI publication, but their
9
+ // sidecars fail independently. Rebuilding that publication from only the
10
+ // current call's successes would erase a failed producer's prior findings. An
11
+ // empty successful answer is different: it deliberately clears that producer.
12
+ //
13
+ // 1. Seed two producers with successful publications.
14
+ // 2. Refresh only the first and assert the second producer's last-good remains.
15
+ // 3. Clear the second with a successful empty publication.
16
+ // 4. Recover the second and assert manifest-order aggregation is restored.
17
+ func TestLSPProjectDiagnosticsRefreshKeepsEachProducerLastGood(t *testing.T) {
18
+ first := NativeLSPPluginEntry{Binary: "first", Name: "@ttsc/first"}
19
+ second := NativeLSPPluginEntry{Binary: "second", Name: "@ttsc/second"}
20
+ source := &NativePluginSource{
21
+ plugins: []NativeLSPPluginEntry{first, second},
22
+ }
23
+ publication := func(codes ...string) *LSPProjectDiagnostics {
24
+ diagnostics := make([]LSPDiagnostic, 0, len(codes))
25
+ for _, code := range codes {
26
+ diagnostics = append(diagnostics, LSPDiagnostic{
27
+ Code: code,
28
+ Message: code,
29
+ })
30
+ }
31
+ return &LSPProjectDiagnostics{
32
+ URI: "file:///project/tsconfig.json",
33
+ Diagnostics: diagnostics,
34
+ }
35
+ }
36
+ codes := func(got *LSPProjectDiagnostics) []string {
37
+ if got == nil {
38
+ return nil
39
+ }
40
+ out := make([]string, 0, len(got.Diagnostics))
41
+ for _, diagnostic := range got.Diagnostics {
42
+ out = append(out, diagnostic.Code.(string))
43
+ }
44
+ return out
45
+ }
46
+ assertCodes := func(label string, got *LSPProjectDiagnostics, want ...string) {
47
+ t.Helper()
48
+ actual := codes(got)
49
+ if len(actual) != len(want) {
50
+ t.Fatalf("%s codes = %v, want %v", label, actual, want)
51
+ }
52
+ for index := range want {
53
+ if actual[index] != want[index] {
54
+ t.Fatalf("%s codes = %v, want %v", label, actual, want)
55
+ }
56
+ }
57
+ }
58
+
59
+ source.storeProjectDiagnostics(first, 1, publication("first-old"))
60
+ source.storeProjectDiagnostics(second, 1, publication("second-old"))
61
+ assertCodes(
62
+ "initial",
63
+ source.projectDiagnosticsSnapshot(),
64
+ "first-old",
65
+ "second-old",
66
+ )
67
+
68
+ source.storeProjectDiagnostics(first, 2, publication("first-new"))
69
+ assertCodes(
70
+ "partial failure",
71
+ source.projectDiagnosticsSnapshot(),
72
+ "first-new",
73
+ "second-old",
74
+ )
75
+
76
+ source.storeProjectDiagnostics(second, 3, publication())
77
+ assertCodes(
78
+ "successful clear",
79
+ source.projectDiagnosticsSnapshot(),
80
+ "first-new",
81
+ )
82
+
83
+ source.storeProjectDiagnostics(second, 4, publication("second-new"))
84
+ assertCodes(
85
+ "recovery",
86
+ source.projectDiagnosticsSnapshot(),
87
+ "first-new",
88
+ "second-new",
89
+ )
90
+ }
@@ -0,0 +1,62 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ // TestLSPProjectDiagnosticsRefreshesOnlyInputOwners verifies flattened client
11
+ // watch registration does not erase contributor ownership.
12
+ //
13
+ // 1. Give two diagnostics-capable producers disjoint exact input snapshots.
14
+ // 2. Resolve the first input's owner from the merged source.
15
+ // 3. Refresh that owner and assert the unrelated producer is never invoked.
16
+ func TestLSPProjectDiagnosticsRefreshesOnlyInputOwners(t *testing.T) {
17
+ root := t.TempDir()
18
+ first := NativeLSPPluginEntry{
19
+ Binary: "ttsc-no-such-first-owner-sidecar",
20
+ Name: "@ttsc/first",
21
+ ProjectDiagnostics: true,
22
+ ProjectInputs: true,
23
+ }
24
+ second := NativeLSPPluginEntry{
25
+ Binary: "ttsc-no-such-second-owner-sidecar",
26
+ Name: "@ttsc/second",
27
+ ProjectDiagnostics: true,
28
+ ProjectInputs: true,
29
+ }
30
+ var log bytes.Buffer
31
+ source := &NativePluginSource{
32
+ err: &log,
33
+ plugins: []NativeLSPPluginEntry{first, second},
34
+ }
35
+ firstInput := filepath.Join(root, "docs", "first.md")
36
+ source.storeProjectInputs(first, 1, LSPProjectInputSnapshot{
37
+ Root: filepath.ToSlash(root),
38
+ Files: []string{filepath.ToSlash(firstInput)},
39
+ })
40
+ source.storeProjectInputs(second, 1, LSPProjectInputSnapshot{
41
+ Root: filepath.ToSlash(root),
42
+ Files: []string{
43
+ filepath.ToSlash(filepath.Join(root, "docs", "second.md")),
44
+ },
45
+ })
46
+ owners := source.ProjectInputOwnersForURI(testFileURI(firstInput))
47
+ if len(owners) != 1 || owners[0] != pluginKey(first) {
48
+ t.Fatalf("first input owners = %#v", owners)
49
+ }
50
+
51
+ result := source.ProjectDiagnosticsForOwners(owners)
52
+
53
+ if result.selected != 1 || result.complete {
54
+ t.Fatalf("scoped failed refresh = %#v", result)
55
+ }
56
+ if !strings.Contains(log.String(), first.Name) {
57
+ t.Fatalf("owned producer was not invoked:\n%s", log.String())
58
+ }
59
+ if strings.Contains(log.String(), second.Name) {
60
+ t.Fatalf("unrelated producer was invoked:\n%s", log.String())
61
+ }
62
+ }