ttsc 0.26.1 → 0.27.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 (119) hide show
  1. package/README.md +10 -1
  2. package/cmd/ttsc/api_transform.go +17 -3
  3. package/cmd/ttscgraph/auxiliary_identity_state_tracks_retargets_not_source_contents_test.go +85 -0
  4. package/cmd/ttscgraph/disk_realpath_other.go +16 -0
  5. package/cmd/ttscgraph/disk_realpath_windows.go +56 -0
  6. package/cmd/ttscgraph/serve.go +104 -35
  7. package/driver/diagnostics_preamble.go +284 -0
  8. package/driver/emit_plugin.go +71 -19
  9. package/driver/graph.go +64 -11
  10. package/driver/input_observation.go +273 -0
  11. package/driver/input_observation_test.go +149 -0
  12. package/driver/plugins.go +384 -4
  13. package/driver/program.go +81 -15
  14. package/driver/resolution_candidates.go +13 -5
  15. package/driver/rewrite.go +47 -4
  16. package/internal/graph/diagnostics.go +4 -3
  17. package/internal/graph/provenance.go +10 -1
  18. package/lib/TtscCompiler.d.ts +12 -11
  19. package/lib/TtscCompiler.js +44 -29
  20. package/lib/TtscCompiler.js.map +1 -1
  21. package/lib/compiler/internal/captureProcessOutput.js +2 -2
  22. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  23. package/lib/compiler/internal/compileProjectInMemory.js +11 -3
  24. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  25. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -0
  26. package/lib/compiler/internal/resolveEmittedJavaScript.js +3 -0
  27. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  28. package/lib/compiler/internal/runBuild.d.ts +29 -1
  29. package/lib/compiler/internal/runBuild.js +117 -32
  30. package/lib/compiler/internal/runBuild.js.map +1 -1
  31. package/lib/compiler/internal/runSingleFileEmit.js +9 -2
  32. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  33. package/lib/compiler/internal/sharedHostHelpers.d.ts +28 -0
  34. package/lib/compiler/internal/sharedHostHelpers.js +39 -0
  35. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  36. package/lib/compiler/internal/startResidentTransform.js +12 -4
  37. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  38. package/lib/compiler/internal/transformProjectInMemory.d.ts +3 -0
  39. package/lib/compiler/internal/transformProjectInMemory.js +239 -35
  40. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  41. package/lib/flags/schema.js +33 -5
  42. package/lib/flags/schema.js.map +1 -1
  43. package/lib/internal/createCanonicalTempDirectory.d.ts +17 -0
  44. package/lib/internal/createCanonicalTempDirectory.js +42 -0
  45. package/lib/internal/createCanonicalTempDirectory.js.map +1 -0
  46. package/lib/internal/projectInputPathIdentity.d.ts +3 -0
  47. package/lib/internal/projectInputPathIdentity.js +11 -5
  48. package/lib/internal/projectInputPathIdentity.js.map +1 -1
  49. package/lib/internal/resolveNodeBinary.d.ts +14 -0
  50. package/lib/internal/resolveNodeBinary.js +150 -0
  51. package/lib/internal/resolveNodeBinary.js.map +1 -0
  52. package/lib/internal/resolveSafeCacheCleanupTargets.d.ts +15 -0
  53. package/lib/internal/resolveSafeCacheCleanupTargets.js +61 -0
  54. package/lib/internal/resolveSafeCacheCleanupTargets.js.map +1 -0
  55. package/lib/launcher/internal/assertNoSolutionBuild.d.ts +31 -0
  56. package/lib/launcher/internal/assertNoSolutionBuild.js +38 -0
  57. package/lib/launcher/internal/assertNoSolutionBuild.js.map +1 -0
  58. package/lib/launcher/internal/prepareExecution.d.ts +3 -0
  59. package/lib/launcher/internal/prepareExecution.js +73 -26
  60. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  61. package/lib/launcher/internal/runTtsc.js +27 -13
  62. package/lib/launcher/internal/runTtsc.js.map +1 -1
  63. package/lib/launcher/internal/runTtscserver.js +18 -3
  64. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  65. package/lib/launcher/internal/runTtsx.js +4 -0
  66. package/lib/launcher/internal/runTtsx.js.map +1 -1
  67. package/lib/launcher/internal/runtimeHooks.d.ts +37 -1
  68. package/lib/launcher/internal/runtimeHooks.js +660 -62
  69. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  70. package/lib/plugin/internal/buildSourcePlugin.d.ts +62 -8
  71. package/lib/plugin/internal/buildSourcePlugin.js +754 -72
  72. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  73. package/lib/plugin/internal/loadProjectPlugins.d.ts +17 -5
  74. package/lib/plugin/internal/loadProjectPlugins.js +1339 -77
  75. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  76. package/lib/register.d.ts +1 -0
  77. package/lib/register.js +46 -0
  78. package/lib/register.js.map +1 -0
  79. package/lib/structures/ITtscCompilerContext.d.ts +6 -3
  80. package/lib/structures/ITtscCompilerTransformation.d.ts +31 -0
  81. package/lib/structures/ITtscPlugin.d.ts +33 -0
  82. package/package.json +16 -9
  83. package/shim/checker/go.mod +9 -0
  84. package/shim/checker/go.sum +22 -0
  85. package/shim/checker/shim.go +75 -0
  86. package/shim/compiler/emit_sourcemap.go +56 -15
  87. package/shim/compiler/incremental.go +45 -8
  88. package/src/TtscCompiler.ts +55 -33
  89. package/src/compiler/internal/captureProcessOutput.ts +3 -2
  90. package/src/compiler/internal/compileProjectInMemory.ts +11 -3
  91. package/src/compiler/internal/resolveEmittedJavaScript.ts +6 -0
  92. package/src/compiler/internal/runBuild.ts +172 -36
  93. package/src/compiler/internal/runSingleFileEmit.ts +9 -2
  94. package/src/compiler/internal/sharedHostHelpers.ts +44 -0
  95. package/src/compiler/internal/startResidentTransform.ts +12 -3
  96. package/src/compiler/internal/transformProjectInMemory.ts +410 -39
  97. package/src/flags/schema.ts +35 -5
  98. package/src/internal/createCanonicalTempDirectory.ts +53 -0
  99. package/src/internal/projectInputPathIdentity.ts +17 -4
  100. package/src/internal/resolveNodeBinary.ts +173 -0
  101. package/src/internal/resolveSafeCacheCleanupTargets.ts +85 -0
  102. package/src/launcher/internal/assertNoSolutionBuild.ts +40 -0
  103. package/src/launcher/internal/prepareExecution.ts +78 -26
  104. package/src/launcher/internal/runTtsc.ts +26 -13
  105. package/src/launcher/internal/runTtscserver.ts +18 -3
  106. package/src/launcher/internal/runTtsx.ts +4 -0
  107. package/src/launcher/internal/runtimeHooks.ts +830 -66
  108. package/src/plugin/internal/buildSourcePlugin.ts +984 -79
  109. package/src/plugin/internal/loadProjectPlugins.ts +1718 -104
  110. package/src/register.ts +47 -0
  111. package/src/structures/ITtscCompilerContext.ts +6 -3
  112. package/src/structures/ITtscCompilerTransformation.ts +39 -0
  113. package/src/structures/ITtscPlugin.ts +36 -0
  114. package/tools/shim_audit/baseline.json +0 -1
  115. package/utility/host.go +9 -3
  116. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +0 -5
  117. package/lib/internal/assertSafeExplicitCacheDirectory.js +0 -26
  118. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +0 -1
  119. package/src/internal/assertSafeExplicitCacheDirectory.ts +0 -29
