ttsc 0.26.2 → 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 (111) hide show
  1. package/README.md +9 -0
  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/emit_plugin.go +20 -3
  8. package/driver/graph.go +64 -11
  9. package/driver/input_observation.go +273 -0
  10. package/driver/input_observation_test.go +149 -0
  11. package/driver/plugins.go +384 -4
  12. package/driver/program.go +54 -1
  13. package/driver/resolution_candidates.go +13 -5
  14. package/driver/rewrite.go +45 -2
  15. package/lib/TtscCompiler.d.ts +12 -11
  16. package/lib/TtscCompiler.js +44 -29
  17. package/lib/TtscCompiler.js.map +1 -1
  18. package/lib/compiler/internal/captureProcessOutput.js +2 -2
  19. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  20. package/lib/compiler/internal/compileProjectInMemory.js +4 -3
  21. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  22. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -0
  23. package/lib/compiler/internal/resolveEmittedJavaScript.js +3 -0
  24. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  25. package/lib/compiler/internal/runBuild.d.ts +1 -1
  26. package/lib/compiler/internal/runBuild.js +80 -34
  27. package/lib/compiler/internal/runBuild.js.map +1 -1
  28. package/lib/compiler/internal/runSingleFileEmit.js +2 -2
  29. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  30. package/lib/compiler/internal/sharedHostHelpers.d.ts +28 -0
  31. package/lib/compiler/internal/sharedHostHelpers.js +39 -0
  32. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  33. package/lib/compiler/internal/startResidentTransform.js +12 -4
  34. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  35. package/lib/compiler/internal/transformProjectInMemory.d.ts +3 -0
  36. package/lib/compiler/internal/transformProjectInMemory.js +239 -35
  37. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  38. package/lib/flags/schema.js +12 -5
  39. package/lib/flags/schema.js.map +1 -1
  40. package/lib/internal/createCanonicalTempDirectory.d.ts +17 -0
  41. package/lib/internal/createCanonicalTempDirectory.js +42 -0
  42. package/lib/internal/createCanonicalTempDirectory.js.map +1 -0
  43. package/lib/internal/projectInputPathIdentity.d.ts +3 -0
  44. package/lib/internal/projectInputPathIdentity.js +11 -5
  45. package/lib/internal/projectInputPathIdentity.js.map +1 -1
  46. package/lib/internal/resolveNodeBinary.d.ts +14 -0
  47. package/lib/internal/resolveNodeBinary.js +150 -0
  48. package/lib/internal/resolveNodeBinary.js.map +1 -0
  49. package/lib/internal/resolveSafeCacheCleanupTargets.d.ts +15 -0
  50. package/lib/internal/resolveSafeCacheCleanupTargets.js +61 -0
  51. package/lib/internal/resolveSafeCacheCleanupTargets.js.map +1 -0
  52. package/lib/launcher/internal/prepareExecution.d.ts +3 -0
  53. package/lib/launcher/internal/prepareExecution.js +42 -14
  54. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  55. package/lib/launcher/internal/runTtsc.js +25 -13
  56. package/lib/launcher/internal/runTtsc.js.map +1 -1
  57. package/lib/launcher/internal/runTtscserver.js +18 -3
  58. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  59. package/lib/launcher/internal/runTtsx.js +2 -0
  60. package/lib/launcher/internal/runTtsx.js.map +1 -1
  61. package/lib/launcher/internal/runtimeHooks.d.ts +37 -1
  62. package/lib/launcher/internal/runtimeHooks.js +653 -62
  63. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  64. package/lib/plugin/internal/buildSourcePlugin.d.ts +62 -8
  65. package/lib/plugin/internal/buildSourcePlugin.js +754 -72
  66. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  67. package/lib/plugin/internal/loadProjectPlugins.d.ts +17 -5
  68. package/lib/plugin/internal/loadProjectPlugins.js +1339 -77
  69. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  70. package/lib/register.d.ts +1 -0
  71. package/lib/register.js +46 -0
  72. package/lib/register.js.map +1 -0
  73. package/lib/structures/ITtscCompilerContext.d.ts +6 -3
  74. package/lib/structures/ITtscCompilerTransformation.d.ts +31 -0
  75. package/lib/structures/ITtscPlugin.d.ts +33 -0
  76. package/package.json +16 -9
  77. package/shim/checker/go.mod +9 -0
  78. package/shim/checker/go.sum +22 -0
  79. package/shim/checker/shim.go +75 -0
  80. package/shim/compiler/incremental.go +45 -8
  81. package/src/TtscCompiler.ts +55 -33
  82. package/src/compiler/internal/captureProcessOutput.ts +3 -2
  83. package/src/compiler/internal/compileProjectInMemory.ts +4 -3
  84. package/src/compiler/internal/resolveEmittedJavaScript.ts +6 -0
  85. package/src/compiler/internal/runBuild.ts +102 -39
  86. package/src/compiler/internal/runSingleFileEmit.ts +2 -2
  87. package/src/compiler/internal/sharedHostHelpers.ts +44 -0
  88. package/src/compiler/internal/startResidentTransform.ts +12 -3
  89. package/src/compiler/internal/transformProjectInMemory.ts +410 -39
  90. package/src/flags/schema.ts +12 -5
  91. package/src/internal/createCanonicalTempDirectory.ts +53 -0
  92. package/src/internal/projectInputPathIdentity.ts +17 -4
  93. package/src/internal/resolveNodeBinary.ts +173 -0
  94. package/src/internal/resolveSafeCacheCleanupTargets.ts +85 -0
  95. package/src/launcher/internal/prepareExecution.ts +50 -14
  96. package/src/launcher/internal/runTtsc.ts +24 -13
  97. package/src/launcher/internal/runTtscserver.ts +18 -3
  98. package/src/launcher/internal/runTtsx.ts +2 -0
  99. package/src/launcher/internal/runtimeHooks.ts +823 -66
  100. package/src/plugin/internal/buildSourcePlugin.ts +984 -79
  101. package/src/plugin/internal/loadProjectPlugins.ts +1718 -104
  102. package/src/register.ts +47 -0
  103. package/src/structures/ITtscCompilerContext.ts +6 -3
  104. package/src/structures/ITtscCompilerTransformation.ts +39 -0
  105. package/src/structures/ITtscPlugin.ts +36 -0
  106. package/tools/shim_audit/baseline.json +0 -1
  107. package/utility/host.go +9 -3
  108. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +0 -5
  109. package/lib/internal/assertSafeExplicitCacheDirectory.js +0 -26
  110. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +0 -1
  111. package/src/internal/assertSafeExplicitCacheDirectory.ts +0 -29