package/driver/plugins.go CHANGED
@@ -5,7 +5,9 @@ import (
5
5
  "fmt"
6
6
  "os"
7
7
  "path/filepath"
8
+ "sort"
8
9
  "strings"
10
+ "sync"
9
11
  )
10
12
 
11
13
  // LinkedPluginsEnv is the environment variable ttsc sets to pass the JSON
@@ -27,6 +29,44 @@ const LinkedPluginsEnv = "TTSC_LINKED_PLUGINS_JSON"
27
29
  // still receive it.
28
30
  const PluginConfigDirEnv = "TTSC_PLUGIN_CONFIG_DIR"
29
31
 
32
+ // TsgoArgsEnv is the environment variable through which the ttsc launcher
33
+ // passes the tsgo CLI flags it did not consume itself (`--strict`,
34
+ // `--declaration`, the single-file lane's output containment, …) to a native
35
+ // sidecar. The value is a JSON array of argv tokens; an empty or absent value
36
+ // means nothing was forwarded.
37
+ //
38
+ // It rides the environment for the same reason PluginConfigDirEnv does, only
39
+ // with sharper consequences. The payload used to travel as a `--tsgo-args`
40
+ // flag, which #113 appended to a plugin protocol third-party hosts had already
41
+ // frozen. A Go `flag.FlagSet` created with `flag.ContinueOnError` treats an
42
+ // undeclared flag as fatal, so every sidecar built before #113 answered
43
+ // `flag provided but not defined: -tsgo-args` and exited 2 — including on
44
+ // `ttsc <file.ts>`, where the launcher forwards its own output-containment
45
+ // flags and the user passed nothing at all (issue #1188). An unknown
46
+ // environment variable is inert to every host; an unknown flag is fatal to all
47
+ // of them. ttsc's own hosts still accept `--tsgo-args` so an older launcher
48
+ // paired with a newer host keeps working.
49
+ const TsgoArgsEnv = "TTSC_TSGO_ARGS"
50
+
51
+ // TsgoArgsFromEnv decodes the forwarded tsgo argv the launcher published in
52
+ // TsgoArgsEnv. An absent or whitespace-only value yields a nil slice and no
53
+ // error, so a host can call this unconditionally.
54
+ //
55
+ // Hosts that also declare a `--tsgo-args` flag should prefer the explicit flag
56
+ // value and fall back to this; LoadProgram already does that for every
57
+ // driver-based host.
58
+ func TsgoArgsFromEnv() ([]string, error) {
59
+ raw := strings.TrimSpace(os.Getenv(TsgoArgsEnv))
60
+ if raw == "" {
61
+ return nil, nil
62
+ }
63
+ var args []string
64
+ if err := json.Unmarshal([]byte(raw), &args); err != nil {
65
+ return nil, fmt.Errorf("ttsc driver: invalid %s: %w", TsgoArgsEnv, err)
66
+ }
67
+ return args, nil
68
+ }
69
+
30
70
  // PluginConfigBaseDir returns the directory where a plugin anchors its
31
71
  // config-file discovery walk and resolves relative "configFile" paths.
32
72
  // The explicit PluginConfigDirEnv channel wins when set; otherwise the
@@ -60,6 +100,90 @@ type PluginContext struct {
60
100
  Cwd string
61
101
  Entry PluginEntry
62
102
  Tsconfig string
103
+
104
+ reportHostInput func(string)
105
+ reportHostInputHash func(string, *string)
106
+ reportHostInputHashUnknown func(string)
107
+ reportHostInputRealpath func(string, *string)
108
+ reportHostInputRealpathUnknown func(string)
109
+ }
110
+
111
+ // ReportHostInputHash declares the exact file state consumed by a native
112
+ // plugin. hash is a lowercase SHA-256 digest for an observed file and nil for
113
+ // a missing candidate. Conflicting observations are retained as host inputs
114
+ // but omitted from PluginHostInputHashes, forcing persistent adapters to
115
+ // decline narrow reuse without failing the transform.
116
+ func (ctx PluginContext) ReportHostInputHash(file string, hash *string) {
117
+ if ctx.reportHostInputHash == nil || strings.TrimSpace(file) == "" {
118
+ return
119
+ }
120
+ if !filepath.IsAbs(file) {
121
+ file = filepath.Join(ctx.Cwd, file)
122
+ }
123
+ file = filepath.Clean(file)
124
+ if hash != nil && !isLowerSHA256(*hash) {
125
+ if ctx.reportHostInputHashUnknown != nil {
126
+ ctx.reportHostInputHashUnknown(file)
127
+ } else if ctx.reportHostInput != nil {
128
+ ctx.reportHostInput(file)
129
+ }
130
+ return
131
+ }
132
+ ctx.reportHostInputHash(file, hash)
133
+ }
134
+
135
+ // ReportHostInputRealpath declares the physical path resolved while a native
136
+ // plugin consumed file. realpath is absolute for an observed path and nil for
137
+ // a missing candidate. Conflicting observations remain host inputs but are
138
+ // omitted from PluginHostInputRealpaths so adapters cannot attach an earlier
139
+ // result to a retargeted symlink or junction.
140
+ func (ctx PluginContext) ReportHostInputRealpath(file string, realpath *string) {
141
+ if ctx.reportHostInputRealpath == nil || strings.TrimSpace(file) == "" {
142
+ return
143
+ }
144
+ if !filepath.IsAbs(file) {
145
+ file = filepath.Join(ctx.Cwd, file)
146
+ }
147
+ file = filepath.Clean(file)
148
+ if realpath != nil {
149
+ if strings.TrimSpace(*realpath) == "" || !filepath.IsAbs(*realpath) {
150
+ if ctx.reportHostInputRealpathUnknown != nil {
151
+ ctx.reportHostInputRealpathUnknown(file)
152
+ } else if ctx.reportHostInput != nil {
153
+ ctx.reportHostInput(file)
154
+ }
155
+ return
156
+ }
157
+ resolved := filepath.Clean(*realpath)
158
+ realpath = &resolved
159
+ }
160
+ ctx.reportHostInputRealpath(file, realpath)
161
+ }
162
+
163
+ func isLowerSHA256(value string) bool {
164
+ if len(value) != 64 {
165
+ return false
166
+ }
167
+ for _, char := range value {
168
+ if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
169
+ return false
170
+ }
171
+ }
172
+ return true
173
+ }
174
+
175
+ // ReportHostInput declares an absolute file whose content or presence was
176
+ // consumed while the native plugin evaluated configuration. Native transform
177
+ // envelopes expose the generation-wide union so persistent hosts can invalidate
178
+ // without re-evaluating plugin config on the JavaScript side.
179
+ func (ctx PluginContext) ReportHostInput(file string) {
180
+ if ctx.reportHostInput == nil || strings.TrimSpace(file) == "" {
181
+ return
182
+ }
183
+ if !filepath.IsAbs(file) {
184
+ file = filepath.Join(ctx.Cwd, file)
185
+ }
186
+ ctx.reportHostInput(filepath.Clean(file))
63
187
  }