package/README.md CHANGED
@@ -30,6 +30,15 @@ npx ttsc --watch # rebuild on save
30
30
 
31
31
  `ttsx` runs a file the way `tsx` or `ts-node` does, but it type-checks the whole project first, so a type error stops the run before anything executes.
32
32
 
33
+ Preload the same checked runtime when Node or a test runner owns the process:
34
+
35
+ ```bash
36
+ node --require ttsc/register src/index.ts
37
+ mocha --require ttsc/register --extension ts,tsx "test/**/*.ts"
38
+ ```
39
+
40
+ `ttsc/register` discovers the nearest `tsconfig.json` for each TypeScript root, applies its plugins, and refuses to execute when the project does not type-check.
41
+
33
42
  That covers the CLI. The integrations each have a short guide:
34
43
 
35
44
  - [`@ttsc/unplugin`](https://ttsc.dev/docs/setup/unplugin): Vite, Rollup, Rolldown, esbuild, webpack, Rspack, Next.js, Turbopack, Farm, and Bun.
@@ -26,6 +26,11 @@ type apiTransformResult struct {
26
26
  // content can influence a transformed module, so bundler caches invalidate
27
27
  // soundly without per-plugin reporting.
28
28
  Graph *driver.TransformGraph `json:"graph,omitempty"`
29
+ // HostInputs are absolute native plugin config files evaluated while this
30
+ // generation loaded. JavaScript hosts merge them with descriptor inputs.
31
+ HostInputs []string `json:"hostInputs,omitempty"`
32
+ HostInputHashes map[string]*string `json:"hostInputHashes,omitempty"`
33
+ HostInputRealpaths map[string]*string `json:"hostInputRealpaths,omitempty"`
29
34
  }
30
35
 
31
36
  // runAPITransform implements the `api-transform` sub-command. It loads the
@@ -70,6 +75,9 @@ func runAPITransform(args []string) int {
70
75
  }
71
76
  typescript := map[string]string{}
72
77
  var graph *driver.TransformGraph
78
+ var hostInputs []string
79
+ var hostInputHashes map[string]*string
80
+ var hostInputRealpaths map[string]*string
73
81
  if prog != nil {
74
82
  defer prog.Close()
75
83
  // Compute the reference graph before SourceFiles() runs linked plugin
@@ -81,12 +89,18 @@ func runAPITransform(args []string) int {
81
89
  typescript[apiOutputKey(cwd, file.FileName())] = file.Text()
82
90
  }
83
91
  diags = append(diags, prog.Diagnostics()...)
92
+ hostInputs = prog.PluginHostInputs()
93
+ hostInputHashes = prog.PluginHostInputHashes()
94
+ hostInputRealpaths = prog.PluginHostInputRealpaths()
84
95
  }
85
96
 
86
97
  result := apiTransformResult{
87
- Diagnostics: make([]apiCompileDiagnostic, 0, len(diags)),
88
- TypeScript: typescript,
89
- Graph: graph,
98
+ Diagnostics: make([]apiCompileDiagnostic, 0, len(diags)),
99
+ Graph: graph,
100
+ HostInputs: hostInputs,
101
+ HostInputHashes: hostInputHashes,
102
+ HostInputRealpaths: hostInputRealpaths,
103
+ TypeScript: typescript,
90
104
  }
91
105
  for _, diag := range diags {
92
106
  result.Diagnostics = append(result.Diagnostics, toAPICompileDiagnostic(diag))
@@ -0,0 +1,85 @@
1
+ package main
2
+
3
+ import (
4
+ "os"
5
+ "os/exec"
6
+ "path/filepath"
7
+ "runtime"
8
+ "testing"
9
+ )
10
+
11
+ // TestAuxiliaryIdentityStateTracksRetargetsNotSourceContents proves the two
12
+ // owners of a selected lexical module path stay separate:
13
+ //
14
+ // 1. ordinary source bytes are owned by graphSession.sourceHashes, so an
15
+ // identity-only auxiliary state must not turn their edit into a reload;
16
+ // 2. retargeting the same lexical symlink or junction must invalidate even
17
+ // when the old and new target bytes agree.
18
+ func TestAuxiliaryIdentityStateTracksRetargetsNotSourceContents(t *testing.T) {
19
+ root := t.TempDir()
20
+ first := filepath.Join(root, "first")
21
+ second := filepath.Join(root, "second")
22
+ if err := os.MkdirAll(first, 0o755); err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ if err := os.MkdirAll(second, 0o755); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ for _, directory := range []string{first, second} {
29
+ if err := os.WriteFile(filepath.Join(directory, "selection.ts"), []byte("export const value = 1;\n"), 0o644); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ }
33
+
34
+ link := filepath.Join(root, "linked")
35
+ createAuxiliaryDirectoryLink(t, first, link)
36
+ selected := filepath.Join(link, "selection.ts")
37
+ previous := captureDiskStates([]auxiliaryInput{{path: selected, identityOnly: true}})
38
+ initial := previous[selected]
39
+ if !initial.Exists || initial.Realpath == "" || !initial.IdentityOnly {
40
+ t.Fatalf("initial identity state = %+v", initial)
41
+ }
42
+
43
+ if err := os.WriteFile(filepath.Join(first, "selection.ts"), []byte("export const value = 2;\n"), 0o644); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if diskStatesChanged(previous) {
47
+ t.Fatal("selected source contents leaked into identity-only auxiliary invalidation")
48
+ }
49
+
50
+ if err := os.Remove(link); err != nil {
51
+ t.Fatal(err)
52
+ }
53
+ createAuxiliaryDirectoryLink(t, second, link)
54
+ if !diskStatesChanged(previous) {
55
+ t.Fatal("same-byte lexical link retarget did not invalidate its physical identity")
56
+ }
57
+
58
+ merged := compactAuxiliaryInputs([]auxiliaryInput{
59
+ {path: selected, identityOnly: true},
60
+ {path: selected},
61
+ })
62
+ if len(merged) != 1 || merged[0].identityOnly {
63
+ t.Fatalf("content-sensitive duplicate did not win: %+v", merged)
64
+ }
65
+ }
66
+
67
+ func createAuxiliaryDirectoryLink(t *testing.T, target, link string) {
68
+ t.Helper()
69
+ if runtime.GOOS == "windows" {
70
+ command := exec.Command(
71
+ "node",
72
+ "-e",
73
+ `require("node:fs").symlinkSync(process.argv[1], process.argv[2], "junction")`,
74
+ target,
75
+ link,
76
+ )
77
+ if output, err := command.CombinedOutput(); err != nil {
78
+ t.Skipf("directory junction unavailable on this host: %v: %s", err, output)
79
+ }
80
+ return
81
+ }
82
+ if err := os.Symlink(target, link); err != nil {
83
+ t.Skipf("directory symlink unavailable on this host: %v", err)
84
+ }
85
+ }
@@ -0,0 +1,16 @@
1
+ //go:build !windows
2
+
3
+ package main
4
+
5
+ import "path/filepath"
6
+
7
+ func diskRealpath(path string) string {
8
+ resolved, err := filepath.EvalSymlinks(path)
9
+ if err != nil {
10
+ return ""
11
+ }
12
+ if absolute, err := filepath.Abs(resolved); err == nil {
13
+ resolved = absolute
14
+ }
15
+ return filepath.Clean(resolved)
16
+ }
@@ -0,0 +1,56 @@
1
+ //go:build windows
2
+
3
+ package main
4
+
5
+ import (
6
+ "path/filepath"
7
+ "strings"
8
+
9
+ "golang.org/x/sys/windows"
10
+ )
11
+
12
+ // diskRealpath asks the opened object for its final path. filepath.EvalSymlinks
13
+ // can leave a readable file below a directory junction unresolved on Windows,
14
+ // which would erase the physical identity this state exists to retain.
15
+ func diskRealpath(path string) string {
16
+ pointer, err := windows.UTF16PtrFromString(path)
17
+ if err != nil {
18
+ return ""
19
+ }
20
+ handle, err := windows.CreateFile(
21
+ pointer,
22
+ windows.FILE_READ_ATTRIBUTES,
23
+ windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
24
+ nil,
25
+ windows.OPEN_EXISTING,
26
+ windows.FILE_FLAG_BACKUP_SEMANTICS,
27
+ 0,
28
+ )
29
+ if err != nil {
30
+ return ""
31
+ }
32
+ defer windows.CloseHandle(handle)
33
+
34
+ buffer := make([]uint16, windows.MAX_PATH)
35
+ for {
36
+ length, err := windows.GetFinalPathNameByHandle(
37
+ handle,
38
+ &buffer[0],
39
+ uint32(len(buffer)),
40
+ 0,
41
+ )
42
+ if err != nil {
43
+ return ""
44
+ }
45
+ if length < uint32(len(buffer)) {
46
+ resolved := windows.UTF16ToString(buffer[:length])
47
+ if strings.HasPrefix(resolved, `\\?\UNC\`) {
48
+ resolved = `\\` + strings.TrimPrefix(resolved, `\\?\UNC\`)
49
+ } else {
50
+ resolved = strings.TrimPrefix(resolved, `\\?\`)
51
+ }
52
+ return filepath.Clean(resolved)
53
+ }
54
+ buffer = make([]uint16, length+1)
55
+ }
56
+ }
@@ -385,9 +385,11 @@ func (s *graphSession) captureState() error {
385
385
  return err
386
386
  }
387
387
  inputs := auxiliaryInputs(program, configs, s.cwd)
388
- inputs = append(inputs, missingRootInputs(configs, sourceHashes)...)
388
+ for _, path := range missingRootInputs(configs, sourceHashes) {
389
+ inputs = append(inputs, auxiliaryInput{path: path})
390
+ }
389
391
  s.configHashes = configHashes
390
- s.auxStates = captureDiskStates(compactSortedStrings(inputs))
392
+ s.auxStates = captureDiskStates(compactAuxiliaryInputs(inputs))
391
393
  s.sourceHashes = sourceHashes
392
394
  s.diskDigests = diskDigests
393
395
  s.rootFiles = projectRootFilesFromConfigs(configs, false)
@@ -661,8 +663,19 @@ func changedSources(previous map[string][sha256.Size]byte) (map[string]string, b
661
663
  }
662
664
 
663
665
  type diskState struct {
664
- Hash [sha256.Size]byte
665
- Exists bool
666
+ Hash [sha256.Size]byte
667
+ Exists bool
668
+ Realpath string
669
+ IdentityOnly bool
670
+ }
671
+
672
+ // auxiliaryInput distinguishes speculative inputs whose contents select the
673
+ // build universe from the lexical spelling of the source the compiler already
674
+ // selected. The resident source hash owns the latter's contents; this input
675
+ // owns only whether the spelling still reaches the same physical file.
676
+ type auxiliaryInput struct {
677
+ path string
678
+ identityOnly bool
666
679
  }
667
680
 
668
681
  // captureDiskStates records the freshness state of speculative resolution
@@ -671,29 +684,35 @@ type diskState struct {
671
684
  // Windows), so any path that is neither a readable file nor a directory is
672
685
  // recorded as absent instead of failing the snapshot: the recorded state only
673
686
  // needs to flip when the candidate becomes resolvable.
674
- func captureDiskStates(paths []string) map[string]diskState {
675
- states := make(map[string]diskState, len(paths))
676
- for _, path := range paths {
677
- content, err := os.ReadFile(path)
687
+ func captureDiskStates(inputs []auxiliaryInput) map[string]diskState {
688
+ states := make(map[string]diskState, len(inputs))
689
+ for _, input := range inputs {
690
+ state := diskState{IdentityOnly: input.identityOnly}
691
+ content, err := os.ReadFile(input.path)
678
692
  if err != nil {
679
- if info, statErr := os.Stat(path); statErr == nil && info.IsDir() {
680
- states[path] = diskState{Exists: true}
681
- } else {
682
- states[path] = diskState{}
693
+ if info, statErr := os.Stat(input.path); statErr == nil && info.IsDir() {
694
+ state.Exists = true
695
+ state.Realpath = diskRealpath(input.path)
683
696
  }
697
+ states[input.path] = state
684
698
  continue
685
699
  }
686
- states[path] = diskState{Hash: sha256.Sum256(content), Exists: true}
700
+ state.Exists = true
701
+ state.Realpath = diskRealpath(input.path)
702
+ if !input.identityOnly {
703
+ state.Hash = sha256.Sum256(content)
704
+ }
705
+ states[input.path] = state
687
706
  }
688
707
  return states
689
708
  }
690
709
 
691
710
  func diskStatesChanged(previous map[string]diskState) bool {
692
- paths := make([]string, 0, len(previous))
693
- for path := range previous {
694
- paths = append(paths, path)
711
+ inputs := make([]auxiliaryInput, 0, len(previous))
712
+ for path, state := range previous {
713
+ inputs = append(inputs, auxiliaryInput{path: path, identityOnly: state.IdentityOnly})
695
714
  }
696
- current := captureDiskStates(paths)
715
+ current := captureDiskStates(inputs)
697
716
  for path, state := range previous {
698
717
  if current[path] != state {
699
718
  return true
@@ -702,16 +721,16 @@ func diskStatesChanged(previous map[string]diskState) bool {
702
721
  return false
703
722
  }
704
723
 
705
- func auxiliaryInputs(program *driver.Program, configs []*shimtsoptions.ParsedCommandLine, cwd string) []string {
706
- inputs := []string{
707
- filepath.Join(cwd, ".gitignore"),
708
- filepath.Join(cwd, ".git", "info", "exclude"),
709
- filepath.Join(cwd, "package.json"),
710
- filepath.Join(cwd, "package-lock.json"),
711
- filepath.Join(cwd, "pnpm-lock.yaml"),
712
- filepath.Join(cwd, "yarn.lock"),
713
- filepath.Join(cwd, "bun.lock"),
714
- filepath.Join(cwd, "bun.lockb"),
724
+ func auxiliaryInputs(program *driver.Program, configs []*shimtsoptions.ParsedCommandLine, cwd string) []auxiliaryInput {
725
+ inputs := []auxiliaryInput{
726
+ {path: filepath.Join(cwd, ".gitignore")},
727
+ {path: filepath.Join(cwd, ".git", "info", "exclude")},
728
+ {path: filepath.Join(cwd, "package.json")},
729
+ {path: filepath.Join(cwd, "package-lock.json")},
730
+ {path: filepath.Join(cwd, "pnpm-lock.yaml")},
731
+ {path: filepath.Join(cwd, "yarn.lock")},
732
+ {path: filepath.Join(cwd, "bun.lock")},
733
+ {path: filepath.Join(cwd, "bun.lockb")},
715
734
  }
716
735
  for _, source := range program.TSProgram.SourceFiles() {
717
736
  file := source.FileName()
@@ -719,12 +738,18 @@ func auxiliaryInputs(program *driver.Program, configs []*shimtsoptions.ParsedCom
719
738
  continue
720
739
  }
721
740
  directory := filepath.Dir(file)
722
- inputs = appendAncestorInputs(inputs, directory, cwd)
741
+ for _, path := range appendAncestorInputs(nil, directory, cwd) {
742
+ inputs = append(inputs, auxiliaryInput{path: path})
743
+ }
723
744
  for _, reference := range source.ReferencedFiles {
724
- inputs = append(inputs, driver.FileCandidates(filepath.Join(directory, filepath.FromSlash(reference.FileName)))...)
745
+ for _, path := range driver.FileCandidates(filepath.Join(directory, filepath.FromSlash(reference.FileName))) {
746
+ inputs = append(inputs, auxiliaryInput{path: path})
747
+ }
725
748
  }
726
749
  for _, reference := range source.TypeReferenceDirectives {
727
- inputs = append(inputs, driver.TypeReferenceCandidates(configs, directory, cwd, reference.FileName)...)
750
+ for _, path := range driver.TypeReferenceCandidates(configs, directory, cwd, reference.FileName) {
751
+ inputs = append(inputs, auxiliaryInput{path: path})
752
+ }
728
753
  }
729
754
  for _, specifier := range driver.SourceModuleSpecifiers(source) {
730
755
  context := driver.ModuleResolutionContext{
@@ -738,7 +763,7 @@ func auxiliaryInputs(program *driver.Program, configs []*shimtsoptions.ParsedCom
738
763
  }
739
764
  resolved := program.TSProgram.GetResolvedModuleFromModuleSpecifier(source, specifier)
740
765
  if resolved != nil && resolved.IsResolved() {
741
- inputs = append(inputs, driver.ModuleResolutionPredecessors(
766
+ predecessors := driver.ModuleResolutionPredecessors(
742
767
  configs,
743
768
  directory,
744
769
  cwd,
@@ -746,10 +771,18 @@ func auxiliaryInputs(program *driver.Program, configs []*shimtsoptions.ParsedCom
746
771
  resolved.ResolvedFileName,
747
772
  program.FS.UseCaseSensitiveFileNames(),
748
773
  context,
749
- )...)
774
+ )
775
+ for _, path := range predecessors {
776
+ inputs = append(inputs, auxiliaryInput{
777
+ path: path,
778
+ identityOnly: sameExistingAuxiliaryPath(path, resolved.ResolvedFileName),
779
+ })
780
+ }
750
781
  continue
751
782
  }
752
- inputs = append(inputs, driver.ModuleResolutionCandidates(configs, directory, cwd, specifier.Text(), context)...)
783
+ for _, path := range driver.ModuleResolutionCandidates(configs, directory, cwd, specifier.Text(), context) {
784
+ inputs = append(inputs, auxiliaryInput{path: path})
785
+ }
753
786
  }
754
787
  }
755
788
  // Config `types` entries request type packages without any source syntax, so
@@ -760,10 +793,46 @@ func auxiliaryInputs(program *driver.Program, configs []*shimtsoptions.ParsedCom
760
793
  continue
761
794
  }
762
795
  for _, name := range parsed.ParsedConfig.CompilerOptions.Types {
763
- inputs = append(inputs, driver.TypeReferenceCandidates(configs, parsed.GetCurrentDirectory(), cwd, name)...)
796
+ for _, path := range driver.TypeReferenceCandidates(configs, parsed.GetCurrentDirectory(), cwd, name) {
797
+ inputs = append(inputs, auxiliaryInput{path: path})
798
+ }
764
799
  }
765
800
  }
766
- return compactSortedStrings(inputs)
801
+ return compactAuxiliaryInputs(inputs)
802
+ }
803
+
804
+ func sameExistingAuxiliaryPath(left, right string) bool {
805
+ leftInfo, err := os.Stat(left)
806
+ if err != nil {
807
+ return false
808
+ }
809
+ rightInfo, err := os.Stat(right)
810
+ return err == nil && os.SameFile(leftInfo, rightInfo)
811
+ }
812
+
813
+ func compactAuxiliaryInputs(inputs []auxiliaryInput) []auxiliaryInput {
814
+ byPath := make(map[string]auxiliaryInput, len(inputs))
815
+ for _, input := range inputs {
816
+ if strings.TrimSpace(input.path) == "" {
817
+ continue
818
+ }
819
+ if previous, exists := byPath[input.path]; exists {
820
+ // A path that also participates as a manifest or other content-bearing
821
+ // input keeps the stronger content-sensitive contract.
822
+ input.identityOnly = previous.identityOnly && input.identityOnly
823
+ }
824
+ byPath[input.path] = input
825
+ }
826
+ paths := make([]string, 0, len(byPath))
827
+ for path := range byPath {
828
+ paths = append(paths, path)
829
+ }
830
+ sort.Strings(paths)
831
+ output := make([]auxiliaryInput, 0, len(paths))
832
+ for _, path := range paths {
833
+ output = append(output, byPath[path])
834
+ }
835
+ return output
767
836
  }
768
837
 
769
838
  func appendAncestorInputs(inputs []string, directory, stop string) []string {
@@ -1,7 +1,6 @@
1
1
  package driver
2
2
 
3
3
  import (
4
- "context"
5
4
  "errors"
6
5
  "strings"
7
6
  "sync"
@@ -222,12 +221,30 @@ func (p *Program) EmitWithPluginTransformers(transforms []PluginTransform, write
222
221
  }
223
222
  }
224
223
  }
225
- if !options.GetEmitDeclarations() {
224
+ // The declaration pass below doubles as this lane's build-information pass,
225
+ // so it also runs for a JavaScript-only `incremental` / `composite` project
226
+ // that has no declarations to write at all.
227
+ if !options.GetEmitDeclarations() && !p.emitsBuildInfo() {
226
228
  return nil, nil
227
229
  }
228
230
 
231
+ // What the build information this pass writes does and does not claim.
232
+ //
233
+ // The JavaScript above was hand-assembled and written outside tsgo's
234
+ // emitter, so tsgo's snapshot never saw it happen and records the JS emit of
235
+ // every file as still pending. That error is one-directional and safe: a
236
+ // consumer reading it can only decide to emit again, never to skip a file
237
+ // ttsc actually transformed. Making the record exact would mean running
238
+ // tsgo's own JavaScript emit a second time and discarding its output, paying
239
+ // a full emit to describe work already done. Everything else in the file —
240
+ // the compiler version, the resolved options, per-file versions and
241
+ // signatures, and the declaration state this pass does produce — is accurate.
242
+ //
243
+ // ttsc itself never reads build information back (see
244
+ // `shimcompiler.EmitFreshWithBuildInfo`), so this asymmetry costs a ttsc
245
+ // rebuild nothing.
229
246
  var wfMu sync.Mutex
230
- result := p.TSProgram.Emit(context.Background(), shimcompiler.EmitOptions{
247
+ result := p.emitProgram(shimcompiler.EmitOptions{
231
248
  EmitOnly: shimcompiler.EmitOnlyDts,
232
249
  WriteFile: func(fileName string, text string, data *shimcompiler.WriteFileData) error {
233
250
  wfMu.Lock()
package/driver/graph.go CHANGED
@@ -23,9 +23,10 @@ const bundledScheme = "bundled:///"
23
23
  //
24
24
  // - Edges maps each file to its direct resolved references — imports,
25
25
  // re-exports, `/// <reference>` targets, type reference directives, and
26
- // ambient-module declaration files, type-only edges included. Direct
27
- // edges are the minimal sufficient statistic; consumers that need a flat
28
- // per-file list compute the reachability closure themselves.
26
+ // ambient-module declaration files, type-only edges included. A leaf file
27
+ // has an empty list so the node and its compiler-time input proof remain
28
+ // explicit. Direct edges are the minimal sufficient statistic; consumers
29
+ // that need a flat per-file list compute the reachability closure themselves.
29
30
  // - Globals lists the files that contribute to the global scope (ambient
30
31
  // declaration files, script files, global augmentations, `typeRoots`
31
32
  // entries). A change to any of them can affect every file.
@@ -39,10 +40,12 @@ const bundledScheme = "bundled:///"
39
40
  // map: project-relative slash paths, falling back to slash-normalized
40
41
  // absolute paths outside the project root (see TransformOutputKey).
41
42
  type TransformGraph struct {
42
- Edges map[string][]string `json:"edges"`
43
- Globals []string `json:"globals"`
44
- Configs []string `json:"configs"`
45
- Candidates map[string][]string `json:"candidates,omitempty"`
43
+ Edges map[string][]string `json:"edges"`
44
+ Globals []string `json:"globals"`
45
+ Configs []string `json:"configs"`
46
+ Candidates map[string][]string `json:"candidates,omitempty"`
47
+ InputHashes map[string]*string `json:"inputHashes,omitempty"`
48
+ InputRealpaths map[string]*string `json:"inputRealpaths,omitempty"`
46
49
  }
47
50
 
48
51
  // NewTransformGraph computes the reference graph of a loaded program, keyed
@@ -69,16 +72,66 @@ func NewTransformGraph(prog *Program, cwd string) *TransformGraph {
69
72
  if shimcompiler.FileAffectsGlobalScope(file) {
70
73
  graph.Globals = append(graph.Globals, key)
71
74
  }
72
- targets := referenceTargets(prog, cwd, file)
73
- if len(targets) != 0 {
74
- graph.Edges[key] = targets
75
- }
75
+ // Keep leaf modules as empty adjacency-list entries. Besides making the
76
+ // graph's node universe explicit, this lets attachInputProof bind every
77
+ // source file to the bytes TypeScript-Go actually read. Omitting a leaf
78
+ // would let an A-B-A edit during compilation pair B's output with identical
79
+ // pre/post project snapshots for A.
80
+ graph.Edges[key] = referenceTargets(prog, cwd, file)
76
81
  }
77
82
  sort.Strings(graph.Globals)
78
83
  graph.Configs = configChain(prog, cwd)
84
+ graph.attachInputProof(prog, cwd)
79
85
  return graph
80
86
  }
81
87
 
88
+ // attachInputProof pairs every graph path with the state the compiler
89
+ // filesystem actually returned while constructing the resident Program. A
90
+ // missing entry means proof was incomplete or contradictory; persistent hosts
91
+ // then keep the fresh result but decline cross-build reuse.
92
+ func (graph *TransformGraph) attachInputProof(prog *Program, cwd string) {
93
+ if prog == nil || prog.inputObserver == nil {
94
+ return
95
+ }
96
+ inputs := map[string]struct{}{}
97
+ for source, targets := range graph.Edges {
98
+ inputs[source] = struct{}{}
99
+ for _, target := range targets {
100
+ inputs[target] = struct{}{}
101
+ }
102
+ }
103
+ for _, input := range graph.Globals {
104
+ inputs[input] = struct{}{}
105
+ }
106
+ for _, input := range graph.Configs {
107
+ inputs[input] = struct{}{}
108
+ }
109
+ for source, candidates := range graph.Candidates {
110
+ inputs[source] = struct{}{}
111
+ for _, candidate := range candidates {
112
+ inputs[candidate] = struct{}{}
113
+ }
114
+ }
115
+ hashes := map[string]*string{}
116
+ realpaths := map[string]*string{}
117
+ for input := range inputs {
118
+ file := filepath.FromSlash(input)
119
+ if !filepath.IsAbs(file) {
120
+ file = filepath.Join(cwd, file)
121
+ }
122
+ hash, realpath, ok := prog.inputObserver.proof(file)
123
+ if !ok {
124
+ continue
125
+ }
126
+ hashes[input] = hash
127
+ realpaths[input] = realpath
128
+ }
129
+ if len(hashes) != 0 {
130
+ graph.InputHashes = hashes
131
+ graph.InputRealpaths = realpaths
132
+ }
133
+ }
134
+
82
135
  // referenceTargets resolves one file's direct reference set to sorted envelope
83
136
  // keys, dropping bundled library files and the file itself.
84
137
  func referenceTargets(prog *Program, cwd string, file *ast.SourceFile) []string {