64
188
 
65
189
  // SourcePreamblePlugin can inject source text before TypeScript-Go parses the
@@ -92,9 +216,244 @@ type EmitTransformPlugin interface {
92
216
  type linkedPluginState struct {
93
217
  cwd string
94
218
  entries []PluginEntry
219
+ inputs *pluginHostInputScopes
95
220
  tsconfig string
96
221
  }
97
222
 
223
+ // pluginHostInputScopes keeps one observation set per plugin hook. A hash from
224
+ // one hook cannot prove another hook's same-file dependency when that hook only
225
+ // reported the path, while ReportHostInput followed by ReportHostInputHash in
226
+ // one hook remains one complete observation.
227
+ type pluginHostInputScopes struct {
228
+ mu sync.Mutex
229
+ scopes []*pluginHostInputs
230
+ }
231
+
232
+ type pluginHostInputs struct {
233
+ files map[string]struct{}
234
+ hashes map[string]pluginHostInputHash
235
+ realpaths map[string]pluginHostInputHash
236
+ mu sync.Mutex
237
+ }
238
+
239
+ type pluginHostInputHash struct {
240
+ hash *string
241
+ known bool
242
+ }
243
+
244
+ func newPluginHostInputScopes() *pluginHostInputScopes {
245
+ return &pluginHostInputScopes{}
246
+ }
247
+
248
+ func newPluginHostInputs() *pluginHostInputs {
249
+ return &pluginHostInputs{
250
+ files: map[string]struct{}{},
251
+ hashes: map[string]pluginHostInputHash{},
252
+ realpaths: map[string]pluginHostInputHash{},
253
+ }
254
+ }
255
+
256
+ func (inputs *pluginHostInputScopes) newScope() *pluginHostInputs {
257
+ scope := newPluginHostInputs()
258
+ if inputs == nil {
259
+ return scope
260
+ }
261
+ inputs.mu.Lock()
262
+ inputs.scopes = append(inputs.scopes, scope)
263
+ inputs.mu.Unlock()
264
+ return scope
265
+ }
266
+
267
+ func (inputs *pluginHostInputScopes) snapshot() []*pluginHostInputs {
268
+ if inputs == nil {
269
+ return nil
270
+ }
271
+ inputs.mu.Lock()
272
+ defer inputs.mu.Unlock()
273
+ return append([]*pluginHostInputs(nil), inputs.scopes...)
274
+ }
275
+
276
+ func (inputs *pluginHostInputs) addHash(file string, hash *string) {
277
+ if inputs == nil {
278
+ return
279
+ }
280
+ inputs.mu.Lock()
281
+ defer inputs.mu.Unlock()
282
+ inputs.files[file] = struct{}{}
283
+ previous, exists := inputs.hashes[file]
284
+ if !exists {
285
+ inputs.hashes[file] = pluginHostInputHash{hash: cloneStringPointer(hash), known: true}
286
+ return
287
+ }
288
+ if !previous.known || !sameStringPointer(previous.hash, hash) {
289
+ inputs.hashes[file] = pluginHostInputHash{known: false}
290
+ }
291
+ }
292
+
293
+ func (inputs *pluginHostInputs) invalidateHash(file string) {
294
+ if inputs == nil {
295
+ return
296
+ }
297
+ inputs.mu.Lock()
298
+ defer inputs.mu.Unlock()
299
+ inputs.files[file] = struct{}{}
300
+ inputs.hashes[file] = pluginHostInputHash{known: false}
301
+ }
302
+
303
+ func (inputs *pluginHostInputs) addRealpath(file string, realpath *string) {
304
+ if inputs == nil {
305
+ return
306
+ }
307
+ inputs.mu.Lock()
308
+ defer inputs.mu.Unlock()
309
+ inputs.files[file] = struct{}{}
310
+ previous, exists := inputs.realpaths[file]
311
+ if !exists {
312
+ inputs.realpaths[file] = pluginHostInputHash{hash: cloneStringPointer(realpath), known: true}
313
+ return
314
+ }
315
+ if !previous.known || !sameStringPointer(previous.hash, realpath) {
316
+ inputs.realpaths[file] = pluginHostInputHash{known: false}
317
+ }
318
+ }
319
+
320
+ func (inputs *pluginHostInputs) invalidateRealpath(file string) {
321
+ if inputs == nil {
322
+ return
323
+ }
324
+ inputs.mu.Lock()
325
+ defer inputs.mu.Unlock()
326
+ inputs.files[file] = struct{}{}
327
+ inputs.realpaths[file] = pluginHostInputHash{known: false}
328
+ }
329
+
330
+ func cloneStringPointer(value *string) *string {
331
+ if value == nil {
332
+ return nil
333
+ }
334
+ cloned := *value
335
+ return &cloned
336
+ }
337
+
338
+ func sameStringPointer(left, right *string) bool {
339
+ if left == nil || right == nil {
340
+ return left == nil && right == nil
341
+ }
342
+ return *left == *right
343
+ }
344
+
345
+ func (inputs *pluginHostInputs) add(file string) {
346
+ if inputs == nil {
347
+ return
348
+ }
349
+ inputs.mu.Lock()
350
+ inputs.files[file] = struct{}{}
351
+ inputs.mu.Unlock()
352
+ }
353
+
354
+ func (inputs *pluginHostInputs) snapshot() (map[string]struct{}, map[string]pluginHostInputHash, map[string]pluginHostInputHash) {
355
+ if inputs == nil {
356
+ return nil, nil, nil
357
+ }
358
+ inputs.mu.Lock()
359
+ defer inputs.mu.Unlock()
360
+ files := make(map[string]struct{}, len(inputs.files))
361
+ for file := range inputs.files {
362
+ files[file] = struct{}{}
363
+ }
364
+ hashes := make(map[string]pluginHostInputHash, len(inputs.hashes))
365
+ for file, observation := range inputs.hashes {
366
+ hashes[file] = pluginHostInputHash{
367
+ hash: cloneStringPointer(observation.hash),
368
+ known: observation.known,
369
+ }
370
+ }
371
+ realpaths := make(map[string]pluginHostInputHash, len(inputs.realpaths))
372
+ for file, observation := range inputs.realpaths {
373
+ realpaths[file] = pluginHostInputHash{
374
+ hash: cloneStringPointer(observation.hash),
375
+ known: observation.known,
376
+ }
377
+ }
378
+ return files, hashes, realpaths
379
+ }
380
+
381
+ func (inputs *pluginHostInputScopes) list() []string {
382
+ union := map[string]struct{}{}
383
+ for _, scope := range inputs.snapshot() {
384
+ files, _, _ := scope.snapshot()
385
+ for file := range files {
386
+ union[file] = struct{}{}
387
+ }
388
+ }
389
+ files := make([]string, 0, len(union))
390
+ for file := range union {
391
+ files = append(files, file)
392
+ }
393
+ sort.Strings(files)
394
+ return files
395
+ }
396
+
397
+ func (inputs *pluginHostInputScopes) hashList() map[string]*string {
398
+ combined := map[string]pluginHostInputHash{}
399
+ for _, scope := range inputs.snapshot() {
400
+ files, hashes, _ := scope.snapshot()
401
+ for file := range files {
402
+ observation, exists := hashes[file]
403
+ if !exists || !observation.known {
404
+ combined[file] = pluginHostInputHash{known: false}
405
+ continue
406
+ }
407
+ previous, exists := combined[file]
408
+ if !exists {
409
+ combined[file] = observation
410
+ } else if !previous.known || !sameStringPointer(previous.hash, observation.hash) {
411
+ combined[file] = pluginHostInputHash{known: false}
412
+ }
413
+ }
414
+ }
415
+ hashes := map[string]*string{}
416
+ for file, observation := range combined {
417
+ if observation.known {
418
+ hashes[file] = cloneStringPointer(observation.hash)
419
+ }
420
+ }
421
+ if len(hashes) == 0 {
422
+ return nil
423
+ }
424
+ return hashes
425
+ }
426
+
427
+ func (inputs *pluginHostInputScopes) realpathList() map[string]*string {
428
+ combined := map[string]pluginHostInputHash{}
429
+ for _, scope := range inputs.snapshot() {
430
+ files, _, realpaths := scope.snapshot()
431
+ for file := range files {
432
+ observation, exists := realpaths[file]
433
+ if !exists || !observation.known {
434
+ combined[file] = pluginHostInputHash{known: false}
435
+ continue
436
+ }
437
+ previous, exists := combined[file]
438
+ if !exists {
439
+ combined[file] = observation
440
+ } else if !previous.known || !sameStringPointer(previous.hash, observation.hash) {
441
+ combined[file] = pluginHostInputHash{known: false}
442
+ }
443
+ }
444
+ }
445
+ realpaths := map[string]*string{}
446
+ for file, observation := range combined {
447
+ if observation.known {
448
+ realpaths[file] = cloneStringPointer(observation.hash)
449
+ }
450
+ }
451
+ if len(realpaths) == 0 {
452
+ return nil
453
+ }
454
+ return realpaths
455
+ }
456
+
98
457
  var pluginRegistry []any
99
458
 
100
459
  // RegisterPlugin registers a driver-level plugin implementation. Linked Go
@@ -113,7 +472,7 @@ func RegisterPlugin(plugin any) {
113
472
  func loadLinkedPluginState(cwd, tsconfigPath string) (linkedPluginState, error) {
114
473
  input := strings.TrimSpace(os.Getenv(LinkedPluginsEnv))
115
474
  if input == "" {
116
- return linkedPluginState{cwd: cwd, tsconfig: tsconfigPath}, nil
475
+ return linkedPluginState{cwd: cwd, inputs: newPluginHostInputScopes(), tsconfig: tsconfigPath}, nil
117
476
  }
118
477
  var entries []PluginEntry
119
478
  if err := json.Unmarshal([]byte(input), &entries); err != nil {
@@ -122,6 +481,7 @@ func loadLinkedPluginState(cwd, tsconfigPath string) (linkedPluginState, error)
122
481
  return linkedPluginState{
123
482
  cwd: cwd,
124
483
  entries: entries,
484
+ inputs: newPluginHostInputScopes(),
125
485
  tsconfig: tsconfigPath,
126
486
  }, nil
127
487
  }
@@ -218,9 +578,29 @@ func registeredPlugin(index int) (any, bool) {
218
578
 
219
579
  // context builds the PluginContext the driver passes to each plugin hook.
220
580
  func (state linkedPluginState) context(entry PluginEntry) PluginContext {
581
+ inputs := state.inputs.newScope()
221
582
  return PluginContext{
222
- Cwd: state.cwd,
223
- Entry: entry,
224
- Tsconfig: state.tsconfig,
583
+ Cwd: state.cwd,
584
+ Entry: entry,
585
+ Tsconfig: state.tsconfig,
586
+ reportHostInput: inputs.add,
587
+ reportHostInputHash: inputs.addHash,
588
+ reportHostInputHashUnknown: inputs.invalidateHash,
589
+ reportHostInputRealpath: inputs.addRealpath,
590
+ reportHostInputRealpathUnknown: inputs.invalidateRealpath,
225
591
  }
226
592
  }
593
+
594
+ // hostInputs returns the exact native configuration inputs reported in this
595
+ // generation.
596
+ func (state linkedPluginState) hostInputs() []string {
597
+ return state.inputs.list()
598
+ }
599
+
600
+ func (state linkedPluginState) hostInputHashes() map[string]*string {
601
+ return state.inputs.hashList()
602
+ }
603
+
604
+ func (state linkedPluginState) hostInputRealpaths() map[string]*string {
605
+ return state.inputs.realpathList()
606
+ }
package/driver/program.go CHANGED
@@ -197,6 +197,7 @@ type Program struct {
197
197
  checkerRelease func()
198
198
  Host shimcompiler.CompilerHost
199
199
  FS vfs.FS
200
+ inputObserver *inputObservationFS
200
201
  SourcePreamble string
201
202
  plugins linkedPluginState
202
203
  pluginsApplied bool
@@ -263,6 +264,23 @@ func ParseTSConfig(fs vfs.FS, cwd, tsconfigPath string, host shimcompiler.Compil
263
264
  return parsed, nil, nil
264
265
  }
265
266
 
267
+ // resolveTsgoArgs picks the forwarded tsgo argv for this load: the caller's
268
+ // explicit LoadProgramOptions.TsgoArgs when it has one, otherwise whatever the
269
+ // launcher published in TsgoArgsEnv.
270
+ //
271
+ // The explicit value wins so a host that declares its own `--tsgo-args` flag
272
+ // (cmd/ttsc, the utility host, @ttsc/lint) keeps deciding for itself, and an
273
+ // embedder that deliberately passes an empty argv is not overridden by an
274
+ // environment variable an ancestor ttsc process happened to set. The fallback
275
+ // is what carries the payload into a third-party sidecar whose flag set does
276
+ // not declare `--tsgo-args` at all — see TsgoArgsEnv and issue #1188.
277
+ func resolveTsgoArgs(explicit []string) ([]string, error) {
278
+ if len(explicit) > 0 {
279
+ return explicit, nil
280
+ }
281
+ return TsgoArgsFromEnv()
282
+ }
283
+
266
284
  // parseTsgoArgs runs forwarded tsgo CLI flags through TypeScript-Go's own
267
285
  // command-line parser, yielding a CompilerOptions overlay ParseTSConfig merges
268
286
  // over the tsconfig. This is how a plugin build — which constructs its Program
@@ -357,6 +375,8 @@ func LoadProgram(cwd, tsconfigPath string, options LoadProgramOptions) (*Program
357
375
  if fs == nil {
358
376
  fs = DefaultFS()
359
377
  }
378
+ inputObserver := newInputObservationFS(fs)
379
+ fs = inputObserver
360
380
  if options.SourcePreamble != "" {
361
381
  fs = sourcePreambleFS{
362
382
  FS: fs,
@@ -365,7 +385,11 @@ func LoadProgram(cwd, tsconfigPath string, options LoadProgramOptions) (*Program
365
385
  }
366
386
  host := DefaultHost(cwd, fs)
367
387
 
368
- cliOptions, cliDiags, err := parseTsgoArgs(options.TsgoArgs, host)
388
+ tsgoArgs, err := resolveTsgoArgs(options.TsgoArgs)
389
+ if err != nil {
390
+ return nil, nil, err
391
+ }
392
+ cliOptions, cliDiags, err := parseTsgoArgs(tsgoArgs, host)
369
393
  if err != nil {
370
394
  return nil, nil, err
371
395
  }
@@ -401,6 +425,7 @@ func LoadProgram(cwd, tsconfigPath string, options LoadProgramOptions) (*Program
401
425
  checkerRelease: done,
402
426
  Host: host,
403
427
  FS: fs,
428
+ inputObserver: inputObserver,
404
429
  SourcePreamble: options.SourcePreamble,
405
430
  }
406
431
  prog.plugins = pluginState
@@ -563,6 +588,34 @@ func (p *Program) HasLinkedProgramPlugins() bool {
563
588
  return p != nil && p.plugins.hasProgramPlugins()
564
589
  }
565
590
 
591
+ // PluginHostInputs returns the generation-wide native configuration files
592
+ // reported by linked plugins while this Program was loaded or transformed.
593
+ func (p *Program) PluginHostInputs() []string {
594
+ if p == nil {
595
+ return nil
596
+ }
597
+ return p.plugins.hostInputs()
598
+ }
599
+
600
+ // PluginHostInputHashes returns evaluation-time fingerprints for the subset of
601
+ // native host inputs whose exact state plugins reported without conflict.
602
+ func (p *Program) PluginHostInputHashes() map[string]*string {
603
+ if p == nil {
604
+ return nil
605
+ }
606
+ return p.plugins.hostInputHashes()
607
+ }
608
+
609
+ // PluginHostInputRealpaths returns evaluation-time physical identities for the
610
+ // subset of native host inputs whose symlink or junction target was observed
611
+ // without conflict.
612
+ func (p *Program) PluginHostInputRealpaths() map[string]*string {
613
+ if p == nil {
614
+ return nil
615
+ }
616
+ return p.plugins.hostInputRealpaths()
617
+ }
618
+
566
619
  // Diagnostics returns project diagnostics that must block compilation or
567
620
  // runtime execution before any JavaScript is emitted or evaluated.
568
621
  func (p *Program) Diagnostics() []Diagnostic {
@@ -638,7 +691,11 @@ func (p *Program) diagnostics(files []*ast.SourceFile) []Diagnostic {
638
691
  }
639
692
  }
640
693
  raw = filterDiagnostics(raw)
641
- return append(out, convertDiagnostics(shimcompiler.SortAndDeduplicateDiagnostics(raw))...)
694
+ // filterDiagnostics runs first because it resolves a node from the position
695
+ // tsgo recorded, which only makes sense against the tree tsgo parsed. The
696
+ // preamble correction happens inside convertProgramDiagnostics, once the
697
+ // positions are no longer used to look anything up.
698
+ return append(out, p.convertProgramDiagnostics(shimcompiler.SortAndDeduplicateDiagnostics(raw))...)
642
699
  }
643
700
 
644
701
  // filterDiagnostics removes diagnostics that are false positives in ttsc's
@@ -684,25 +741,34 @@ func isUnusedOverloadSignatureTypeParameterDiagnostic(d *ast.Diagnostic) bool {
684
741
  // convertDiagnostics translates shim-specific diagnostics into the plain
685
742
  // Diagnostic struct with line/column populated via tsgo's ECMALineMap (the
686
743
  // same helper tsc uses for its "file:line:col: message" banner).
744
+ //
745
+ // Diagnostics produced by a Program go through convertProgramDiagnostics
746
+ // instead, which undoes a source preamble's position shift first.
687
747
  func convertDiagnostics(in []*ast.Diagnostic) []Diagnostic {
688
748
  out := make([]Diagnostic, 0, len(in))
689
749
  for _, d := range in {
690
750
  if d == nil {
691
751
  continue
692
752
  }
693
- diag := Diagnostic{Code: d.Code(), Message: d.String(), raw: d}
694
- if file := d.File(); file != nil {
695
- diag.File = file.FileName()
696
- if pos := d.Pos(); pos >= 0 {
697
- length := d.Len()
698
- diag.Start = &pos
699
- diag.Length = &length
700
- line, col := shimscanner.GetECMALineAndByteOffsetOfPosition(file, pos)
701
- diag.Line = line + 1
702
- diag.Column = col + 1
703
- }
704
- }
705
- out = append(out, diag)
753
+ out = append(out, convertDiagnostic(d))
706
754
  }
707
755
  return out
708
756
  }
757
+
758
+ // convertDiagnostic translates one shim diagnostic, taking its file, line, and
759
+ // column from whatever source file the diagnostic is anchored to.
760
+ func convertDiagnostic(d *ast.Diagnostic) Diagnostic {
761
+ diag := Diagnostic{Code: d.Code(), Message: d.String(), raw: d}
762
+ if file := d.File(); file != nil {
763
+ diag.File = file.FileName()
764
+ if pos := d.Pos(); pos >= 0 {
765
+ length := d.Len()
766
+ diag.Start = &pos
767
+ diag.Length = &length
768
+ line, col := shimscanner.GetECMALineAndByteOffsetOfPosition(file, pos)
769
+ diag.Line = line + 1
770
+ diag.Column = col + 1
771
+ }
772
+ }
773
+ return diag
774
+ }
@@ -23,9 +23,10 @@ type ModuleResolutionContext struct {
23
23
 
24
24
  // SupersedingModuleCandidates returns, per source-file envelope key, the
25
25
  // resolution candidates strictly ahead of each module target the loaded
26
- // program selected. A missing member can therefore become a freshness input
27
- // without making the whole resolution search (including lower-priority paths)
28
- // an invalidation input.
26
+ // program selected. When the selected target is reported by physical path, it
27
+ // also retains that candidate's lexical spelling. A missing member or a link
28
+ // retarget can therefore become a freshness input without making the whole
29
+ // resolution search (including lower-priority paths) an invalidation input.
29
30
  //
30
31
  // Candidate enumeration is host-owned. It mirrors the resident graph session's
31
32
  // speculation for relative modules, paths, rootDirs, package imports/exports,
@@ -75,7 +76,10 @@ func SupersedingModuleCandidates(prog *Program, cwd string) map[string][]string
75
76
  }
76
77
 
77
78
  // ModuleResolutionPredecessors returns the paths in the host-owned candidate
78
- // search that precede resolvedFileName. An unresolved specifier keeps the full
79
+ // search that precede resolvedFileName. When the compiler reports the winner by
80
+ // physical realpath, the result also retains that selected candidate's lexical
81
+ // spelling: retargeting its symlink/junction can change resolution without
82
+ // changing the old or new target bytes. An unresolved specifier keeps the full
79
83
  // candidate list through ModuleResolutionCandidates; a resolved specifier must
80
84
  // not track a lower-priority path because creating it cannot alter resolution.
81
85
  func ModuleResolutionPredecessors(
@@ -101,7 +105,11 @@ func ModuleResolutionPredecessors(
101
105
  // spellings already agree pays nothing for it.
102
106
  for index, candidate := range candidates {
103
107
  if sameExistingPath(candidate, resolvedFileName) {
104
- return compactStringsInOrder(candidates[:index])
108
+ end := index
109
+ if !sameCandidatePath(candidate, resolvedFileName, caseSensitive) {
110
+ end++
111
+ }
112
+ return compactStringsInOrder(candidates[:end])
105
113
  }
106
114
  }
107
115
  // Never widen freshness to candidates whose precedence relative to the